-
Notifications
You must be signed in to change notification settings - Fork 823
MAINT: Ordering AttackResults by timestamp for perf #2295
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,153 @@ | ||
| # Copyright (c) Microsoft Corporation. | ||
| # Licensed under the MIT license. | ||
|
|
||
| """ | ||
| Index AttackResultEntries for the History recency query and make the ``timestamp`` | ||
| column the single source of truth for "last updated". | ||
|
|
||
| Adds an index on ``conversation_id`` (serves the per-conversation dedup window) and a | ||
| composite ``(timestamp, id)`` index (serves the recency ORDER BY + keyset seek). Backfills | ||
| ``timestamp`` from the legacy ``attack_metadata.updated_at``/``created_at`` JSON keys so | ||
| manually-edited conversations keep their current History order, and drops the now-redundant | ||
| ``updated_at`` key from the JSON metadata. | ||
|
|
||
| Revision ID: d7e9f1a3b5c6 | ||
| Revises: 3f6e8a0c2d4b | ||
| Create Date: 2026-07-20 12:00:00.000000 | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import logging | ||
| from collections.abc import Sequence # noqa: TC003 | ||
| from datetime import datetime | ||
|
|
||
| import sqlalchemy as sa | ||
| from alembic import op | ||
|
|
||
| from pyrit.memory.memory_models import CustomUUID, UTCDateTime | ||
|
|
||
| # revision identifiers, used by Alembic. | ||
| revision: str = "d7e9f1a3b5c6" | ||
| down_revision: str | Sequence[str] | None = "3f6e8a0c2d4b" | ||
| branch_labels: str | Sequence[str] | None = None | ||
| depends_on: str | Sequence[str] | None = None | ||
|
|
||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| def _attack_results_table() -> sa.Table: | ||
| """ | ||
| Build a lightweight typed table clause for reading/writing the columns this migration | ||
| touches, so ``UTCDateTime``/``JSON`` bind and result processors run on both backends. | ||
|
|
||
| Returns: | ||
| sa.Table: A minimal ``AttackResultEntries`` table with the id, attack_metadata, and | ||
| timestamp columns. | ||
| """ | ||
| metadata_obj = sa.MetaData() | ||
| return sa.Table( | ||
| "AttackResultEntries", | ||
| metadata_obj, | ||
| sa.Column("id", CustomUUID(), primary_key=True), | ||
| sa.Column("attack_metadata", sa.JSON()), | ||
| sa.Column("timestamp", UTCDateTime()), | ||
| extend_existing=True, | ||
| ) | ||
|
|
||
|
|
||
| def _parse_iso(value: object) -> datetime | None: | ||
| """ | ||
| Parse an ISO-8601 string (tolerating a trailing ``Z``) into a datetime, or ``None``. | ||
|
|
||
| Returns: | ||
| datetime | None: The parsed datetime, or ``None`` when ``value`` is not a parseable | ||
| ISO string. | ||
| """ | ||
| if not isinstance(value, str) or not value: | ||
| return None | ||
| try: | ||
| return datetime.fromisoformat(value.replace("Z", "+00:00")) | ||
| except ValueError: | ||
| return None | ||
|
|
||
|
|
||
| def upgrade() -> None: | ||
| """Apply this schema upgrade.""" | ||
| op.create_index( | ||
| "ix_AttackResultEntries_conversation_id", | ||
| "AttackResultEntries", | ||
| ["conversation_id"], | ||
| ) | ||
| op.create_index( | ||
| "ix_AttackResultEntries_timestamp_id", | ||
| "AttackResultEntries", | ||
| ["timestamp", "id"], | ||
| ) | ||
| _backfill_timestamp_from_metadata() | ||
|
|
||
|
|
||
| def downgrade() -> None: | ||
| """Revert this schema upgrade.""" | ||
| # Restore metadata.updated_at from the timestamp column so the legacy JSON recency sort | ||
| # reflects each row's last-updated time again. The original created/updated split cannot be | ||
| # perfectly recovered (it was collapsed into the single timestamp), so updated_at is set to | ||
| # the current timestamp value. | ||
| _restore_updated_at_to_metadata() | ||
| op.drop_index("ix_AttackResultEntries_timestamp_id", table_name="AttackResultEntries") | ||
| op.drop_index("ix_AttackResultEntries_conversation_id", table_name="AttackResultEntries") | ||
|
|
||
|
|
||
| def _backfill_timestamp_from_metadata() -> None: | ||
| """ | ||
| Set ``timestamp = COALESCE(parse(metadata.updated_at), parse(metadata.created_at), | ||
| timestamp)`` for existing rows and drop the now-redundant ``updated_at`` JSON key. | ||
|
|
||
| Only rows that carried a JSON recency key change; programmatic rows already have | ||
| ``timestamp`` equal to their creation time. Idempotent: rerunning is a no-op once the | ||
| ``updated_at`` key is gone and ``timestamp`` already reflects it. | ||
| """ | ||
| bind = op.get_bind() | ||
| table = _attack_results_table() | ||
|
|
||
| rows = bind.execute(sa.select(table.c.id, table.c.attack_metadata, table.c.timestamp)).fetchall() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. worth filtering here for attack_metadata being not None instead of all entries? |
||
|
|
||
| updated_rows = 0 | ||
| for row in rows: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is it worth batching via a temp side table (and applying changes there and then updating actual table against the updated side table) rather than row by row? we ran into an issue with too many database roundtrips when migrating prod over and the solution Roman came up with is batching here: #2266 Might be different depending on how many rows have "updated_at" metadata but wondering if you considered this? |
||
| metadata = dict(row.attack_metadata) if isinstance(row.attack_metadata, dict) else {} | ||
| new_timestamp = _parse_iso(metadata.get("updated_at")) or _parse_iso(metadata.get("created_at")) | ||
|
|
||
| had_updated_at = "updated_at" in metadata | ||
| new_metadata = {k: v for k, v in metadata.items() if k != "updated_at"} | ||
|
|
||
| timestamp_changes = new_timestamp is not None and new_timestamp != row.timestamp | ||
| if not timestamp_changes and not had_updated_at: | ||
| continue | ||
|
|
||
| values: dict[str, object] = {"attack_metadata": new_metadata or None} | ||
| if timestamp_changes: | ||
| values["timestamp"] = new_timestamp | ||
| bind.execute(sa.update(table).where(table.c.id == row.id).values(**values)) | ||
| updated_rows += 1 | ||
|
|
||
| if updated_rows: | ||
| logger.info(f"Attack recency backfill: updated {updated_rows} AttackResultEntries row(s).") | ||
|
|
||
|
|
||
| def _restore_updated_at_to_metadata() -> None: | ||
| """ | ||
| Downgrade inverse: write ``metadata.updated_at = timestamp.isoformat()`` for every row so | ||
| the legacy JSON recency expression orders rows by their last-updated time again. | ||
| """ | ||
| bind = op.get_bind() | ||
| table = _attack_results_table() | ||
|
|
||
| rows = bind.execute(sa.select(table.c.id, table.c.attack_metadata, table.c.timestamp)).fetchall() | ||
|
|
||
| for row in rows: | ||
| if row.timestamp is None: | ||
| continue | ||
| metadata = dict(row.attack_metadata) if isinstance(row.attack_metadata, dict) else {} | ||
| metadata["updated_at"] = row.timestamp.isoformat() | ||
| bind.execute(sa.update(table).where(table.c.id == row.id).values(attack_metadata=metadata)) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this order is wrong right? should be timestamp, then metadata.created_at, and then datetime.now