Add a channels filter to the checkpoint list endpoint
What does this MR do and why?
GET /api/v4/ai/duo_workflows/workflows/:id/checkpoints now accepts an optional channels query param that returns only the requested channels in each checkpoint's channel_values. The AI Gateway's stop-recovery walk pages this endpoint but reads only the status channel of the checkpoints it walks past, and the endpoint used to rebuild every channel of every checkpoint on the page. Callers that omit channels get the same response, queries, and query count as before.
Design notes
ee/app/models/ai/duo_workflows/workflow.rb:blobs_by_thread_ts_for(checkpoints, channels:)filters the blob query on the requested channels intersected with the union of the page headers'channel_keys. When any header on the page has nochannel_keys, or the union has more than 100 entries, it filters on the requested channels alone. This reuses thechannel IN (...)clause that the membership filter already adds in !252495 (merged), so the query shape stays the same, and the list stays at most 100 items because the param is capped at 100.reconstructed_channel_values_from(checkpoint, blobs, channels:)slices the header's storedchannel_valuesto the requested channels before the early return for "no blobs", so every return path is filtered.group_bounded_blobs_fromnow requires a compaction anchor only for the requested channels (the header's membership intersected withchannels). Without this change, achannels=statusrequest would refetch the whole chain whenever an unrequested channel lacked its anchor.unbounded_blobs_by_thread_ts, the refetch used when a group lacks a compaction anchor, is filtered by the requested channels too, and memoized per channel list, so a page still refetches at most once.ee/lib/api/ai/duo_workflows/workflows_internal.rb: on the legacy branch (dw_read_blobs_listoff, rows read fromp_duo_workflows_checkpoints),channel_valueslives in one jsonb column, so the endpoint slices it after loading.- The param is
Array[String], coerced withAPI::Validations::Types::CommaSeparatedToArray, so bothchannels=status,ui_chat_logandchannels[]=statuswork. An empty list returns 400. The list allows at most 100 items, each 1 to 255 characters. A repeated key without brackets (channels=a&channels=b) keeps only the last value, which is standard Rack behavior. Unknown channel names are dropped without an error. checkpoint_writesandchannel_versionsare not filtered; onlychannel_valuesis.doc/api/openapi/openapi_v3.yamlis regenerated for the new param.- The current AI Gateway does not send
channels, so it sees no change. The gateway MR gitlab-org/modelops/applied-ml/code-suggestions/ai-assist!7141 (merged) sendschannels[]=status, and a request spec on this branch covers that form. A Rails version without this MR ignores the param, because Grape ignores undeclared params, and returns full checkpoints, so either deploy order is safe. - On a stop-recovery RESUME, the gateway also needs the boundary checkpoint's
ui_chat_log, because it is the baseline for the cancelled-turn context. The gateway MR fetches the boundary in full throughby_thread_ts, and LangGraph loads it again to resume. Per resume with the boundary on the first page, the local estimate is about 436 MB today, about 53 MB with both MRs (about 88% less), and about 28 MB if the gateway reuses its boundary fetch for LangGraph's load. - !258386 (merged) edits the same
reconstruct_page!andreconstructed_channel_values_from. Whichever MR merges second needs a rebase.
Benchmark
Local GDK, synthetic workflow with 60 checkpoints in one current_thread group, 30 KB and 15 KB per-step deltas, compressed responses, MemoryProfiler total allocated bytes (the same kind of metric as mem_total_bytes in the API logs), second run of each. Not production data.
| Request | Allocated |
|---|---|
per_page=20 |
410 MB |
per_page=20, channels=status |
1.6 MB |
per_page=20, channels=status,ui_chat_log |
123 MB |
by_thread_ts for the boundary checkpoint |
26 MB |
References
- #631230 (closed)
- #630827 (closed)
- gitlab-org/modelops/applied-ml/code-suggestions/ai-assist!7141 (merged)
- !258386 (merged)
Screenshots or screen recordings
No UI change.
How to set up and validate locally
- Check out this branch and open a Rails console:
bin/rails console. - Create a workflow with one checkpoint holding two channels:
user = User.find_by_username('root') workflow = Ai::DuoWorkflows::Workflow.create!(user: user, project: Project.first, goal: 'channels QA', incremental_checkpoints_enabled: true, environment: :web) blob = ->(channel, value) do { channel: channel, version: '1', write_type: 'json', step_action: 'compaction', data: Base64.strict_encode64(Zlib::Deflate.deflate(value.to_json)) } end ts = Gitlab::Utils.uuid_v7 Ai::DuoWorkflows::CreateCheckpointService.new(workflow: workflow, params: { thread_ts: ts, current_thread: 0, checkpoint: { 'id' => ts, 'ts' => Time.current.iso8601, 'channel_versions' => {}, 'channel_values' => {} }, metadata: { 'step' => 0 }, channel_keys: %w[status ui_chat_log], channel_blobs: [blob.call('status', 'INPUT_REQUIRED'), blob.call('ui_chat_log', [{ 'content' => 'hi' }])] }).execute headers = workflow.checkpoint_headers.to_a - Rebuild without a filter and confirm both channels come back:
Expect:
workflow.reconstructed_channel_values_from(headers.first, workflow.blobs_by_thread_ts_for(headers)){"status"=>"INPUT_REQUIRED", "ui_chat_log"=>[{"content"=>"hi"}]} - Rebuild with the filter the endpoint applies for
?channels=status:Expect:workflow.reconstructed_channel_values_from(headers.first, workflow.blobs_by_thread_ts_for(headers, channels: %w[status]), channels: %w[status]){"status"=>"INPUT_REQUIRED"} - Run the endpoint specs:
Expect: all examples pass.
bin/rspec ee/spec/requests/api/ai/duo_workflows/workflows_internal_spec.rb -e 'with channels parameter'
Edge cases
Each case below has a spec:
channels=(empty) returns 400.- A list of 101 channels returns 400.
- A channel the header does not declare returns empty
channel_values. - A group without a compaction anchor refetches with the channel filter applied (two blob queries, both filtered on
status). - The legacy branch slices
channel_valuesthe same way.
Database
This MR narrows or adds a channel filter on two blob queries on p_duo_workflows_checkpoint_blobs, with no new index or migration. On a production workflow with 1,139 checkpoints, a channels=status read uses the existing (workflow_id, channel, id) index and reads 503 buffers, compared with 2,375 for the master page query and 1,482 for the master refetch.
Query plans
- Two queries change: the page query (
Workflow#blobs_by_thread_ts_for) and the refetch query (Workflow#unbounded_blobs_by_thread_ts), which runs when a group lacks a compaction anchor. - On master, the page query filters
channelby the union of the page headers'channel_keys, or has no filter when a header lacks one. The refetch query has nochannelfilter. - With
channels=status, both queries filterchannel = 'status'. A multi-channel request such aschannels=status,ui_chat_logproduceschannel IN (...), the same shape as the master page query below. Callers that omitchannelsrun the master queries unchanged. - Plans come from Database Lab (
gitlab-production-main), with data as of 2026-09-29 04:24 UTC. The plans shorten thethread_tslist for readability. The 1,139-value list already exists on master, and this MR does not change it. Planning takes 90 to 170 ms with it. - Test workflow 8711521 has the most checkpoints of the workflows created on 2026-09-27: 1,139 checkpoints in one
current_threadgroup and 3,528 blobs. One group and a linear chain give both queries the same 1,139-valuethread_tslist, so onechannels=statusplan covers both. - With
channel = 'status', Postgres usesidx_duo_wf_checkpoint_blobs_on_workflow_channel_idand returns rows inidorder without a sort: 506 rows, 503 buffers, 2.3 ms. - The master page query uses the same index plus a sort (2,427 rows, 2,375 buffers, 7.0 ms). The master refetch query uses
idx_duo_wf_checkpoint_blobs_dedupplus a sort (3,528 rows, 1,482 buffers, 112.7 ms, most of it cold reads).
This MR: page query and refetch with channels=status
SELECT "p_duo_workflows_checkpoint_blobs".* FROM "p_duo_workflows_checkpoint_blobs" WHERE "p_duo_workflows_checkpoint_blobs"."workflow_id" = 8711521 AND "p_duo_workflows_checkpoint_blobs"."workflow_created_at" = '2026-09-27 13:05:42.680577' AND "p_duo_workflows_checkpoint_blobs"."project_id" = 84861201 AND "p_duo_workflows_checkpoint_blobs"."thread_ts" IN (… 1,139 thread_ts values …) AND "p_duo_workflows_checkpoint_blobs"."channel" = 'status' ORDER BY "p_duo_workflows_checkpoint_blobs"."id" ASC Index Scan using index_22d319300b on gitlab_partitions_dynamic.p_duo_workflows_checkpoint_blobs_20260927 p_duo_workflows_checkpoint_blobs (cost=3.27..270.06 rows=1 width=369) (actual time=0.107..2.257 rows=506 loops=1)
Index Cond: ((p_duo_workflows_checkpoint_blobs.workflow_id = 8711521) AND (p_duo_workflows_checkpoint_blobs.channel = 'status'::text))
Filter: ((p_duo_workflows_checkpoint_blobs.workflow_created_at = '2026-09-27 13:05:42.680577+00'::timestamp with time zone) AND (p_duo_workflows_checkpoint_blobs.project_id = 84861201) AND (p_duo_workflows_checkpoint_blobs.thread_ts = ANY ('{… 1,139 thread_ts values …}'::text[])))
Buffers: shared hit=503
I/O Timings: read=0.000 write=0.000
Settings: random_page_cost = '1.5', seq_page_cost = '4', effective_cache_size = '472585MB', work_mem = '230MB', jit = 'off'
Time: 94.258 ms
- planning: 91.937 ms
- execution: 2.321 msMaster: page query (header channel_keys filter)
SELECT "p_duo_workflows_checkpoint_blobs".* FROM "p_duo_workflows_checkpoint_blobs" WHERE "p_duo_workflows_checkpoint_blobs"."workflow_id" = 8711521 AND "p_duo_workflows_checkpoint_blobs"."workflow_created_at" = '2026-09-27 13:05:42.680577' AND "p_duo_workflows_checkpoint_blobs"."project_id" = 84861201 AND "p_duo_workflows_checkpoint_blobs"."thread_ts" IN (… 1,139 thread_ts values …) AND "p_duo_workflows_checkpoint_blobs"."channel" IN ('status', 'conversation_history', 'ui_chat_log', 'context', 'agent_context_limits', 'branch:to:user_input#fetch') ORDER BY "p_duo_workflows_checkpoint_blobs"."id" ASC Sort (cost=1747.74..1747.75 rows=1 width=369) (actual time=6.602..6.829 rows=2427 loops=1)
Sort Key: p_duo_workflows_checkpoint_blobs.id
Sort Method: quicksort Memory: 1774kB
Buffers: shared hit=2375
I/O Timings: read=0.000 write=0.000
-> Index Scan using index_22d319300b on gitlab_partitions_dynamic.p_duo_workflows_checkpoint_blobs_20260927 p_duo_workflows_checkpoint_blobs (cost=3.27..1747.73 rows=1 width=369) (actual time=0.093..5.171 rows=2427 loops=1)
Index Cond: ((p_duo_workflows_checkpoint_blobs.workflow_id = 8711521) AND (p_duo_workflows_checkpoint_blobs.channel = ANY ('{status,conversation_history,ui_chat_log,context,agent_context_limits,branch:to:user_input#fetch}'::text[])))
Filter: ((p_duo_workflows_checkpoint_blobs.workflow_created_at = '2026-09-27 13:05:42.680577+00'::timestamp with time zone) AND (p_duo_workflows_checkpoint_blobs.project_id = 84861201) AND (p_duo_workflows_checkpoint_blobs.thread_ts = ANY ('{… 1,139 thread_ts values …}'::text[])))
Buffers: shared hit=2372
I/O Timings: read=0.000 write=0.000
Settings: random_page_cost = '1.5', seq_page_cost = '4', effective_cache_size = '472585MB', work_mem = '230MB', jit = 'off'
Time: 178.330 ms
- planning: 171.326 ms
- execution: 7.004 msMaster: refetch query (no channel filter)
SELECT "p_duo_workflows_checkpoint_blobs".* FROM "p_duo_workflows_checkpoint_blobs" WHERE "p_duo_workflows_checkpoint_blobs"."workflow_id" = 8711521 AND "p_duo_workflows_checkpoint_blobs"."workflow_created_at" = '2026-09-27 13:05:42.680577' AND "p_duo_workflows_checkpoint_blobs"."project_id" = 84861201 AND "p_duo_workflows_checkpoint_blobs"."thread_ts" IN (… 1,139 thread_ts values …) ORDER BY "p_duo_workflows_checkpoint_blobs"."id" ASC Sort (cost=2296.56..2296.56 rows=1 width=369) (actual time=111.916..112.444 rows=3528 loops=1)
Sort Key: p_duo_workflows_checkpoint_blobs.id
Sort Method: quicksort Memory: 1977kB
Buffers: shared hit=1378 read=104
I/O Timings: read=103.106 write=0.000
-> Index Scan using p_duo_workflows_checkpoint_b_project_id_workflow_id_threa_idx33 on gitlab_partitions_dynamic.p_duo_workflows_checkpoint_blobs_20260927 p_duo_workflows_checkpoint_blobs (cost=0.55..2296.55 rows=1 width=369) (actual time=6.502..109.808 rows=3528 loops=1)
Index Cond: ((p_duo_workflows_checkpoint_blobs.project_id = 84861201) AND (p_duo_workflows_checkpoint_blobs.workflow_id = 8711521) AND (p_duo_workflows_checkpoint_blobs.thread_ts = ANY ('{… 1,139 thread_ts values …}'::text[])) AND (p_duo_workflows_checkpoint_blobs.workflow_created_at = '2026-09-27 13:05:42.680577+00'::timestamp with time zone))
Buffers: shared hit=1375 read=104
I/O Timings: read=103.106 write=0.000
Settings: jit = 'off', random_page_cost = '1.5', seq_page_cost = '4', effective_cache_size = '472585MB', work_mem = '230MB'
Time: 202.610 ms
- planning: 89.930 ms
- execution: 112.680 msMR 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.