Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions pyrit/backend/mappers/attack_mappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,22 +231,22 @@ def _resolve_summary_timestamps(ar: AttackResult) -> tuple[datetime, datetime]:
"""
Resolve ``created_at`` / ``updated_at`` for a summary.

Resolution order for ``created_at``: explicit metadata override, then the
persisted ``AttackResult.timestamp``, and finally ``datetime.now`` as a
last-resort fallback for never-persisted results.
``updated_at`` is the persisted ``AttackResult.timestamp`` — the single indexed recency
key that manual edits (add message, branch, promote conversation) bump. ``created_at``
comes from the display-only ``metadata.created_at`` override, falling back to
``AttackResult.timestamp`` and finally ``datetime.now`` for never-persisted results.
Comment on lines +236 to +237

Copy link
Copy Markdown
Contributor

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


Returns:
A ``(created_at, updated_at)`` tuple.
"""
created_str = ar.metadata.get("created_at")
updated_str = ar.metadata.get("updated_at")
if created_str:
created_at = datetime.fromisoformat(created_str)
elif ar.timestamp is not None:
created_at = ar.timestamp
else:
created_at = datetime.now(timezone.utc)
updated_at = datetime.fromisoformat(updated_str) if updated_str else created_at
updated_at = ar.timestamp if ar.timestamp is not None else created_at
return created_at, updated_at


Expand Down
50 changes: 19 additions & 31 deletions pyrit/backend/services/attack_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,9 +358,9 @@ async def create_attack_async(self, *, request: CreateAttackRequest) -> CreateAt
),
),
outcome=AttackOutcome.UNDETERMINED,
timestamp=now,
metadata={
"created_at": now.isoformat(),
"updated_at": now.isoformat(),
},
labels=labels,
)
Expand Down Expand Up @@ -414,15 +414,11 @@ async def update_attack_async(self, *, attack_result_id: str, request: UpdateAtt
}
new_outcome = outcome_map.get(request.outcome, AttackOutcome.UNDETERMINED)

ar = results[0]
updated_metadata = dict(ar.metadata) if ar.metadata else {}
updated_metadata["updated_at"] = datetime.now(timezone.utc).isoformat()

self._memory.update_attack_result_by_id(
attack_result_id=attack_result_id,
update_fields={
"outcome": new_outcome.value,
"attack_metadata": updated_metadata,
"timestamp": datetime.now(timezone.utc),
},
)

Expand Down Expand Up @@ -527,14 +523,11 @@ async def create_related_conversation_async(
# Add to pruned_conversation_ids so user-created branches are visible in the GUI history panel.
existing_pruned = ar.get_pruned_conversation_ids()

updated_metadata = dict(ar.metadata or {})
updated_metadata["updated_at"] = now.isoformat()

self._memory.update_attack_result_by_id(
attack_result_id=attack_result_id,
update_fields={
"pruned_conversation_ids": existing_pruned + [new_conversation_id],
"attack_metadata": updated_metadata,
"timestamp": now,
},
)

Expand Down Expand Up @@ -590,16 +583,14 @@ async def update_main_conversation_async(
updated_pruned.append(ar.conversation_id)

now = datetime.now(timezone.utc)
updated_metadata = dict(ar.metadata or {})
updated_metadata["updated_at"] = now.isoformat()

self._memory.update_attack_result_by_id(
attack_result_id=attack_result_id,
update_fields={
"conversation_id": target_conv_id,
"pruned_conversation_ids": updated_pruned if updated_pruned else None,
"adversarial_chat_conversation_ids": updated_adversarial if updated_adversarial else None,
"attack_metadata": updated_metadata,
"timestamp": now,
},
)

Expand Down Expand Up @@ -752,12 +743,12 @@ async def _update_attack_after_message_async(
self, *, attack_result_id: str, ar: AttackResult, request: AddMessageRequest
) -> None:
"""
Update attack metadata and converter tracking after a message is added.
"""
updated_metadata = dict(ar.metadata or {})
updated_metadata["updated_at"] = datetime.now(timezone.utc).isoformat()
Update attack recency and converter tracking after a message is added.

update_fields: dict[str, Any] = {"attack_metadata": updated_metadata}
Bumps the attack's ``timestamp`` column (the single indexed recency key) so the edited
conversation re-floats to the top of the History view.
"""
update_fields: dict[str, Any] = {"timestamp": datetime.now(timezone.utc)}

if request.converter_ids:
converter_objs = get_converter_service().get_converter_objects_for_ids(converter_ids=request.converter_ids)
Expand Down Expand Up @@ -924,18 +915,16 @@ def _encode_attack_cursor(*, cursor: AttackResultsKeysetCursor, fingerprint: str
"""
Encode a keyset anchor and its filter fingerprint into an opaque pagination cursor.

The anchor's recency string and timestamp can contain ``.``/``:``/``-`` (ISO
timestamps), so the payload is JSON-serialized and base64url-encoded rather than
joined with a delimiter, keeping the cursor an unambiguous opaque token for
``_decode_attack_cursor``.
The anchor's timestamp can contain ``.``/``:``/``-`` (ISO timestamps), so the payload
is JSON-serialized and base64url-encoded rather than joined with a delimiter, keeping
the cursor an unambiguous opaque token for ``_decode_attack_cursor``.

Returns:
An opaque base64url cursor string encoding ``{fingerprint, recency, timestamp,
An opaque base64url cursor string encoding ``{fingerprint, timestamp,
attack_result_id}``.
"""
payload = {
"f": fingerprint,
"r": cursor.recency,
"t": cursor.timestamp.isoformat(),
"i": cursor.attack_result_id,
}
Expand All @@ -947,12 +936,12 @@ def _decode_attack_cursor(*, cursor: str | None, fingerprint: str) -> AttackResu
"""
Decode the opaque list-attacks cursor into a keyset (seek) anchor.

The cursor encodes the previous page's last-row recency anchor together with a
The cursor encodes the previous page's last-row timestamp anchor together with a
fingerprint of the filter set it was generated for (see ``_attack_filter_fingerprint``).
A cursor is honored only when its fingerprint matches the current request's filters;
malformed, legacy (offset/attack-result-id), or filter-mismatched cursors fall back to
the first page (``None``) so a stale cursor degrades gracefully instead of raising or
seeking within the wrong result set.
malformed, legacy (offset/attack-result-id/recency-string), or filter-mismatched cursors
fall back to the first page (``None``) so a stale cursor degrades gracefully instead of
raising or seeking within the wrong result set.

Returns:
The decoded ``AttackResultsKeysetCursor``, or ``None`` to start at the first page.
Expand All @@ -966,10 +955,9 @@ def _decode_attack_cursor(*, cursor: str | None, fingerprint: str) -> AttackResu
return None
if not isinstance(payload, dict) or payload.get("f") != fingerprint:
return None
recency = payload.get("r")
raw_timestamp = payload.get("t")
attack_result_id = payload.get("i")
if not isinstance(recency, str) or not isinstance(raw_timestamp, str) or not isinstance(attack_result_id, str):
if not isinstance(raw_timestamp, str) or not isinstance(attack_result_id, str):
return None
try:
timestamp = datetime.fromisoformat(raw_timestamp)
Expand All @@ -989,7 +977,7 @@ def _decode_attack_cursor(*, cursor: str | None, fingerprint: str) -> AttackResu
# A crafted cursor near datetime's min/max with a large UTC offset overflows the
# representable range when shifted to UTC; treat it as malformed and restart at page one.
return None
return AttackResultsKeysetCursor(recency=recency, timestamp=timestamp, attack_result_id=attack_result_id)
return AttackResultsKeysetCursor(timestamp=timestamp, attack_result_id=attack_result_id)

# ========================================================================
# Private Helper Methods - Duplicate / Branch
Expand Down
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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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))
31 changes: 1 addition & 30 deletions pyrit/memory/azure_sql_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any, Literal, cast

from sqlalchemy import and_, column, create_engine, event, exists, func, select, text
from sqlalchemy import and_, create_engine, event, exists, text
from sqlalchemy.engine.base import Engine
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import InstrumentedAttribute, sessionmaker
Expand Down Expand Up @@ -441,35 +441,6 @@ def _get_condition_json_array_match(
combined = joiner.join(conditions)
return text(f"""ISJSON("{table_name}".{column_name}) = 1 AND ({combined})""").bindparams(**bindparams_dict)

def _attack_results_recency_expr(self) -> Any:
"""
Return the Azure SQL recency sort expression reproducing the History-view sort.

Coalesces the ``attack_metadata`` ``updated_at`` key, falling back to ``created_at``
then an empty string. Only JSON *string* values are honored: each accessor is an
``OPENJSON`` subquery filtered to ``type = 1`` (JSON string), so a non-string value
(e.g. a JSON bool or number) yields no row and coalesces to ``""`` instead. A raw
``JSON_VALUE`` would instead stringify such a scalar (``false`` -> ``"false"``, ``2`` ->
``"2"``), diverging from ``_attack_result_recency_key``'s Python anchor -- which collapses
non-strings to ``""`` -- so the keyset seek would anchor on a value the database never
sorted by and skip rows at a page boundary. Mirroring SQLite's ``json_type`` guard keeps
recency a pure text sort key that agrees with the Python key on both backends. Uses ORM
``func``/``select`` expressions (not raw ``text``) so they adapt to the aliased subquery
SQLAlchemy emits when a collection ``joinedload`` is combined with ``limit``. Backs both
the recency ORDER BY and the keyset seek predicate so they stay in lockstep.

Returns:
Any: A SQLAlchemy expression yielding the coalesced recency string.
"""
metadata = AttackResultEntry.attack_metadata

def _string_valued(json_key: str) -> Any:
table = func.openjson(metadata).table_valued(column("key"), column("value"), column("type"))
rows = table.alias(f"oj_{json_key}")
return select(rows.c["value"]).where(rows.c["key"] == json_key, rows.c["type"] == 1).scalar_subquery()

return func.coalesce(_string_valued("updated_at"), _string_valued("created_at"), "")

def _get_attack_result_label_condition(self, *, labels: dict[str, str | Sequence[str]]) -> Any:
"""
Azure SQL implementation for filtering AttackResults by labels.
Expand Down
Loading
Loading