Skip to content

Commit b6e479c

Browse files
behnam-oBehnam Ousat
andauthored
FIX: bound attack conversation IDs before indexing (#2317)
Co-authored-by: Behnam Ousat <behnamousat@microsoft.com>
1 parent cab4ba5 commit b6e479c

5 files changed

Lines changed: 54 additions & 2 deletions

File tree

.github/copilot-instructions.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ Aim for fewer, higher-signal comments. A review with 2-3 important comments is b
2929

3030
BEFORE editing or code-reviewing any file, you MUST read the `.github/instructions/` files whose `applyTo` patterns match the files you are about to edit. For example:
3131
- Editing/code-reviewing `pyrit/**/*.py` → read `style-guide.instructions.md` and `user-custom.instructions.md`
32+
- Editing/code-reviewing database models, Alembic migrations, or memory migration tests → also read `database.instructions.md`
3233
- Editing/code-reviewing `pyrit/scenario/**` → also read `scenarios.instructions.md`
3334
- Editing/code-reviewing `pyrit/setup/initializers/techniques/**` → also read `setup-techniques.instructions.md`
3435
- Editing/code-reviewing `pyrit/converter/**` → also read `converters.instructions.md`
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
description: "Use when editing database models, SQLAlchemy schemas, Alembic migrations, indexes, keys, constraints, or database migration tests for SQLite and SQL Server."
3+
applyTo: "pyrit/memory/**/*.py, tests/**/memory/**/*.py"
4+
---
5+
6+
# Database Portability Guidelines
7+
8+
- Test schema and migration changes against both SQLite and SQL Server semantics. SQLite accepts
9+
text columns as index keys, while SQL Server rejects `VARCHAR(MAX)` / `NVARCHAR(MAX)` key columns.
10+
- Give every `String` / `Unicode` column used in a primary key, foreign key, index, or unique
11+
constraint an explicit, appropriately bounded length in both ORM models and Alembic migrations.
12+
- When correcting an existing unbounded indexed column, alter it to the bounded type before
13+
creating the index. Keep the operation dialect-aware when a backend such as SQLite does not
14+
support the same `ALTER COLUMN` operation.

pyrit/memory/alembic/versions/d7e9f1a3b5c6_index_attack_result_recency.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
composite ``(timestamp, id)`` index (serves the recency ORDER BY + keyset seek). Backfills
1010
``timestamp`` from the legacy ``attack_metadata.updated_at``/``created_at`` JSON keys so
1111
manually-edited conversations keep their current History order, and drops the now-redundant
12-
``updated_at`` key from the JSON metadata.
12+
``updated_at`` key from the JSON metadata. Narrows ``conversation_id`` to ``VARCHAR(36)`` on
13+
all backends so the persisted schema matches the ORM model; this is required on SQL Server
14+
because ``VARCHAR(MAX)`` columns cannot be index keys.
1315
1416
Revision ID: d7e9f1a3b5c6
1517
Revises: 3f6e8a0c2d4b
@@ -40,6 +42,7 @@
4042
# executemany per batch keeps the number of statements (and, on SQL Server, database roundtrips)
4143
# proportional to the row count / batch size instead of one statement per row.
4244
_BACKFILL_UPDATE_BATCH_SIZE = 400
45+
_CONVERSATION_ID_LENGTH = 36
4346

4447

4548
def _attack_results_table() -> sa.Table:
@@ -80,6 +83,7 @@ def _parse_iso(value: object) -> datetime | None:
8083

8184
def upgrade() -> None:
8285
"""Apply this schema upgrade."""
86+
_set_conversation_id_type(length=_CONVERSATION_ID_LENGTH)
8387
op.create_index(
8488
"ix_AttackResultEntries_conversation_id",
8589
"AttackResultEntries",
@@ -102,6 +106,27 @@ def downgrade() -> None:
102106
_restore_updated_at_to_metadata()
103107
op.drop_index("ix_AttackResultEntries_timestamp_id", table_name="AttackResultEntries")
104108
op.drop_index("ix_AttackResultEntries_conversation_id", table_name="AttackResultEntries")
109+
_set_conversation_id_type(length=None)
110+
111+
112+
def _set_conversation_id_type(*, length: int | None) -> None:
113+
"""
114+
Set ``conversation_id`` to bounded or unbounded ``VARCHAR``.
115+
116+
Batch alteration recreates the table on SQLite, which does not support ``ALTER COLUMN``
117+
directly, and emits an in-place alteration on SQL Server. The bounded type keeps the
118+
persisted schema aligned with the ORM model and makes the column indexable on SQL Server.
119+
120+
Args:
121+
length (int | None): The target ``VARCHAR`` length, or ``None`` for ``VARCHAR(MAX)``.
122+
"""
123+
with op.batch_alter_table("AttackResultEntries") as batch_op:
124+
batch_op.alter_column(
125+
"conversation_id",
126+
existing_type=sa.String(),
127+
type_=sa.String(length=length),
128+
existing_nullable=False,
129+
)
105130

106131

107132
def _backfill_timestamp_from_metadata() -> None:

pyrit/memory/memory_models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1557,7 +1557,7 @@ class AttackResultEntry(Base):
15571557
{"extend_existing": True},
15581558
)
15591559
id = mapped_column(CustomUUID, nullable=False, primary_key=True)
1560-
conversation_id = mapped_column(String, nullable=False)
1560+
conversation_id = mapped_column(String(36), nullable=False)
15611561
objective = mapped_column(Unicode, nullable=False)
15621562
atomic_attack_identifier: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
15631563
atomic_attack_identifier_hash: Mapped[str | None] = mapped_column(

tests/unit/memory/test_migration.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2315,6 +2315,11 @@ def test_attack_recency_upgrade_creates_indexes_and_backfills_timestamp():
23152315
command.upgrade(config, _ATTACK_RECENCY_REV)
23162316

23172317
index_names = {ix["name"] for ix in inspect(connection).get_indexes("AttackResultEntries")}
2318+
conversation_id_column = next(
2319+
column
2320+
for column in inspect(connection).get_columns("AttackResultEntries")
2321+
if column["name"] == "conversation_id"
2322+
)
23182323
rows = dict(
23192324
connection.execute(text('SELECT id, attack_metadata FROM "AttackResultEntries"')).fetchall()
23202325
)
@@ -2324,6 +2329,7 @@ def test_attack_recency_upgrade_creates_indexes_and_backfills_timestamp():
23242329

23252330
assert "ix_AttackResultEntries_conversation_id" in index_names
23262331
assert "ix_AttackResultEntries_timestamp_id" in index_names
2332+
assert conversation_id_column["type"].length == 36
23272333

23282334
edited_metadata = json.loads(rows[edited_id])
23292335
assert "updated_at" not in edited_metadata
@@ -2364,6 +2370,11 @@ def test_attack_recency_downgrade_restores_updated_at_and_drops_indexes():
23642370
command.downgrade(config, _ATTACK_RECENCY_PREV_REV)
23652371

23662372
index_names_down = {ix["name"] for ix in inspect(connection).get_indexes("AttackResultEntries")}
2373+
conversation_id_column_down = next(
2374+
column
2375+
for column in inspect(connection).get_columns("AttackResultEntries")
2376+
if column["name"] == "conversation_id"
2377+
)
23672378
restored = json.loads(
23682379
connection.execute(
23692380
text('SELECT attack_metadata FROM "AttackResultEntries" WHERE id = :id'),
@@ -2373,6 +2384,7 @@ def test_attack_recency_downgrade_restores_updated_at_and_drops_indexes():
23732384

23742385
assert "ix_AttackResultEntries_conversation_id" not in index_names_down
23752386
assert "ix_AttackResultEntries_timestamp_id" not in index_names_down
2387+
assert conversation_id_column_down["type"].length is None
23762388
assert restored["created_at"] == "2026-06-01T00:00:00+00:00"
23772389
assert restored["updated_at"].startswith("2026-06-01T12:00:00")
23782390
finally:

0 commit comments

Comments
 (0)