Write ai_governance_sessions from SyncSessionArtifactWorker
What does this MR do and why?
Ai::DuoWorkflows::SyncSessionArtifactWorker now also writes each Duo workflow into the new ai_governance_sessions table, alongside the existing duo_workflow_session_artifacts write.
Without a writer, the new table stays empty, so nothing that reads from it can be tested end to end.
How it works:
- Adds
Ai::Governance::Session.sync_from_workflow!, an upsert that mirrors the existingAi::DuoWorkflows::SessionArtifact.sync_from_workflow!. - It upserts on the partial unique index
i_ai_governance_sessions_on_namespace_workflow(namespace_id,workflow_idwhereworkflow_idis not null). This means repeated runs on each status change update one row instead of creating duplicates. session_finished_atis left alone on sync, so a sync never resets it.- The table is added to the worker's
defer_on_database_health_signallist. - The new write is behind the
sync_ai_governance_sessionsfeature flag (gitlab_com_derisk, off by default), checked against the workflow's root namespace. Rollout issue: https://gitlab.com/gitlab-org/gitlab/-/work_items/630766
Known limit: session_finished_at stays null for now. Setting it is a follow-up.
Review note: all three callers of this worker are Duo Agent Platform services (Ai::DuoWorkflows::CreateWorkflowService, Ai::DuoWorkflows::UpdateWorkflowStatusService, Ai::ExternalAgents::Sessions::CreateService), so a review from that team is wanted.
References
Closes https://gitlab.com/gitlab-org/gitlab/-/work_items/630096
Epic: https://gitlab.com/groups/gitlab-org/-/epics/21540
Depends on !256344 (merged), which adds the table (merged).
Screenshots or screen recordings
Not applicable. This is a backend-only change.
How to set up and validate locally
Run:
bundle exec rspec ee/spec/models/ai/governance/session_spec.rb ee/spec/workers/ai/duo_workflows/sync_session_artifact_worker_spec.rbDatabase
Ai::Governance::Session.sync_from_workflow! runs one upsert per SyncSessionArtifactWorker job.
ai_governance_sessions has no rows on production yet, because this MR adds the first writer. To get realistic plans, I filled the table in the postgres.ai clone with the latest 1 million rows from duo_workflows_workflows:
exec INSERT INTO ai_governance_sessions (namespace_id, project_id, user_id, workflow_id, created_at, updated_at, session_started_at, source, status, agent_type, flow_type)
SELECT COALESCE(w.namespace_id, p.project_namespace_id), w.project_id, w.user_id, w.id, now(), now(), w.created_at, 0, w.status, w.agent_type, w.workflow_definition
FROM (SELECT * FROM duo_workflows_workflows ORDER BY id DESC LIMIT 1000000) w
LEFT JOIN projects p ON p.id = w.project_id;
exec ANALYZE ai_governance_sessions;I then inserted one row with a known key, so the conflict path has a row to hit:
exec INSERT INTO ai_governance_sessions (namespace_id, project_id, user_id, workflow_id, created_at, updated_at, session_started_at, source, status, agent_type, flow_type)
VALUES (9970, NULL, 1, 999999999, now(), now(), '2026-09-22 14:39:51', 0, 0, NULL, 'software_development');The insert path uses workflow_id 999999998, which has no existing row.
INSERT INTO "ai_governance_sessions"
("workflow_id","user_id","project_id","namespace_id","status","flow_type","agent_type","source","session_started_at","created_at","updated_at")
VALUES
(999999999, 1, NULL, 9970, 1, 'software_development', NULL, 0, '2026-09-22 14:39:51', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT ("namespace_id","workflow_id") WHERE (workflow_id IS NOT NULL)
DO UPDATE SET
updated_at = (CASE WHEN ("ai_governance_sessions"."user_id" IS NOT DISTINCT FROM excluded."user_id"
AND "ai_governance_sessions"."project_id" IS NOT DISTINCT FROM excluded."project_id"
AND "ai_governance_sessions"."status" IS NOT DISTINCT FROM excluded."status"
AND "ai_governance_sessions"."flow_type" IS NOT DISTINCT FROM excluded."flow_type"
AND "ai_governance_sessions"."agent_type" IS NOT DISTINCT FROM excluded."agent_type"
AND "ai_governance_sessions"."source" IS NOT DISTINCT FROM excluded."source"
AND "ai_governance_sessions"."session_started_at" IS NOT DISTINCT FROM excluded."session_started_at")
THEN "ai_governance_sessions".updated_at ELSE CURRENT_TIMESTAMP END),
"user_id" = excluded."user_id",
"project_id" = excluded."project_id",
"status" = excluded."status",
"flow_type" = excluded."flow_type",
"agent_type" = excluded."agent_type",
"source" = excluded."source",
"session_started_at" = excluded."session_started_at"
RETURNING "id"| Path | When | Plan | Execution | WAL records |
|---|---|---|---|---|
| Conflict (update) | Later status changes | https://postgres.ai/console/gitlab/gitlab-production-main/sessions/58554/commands/163490 | 1.431 ms | 2 |
| Insert | First sync of a workflow | https://postgres.ai/console/gitlab/gitlab-production-main/sessions/58554/commands/163491 | 0.452 ms | 7 |
The conflict path updates the existing row in place. The insert path writes the row plus one entry in each of the 6 indexes. Both take only RowExclusiveLock, through the fast path.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.