Cap the AI session artifact download and say when it was cut
What changes for users
Values: 5,000 events and 15 MB, constants that are easy to raise later if needed. Shared with Nate in #634979.
- A session download now stops at 5,000 audit events or 15 MB of event details, newest first. The file says the limits and whether and why it was cut.
- Downloads within the limits are the same, apart from three new keys.
- How many sessions on GitLab.com go over the limits is not measured: the events are in ClickHouse, which Database Lab cannot query, and the PostgreSQL table is still empty there.
The AI session artifact download now stops at 5,000 audit events or 15 MB of event details, newest first. The JSON file records the limits and whether and why it was cut. Exports within the limits are unchanged apart from three new keys.
This is the "Download" action in the session drawer of the AI Governance Audit events tab, on groups and projects. Stacked on Load AI audit event details after the ClickHous... (!265815): it targets that branch and shows only its own commit. Once !265815 merges it is rebased onto master and retargeted, and the diff does not change.
Why
download (Duo sessions) and download_session (external agent sessions) in Security::AgentArtifactsDownloadable build the whole file with Gitlab::Json.dump and send_data. Both export services load every audit event of the session and parse each details. The web worker holds the rows, the parsed hashes and the final string at once, so memory grows with the size of the session.
External agent events are capped at 10 KB of details on ingest. Duo events are not yet (#603352+), and a session has no cap on its number of events.
The change
MAX_AUDIT_EVENTS = 5_000andMAX_AUDIT_EVENTS_DETAILS_BYTES = 15.megabytesonAi::DuoWorkflows::SessionArtifactExportService. The Governance subclass inherits them. Both finders takelimit(MAX_AUDIT_EVENTS + 1), so one extra row shows the export was cut without a count query.- Events are serialized newest first in batches of 500. The byte budget sums each event's
detailssize and stops once it is passed. On ClickHouse, details are loaded per batch throughClickHouseFinder.with_detailsfrom Load AI audit event details after the ClickHous... (!265815), so memory stays within the budget plus one batch. On PostgreSQL the records come with their details, so there the event cap bounds memory and the budget only trims the file. - The file gains
audit_events_limits,audit_events_truncatedandaudit_events_truncated_by("events", "details_bytes" or null). - Specs cover a cut by events, a cut by bytes, and an export within limits on both paths. The controller specs check the new keys. The memory claim is shown by a ClickHouse spec with the batch size set to 1 and a 20-byte budget over three events: the details lookup runs twice (once per consumed batch), the third batch is never fetched, and the file says
details_bytes.
A cut file starts like this:
{
"session": { "...": "..." },
"audit_events_limits": { "events": 5000, "details_bytes": 15728640 },
"audit_events_truncated": true,
"audit_events_truncated_by": "details_bytes",
"audit_events": [ "..." ]
}Queries, before and after
Both export queries already run today; this MR adds LIMIT 5001 (the cap plus one row) to each. Nothing else in them changes, and no index changes: the limit sits on the same ORDER BY created_at DESC, id DESC that the queries already had.
PostgreSQL (AuditEvents::AiAuditEvents::PostgresqlFinder, a Duo session), before:
SELECT "ai_audit_events".* FROM "ai_audit_events"
WHERE "ai_audit_events"."workflow_id" = 12345
ORDER BY "ai_audit_events"."created_at" DESC, "ai_audit_events"."id" DESCAfter:
SELECT "ai_audit_events".* FROM "ai_audit_events"
WHERE "ai_audit_events"."workflow_id" = 12345
ORDER BY "ai_audit_events"."created_at" DESC, "ai_audit_events"."id" DESC LIMIT 5001The index (workflow_id, created_at DESC, id DESC) already serves the filter and the order, so the limit only stops the scan earlier. On GitLab.com the table is still empty: a Database Lab run of the after query on 2026-10-30 scanned all 13 partitions (this query has no created_at bound) and returned 0 rows; with empty partitions the planner picks sequential scans, so the index choice cannot be shown until data exists.
ClickHouse (AuditEvents::AiAuditEvents::ClickHouseFinder, a Duo session; an external session filters on ai_governance_session_id with the session's start bound instead), before:
SELECT id, workflow_id, ai_governance_session_id, created_at, traversal_path, event_name, author_id, ip_address
FROM (
SELECT id, workflow_id, any(ai_governance_session_id) AS ai_governance_session_id, created_at, traversal_path,
any(event_name) AS event_name, any(author_id) AS author_id, any(ip_address) AS ip_address
FROM ai_audit_events
WHERE workflow_id = 12345
GROUP BY traversal_path, workflow_id, created_at, id
) ai_audit_events
ORDER BY created_at DESC, id DESCAfter: the same query with LIMIT 5001 on the outer select. The dedup GROUP BY still runs over the session's rows, which Load AI audit event details after the ClickHous... (!265815) made cheap by leaving details out of it; the limit bounds the rows returned to the export and so the number of details lookups (at most 10 batches of 500, since the export keeps the first 5,000 rows).
Decisions
- A cap over streaming or a background job. The two limits bound memory by themselves, and the drawer link stays synchronous. A job would need a notification flow for a file most users open right away.
- 15 MB matches
TARGET_FILESIZEof the CSV exports, so there is one size rule. 5,000 events is unmeasured on production and is a named constant to tune. It bounds the page query and the number of details lookups, which the byte budget alone does not. - Newest events stay, matching the order the page shows. Keeping the oldest was rejected because the latest activity is what the dashboard surfaces.
- Three explicit keys over a note in the events array, so a script checks one boolean and one reason.
Out of scope
- The per-user rate limit on the two download actions, which is in Rate limit the AI governance sessions field and... (!265874 - merged).
- A size cap on Duo audit event
detailsat ingest (#603352+).
Related to #635324.