Replicate and expose the Duo Workflow drop reason
What does this MR do and why?
Part of #631550, which asks why about 33,000 Developer flow sessions on the DAP Impact dashboard show as "Failed" with no reason.
This MR carries the drop_reason column into the enriched ClickHouse table and exposes it in the Duo Workflows aggregation engine. The Postgres column, its Siphon replication and the drop sites landed on master in !256455 (merged) (text column, string vocabulary: job_failed, no_status_reported, no_activity, workload_not_created, api_reported, preconditions_not_met, start_failed), which superseded !260682 (closed). This MR now targets master.
Changes:
- Four ClickHouse migrations in
db/click_house/migrate/main/:- Add
drop_reason LowCardinality(Nullable(String))toduo_workflows_workflows_enriched, placedAFTER execution_mode. The Siphon column isNullable(String); the views'APPEND TOinsert converts it. The column stays Nullable because the engine treats NULL as "no reason recorded". - Drop and recreate
duo_workflows_workflows_enriched_delta_mvwithdrop_reasonin the column list and the SELECT. - Do the same for
duo_workflows_workflows_enriched_mr_outcomes_mv. - Backfill
drop_reasonon enriched rows that already exist (see the database review notes).
- Add
- In both views, the dedup CTE uses
argMax(drop_reason, ...)and the projection useswf.drop_reason/unresolved.drop_reason. Refreshable materialized views cannot be altered, so recreating them is the only way to add a column.downrecreates the original definitions. The view definitions are otherwise copied unchanged fromdb/click_house/main.sql. db/click_house/main.sqland the schema cache filedb/click_house/schema_cache/main/duo_workflows_workflows_enriched.ymlare regenerated. The diff is only the new column. The engine reads its column list from the schema cache, so the cache must be updated for the dedup subquery to include the column.ee/app/models/analytics/aggregation_engines/duo_workflows.rb:DROP_REASONSreusesAi::DuoWorkflows::Workflow::DROP_REASONS, so the engine cannot drift from the model's vocabulary.- New
drop_reasondimension (string). Values pass through as stored; NULL stays NULL. - New
drop_reasonfilter (exact match) anddrop_reason_notfilter (exclusion, max 100 values). Both keep only names in the vocabulary, so unrecognized names match nothing and exclude nothing, like the existingstatusfilters.
- In GraphQL these are
DuoWorkflowsAggregationResponseDimensions.dropReasonand thedropReason/dropReasonNotarguments. Rows with no reason are matched by neitherdropReasonnordropReasonNot:NOT INhas SQL NULL semantics, the same as the existingprojectIdNot. doc/api/graphql/reference/_index.mdis regenerated.doc/user/glql/data_sources/duo_workflows.mdgains thedropReasonfilter and dimension. GLQL usage depends on the GLQL compiler change (glql!552 (merged)) and the package bump in MR 4.
Database review notes
- Rows for flows that failed before !256455 (merged) merged (2026-10-11) have
drop_reasonNULL by construction. Postgres has no record of the cause, so there is nothing to backfill for them. - Rows for flows that failed after that merge do have the reason in
siphon_duo_workflows_workflows, but their enriched rows were emitted before the column existed and read as NULL afterADD COLUMN. The delta view never re-emits them, because it only scans Siphon rows changed in its window and a failed flow is terminal. Migration20261015085307re-emits the current enriched row for each of those ids with the reason filled in, every other column taken withargMax(..., _version)so thelater_*arrays are kept, and_versionset tomax(_version) + 1 microsecond, the same stamping rule as the mr_outcomes view. It skips rows that already carry a reason, so re-running it is idempotent;downis a no-op because the re-emitted rows differ only by_version. The affected set is bounded by failures over a few days, so it is a singleINSERT ... SELECT. - The column has no default and no index.
- The recreated views repopulate on their normal refresh schedule.
Tests
Three new examples in ee/spec/models/analytics/aggregation_engines/duo_workflows_spec.rb:
- Dimension grouping, including the NULL bucket, and a two-version flow that checks the dedup subquery reads
drop_reasonfrom the latest version. - Positive filter.
- Negative filter, which also drops NULL rows.
- Unknown names in either filter (match nothing, exclude nothing), and a mixed list of known and unknown names (only the known ones apply).
spec/db/click_house/migrate/20261015085307_backfill_drop_reason_on_duo_workflows_workflows_enriched_spec.rb runs the backfill migration against fixture rows: the reason is filled only where the latest Siphon row has one and the enriched row has none, the later_* arrays survive, the re-emitted row sits one microsecond above the previous version, rows with no reason, an existing reason or a retry-cleared reason stay as they were, and a second run changes nothing.
I wrote them first and watched them fail with "the specified identifier is not available: 'drop_reason'". The fixture builder now inserts drop_reason. After !256455 (merged) merged, the type moved from an integer enum to the text vocabulary and the examples were updated to the stored names.
How to validate
-
Run the ClickHouse migrations:
bundle exec rake gitlab:clickhouse:migrate -
Run the engine spec:
bundle exec rspec ee/spec/models/analytics/aggregation_engines/duo_workflows_spec.rb -
Run the Siphon schema spec (unchanged by this MR, kept as a guard):
bundle exec rspec spec/db/clickhouse_siphon_tables_spec.rb
Related to #631550
Implementation plan (from the 4-MR plan for #631550)
MR 2 — Replicate to ClickHouse (gitlab-org/gitlab)
Stacked on MR 1. The Siphon table column itself ships in MR 1: spec/db/clickhouse_siphon_tables_spec.rb fails any MR that adds a Postgres column to a replicated table without the ClickHouse column, so the Siphon migration cannot be split out. MR 2 adds the enriched column, the views, and the engine.
Task 5: Siphon column, enriched column, refresh MVs
Files:
- Create:
db/click_house/migrate/main/<ts1>_add_drop_reason_to_siphon_duo_workflows_workflows.rb - Create:
db/click_house/migrate/main/<ts2>_add_drop_reason_to_duo_workflows_workflows_enriched.rb - Create:
db/click_house/migrate/main/<ts3>_recreate_duo_workflows_workflows_enriched_delta_mv_with_drop_reason.rb - Create:
db/click_house/migrate/main/<ts4>_recreate_duo_workflows_workflows_enriched_mr_outcomes_mv_with_drop_reason.rb - Modify:
db/click_house/main.sql(generated) - Test:
spec/db/clickhouse_siphon_tables_spec.rb(existing, must stay green), new example inee/spec/models/analytics/aggregation_engines/duo_workflows_spec.rbin Task 6 covers the data path.
Interfaces:
-
Produces:
drop_reason Nullable(Int16)onsiphon_duo_workflows_workflowsandduo_workflows_workflows_enriched, copied by both refresh MVs. -
Step 1: Siphon table column
# frozen_string_literal: true
class AddDropReasonToSiphonDuoWorkflowsWorkflows < ClickHouse::Migration
def up
execute <<~SQL
ALTER TABLE siphon_duo_workflows_workflows
ADD COLUMN IF NOT EXISTS drop_reason Nullable(Int16);
SQL
end
def down
execute <<~SQL
ALTER TABLE siphon_duo_workflows_workflows
DROP COLUMN IF EXISTS drop_reason;
SQL
end
end- Step 2: Enriched table column
Same shape against duo_workflows_workflows_enriched, placed after execution_mode with AFTER execution_mode so main.sql stays readable.
- Step 3: Recreate the two refresh MVs
Copy the full CREATE MATERIALIZED VIEW duo_workflows_workflows_enriched_delta_mv ... statement from db/click_house/main.sql (around line 4047) into the migration, add `drop_reason` Nullable(Int16), to the column list after execution_mode, and add drop_reason to the SELECT list in the same position. The migration body is:
def up
execute 'DROP VIEW IF EXISTS duo_workflows_workflows_enriched_delta_mv'
execute <<~SQL
<full CREATE MATERIALIZED VIEW statement with the new column>
SQL
end
def down
execute 'DROP VIEW IF EXISTS duo_workflows_workflows_enriched_delta_mv'
execute <<~SQL
<the statement exactly as it is in main.sql today>
SQL
endDo the same for duo_workflows_workflows_enriched_mr_outcomes_mv (around line 4287). Refreshable MVs are dropped and recreated, there is no ALTER for them.
- Step 4: Run the ClickHouse migrations locally and regenerate main.sql
./tmp/run.sh bundle exec rake gitlab:clickhouse:migrate
RAILS_ENV=test ./tmp/run.sh bundle exec rake gitlab:clickhouse:migrate
git diff --stat db/click_house/main.sqlExpected: main.sql gains the column in three tables/views and both MV definitions change only by the new column.
- Step 5: Run the Siphon schema spec
./tmp/run.sh bundle exec rspec spec/db/clickhouse_siphon_tables_spec.rbExpected: pass. If it reports a type mismatch, check Gitlab::ClickHouse::SiphonGenerator::PG_TYPE_MAP for smallint.
- Step 6: Commit
git add db/click_house
git commit -m "Replicate Duo Workflow drop_reason to ClickHouse"Task 6: Engine dimension and filter
Files:
- Modify:
ee/app/models/analytics/aggregation_engines/duo_workflows.rb:21-54(dimensions) and:134-145(filters) - Test:
ee/spec/models/analytics/aggregation_engines/duo_workflows_spec.rb - Modify:
doc/api/graphql/reference/_index.md(generated bybundle exec rake gitlab:graphql:compile_docs) - Modify:
doc/user/glql/data_sources/duo_workflows.md(add the field to the dimensions and filters tables)
Interfaces:
-
Produces: dimension
drop_reason(GraphQLdropReason, String, formatted name) and filterdropReason: [String!]/dropReasonNot. -
Step 1: Write the failing specs
In duo_workflows_spec.rb, inside describe 'dimensions':
describe 'drop_reason' do
let(:flows_data) do
[
{ id: 1, status: 3, drop_reason: 1, created_at: 10.days.ago },
{ id: 2, status: 3, drop_reason: 3, created_at: 10.days.ago },
{ id: 3, status: 3, drop_reason: nil, created_at: 10.days.ago },
{ id: 4, status: 2, drop_reason: nil, created_at: 10.days.ago }
]
end
it 'groups failed flows by the recorded reason, with unknown as nil' do
request = { dimensions: [{ identifier: :drop_reason }], metrics: [{ identifier: :total_count }] }
expect(engine).to execute_aggregation(request).and_return(an_array_matching([
{ drop_reason: 'timeout', total_count: 1 },
{ drop_reason: 'ci_failure', total_count: 1 },
{ drop_reason: nil, total_count: 2 }
]))
end
endInside describe 'filters':
describe 'drop_reason' do
let(:flows_data) do
[
{ id: 1, status: 3, drop_reason: 1, created_at: 10.days.ago },
{ id: 2, status: 3, drop_reason: 3, created_at: 10.days.ago }
]
end
it 'filters by reason name' do
request = { filters: [{ identifier: :drop_reason, values: ['ci_failure'] }], metrics: [{ identifier: :total_count }] }
expect(engine).to execute_aggregation(request).and_return([{ total_count: 1 }])
end
endUse the status integer for failed from Ai::DuoWorkflows::Workflow.state_machines[:status].states[:failed].value instead of the literal 3 if the fixture helper does not map names. Add drop_reason: flow[:drop_reason] to the clickhouse_fixture row builder at the top of the spec.
- Step 2: Run them to see them fail
./tmp/run.sh bundle exec rspec ee/spec/models/analytics/aggregation_engines/duo_workflows_spec.rb -e 'drop_reason'Expected: FAIL, unknown identifier drop_reason.
- Step 3: Implement
In the engine, after column :status:
column :drop_reason, :string,
description: 'Why a failed flow was dropped: timeout, reconcile, ci_failure, start_failure, ' \
'agent_error. `null` for flows that did not fail or whose drop did not record a reason',
formatter: ->(value) { ::Ai::DuoWorkflows::Workflow::DROP_REASONS.key(value)&.to_s }In filters, after exact_match :status:
exact_match :drop_reason, :string,
description: 'Filter by one or many drop reasons (timeout, reconcile, ci_failure, start_failure, agent_error)',
formatter: ->(values) { ::Ai::DuoWorkflows::Workflow::DROP_REASONS.values_at(*values.map(&:to_sym)).compact }Mirror how status maps names to integers in this file (STATUSES.values_at), and add the exact_not_match twin if status has one.
- Step 4: Run the engine spec, regenerate GraphQL docs
./tmp/run.sh bundle exec rspec ee/spec/models/analytics/aggregation_engines/duo_workflows_spec.rb
./tmp/run.sh bundle exec rake gitlab:graphql:compile_docsExpected: spec green; doc/api/graphql/reference/_index.md gains DuoWorkflowsAggregationResponseDimensions.dropReason and the dropReason / dropReasonNot arguments.
- Step 5: Update the GLQL data source doc
In doc/user/glql/data_sources/duo_workflows.md, add dropReason to the dimensions table and the filters table with the five values and the null meaning. US spelling, serial comma, no bold for emphasis.
- Step 6: Commit and open MR 2
git add ee/app/models/analytics/aggregation_engines/duo_workflows.rb ee/spec doc
git commit -m "Expose Duo Workflow drop reason in the aggregation engine"Title [skip ci] Replicate and expose the Duo Workflow drop reason. Labels from the issue plus backend, database, clickhouse. Request ~database review; state that drop_reason on rows written before MR 1 is NULL by construction.