Store each externally stored field in its own payload and expose hasContent
What does this MR do and why?
Gitlab::ExternallyStoredField stores some model fields as a JSON document in object storage instead of Postgres. Two models use it: WorkItems::AgentPlan (content, readiness_score_feedback) and Ci::Catalog::BundledResources::Version (readme).
The goal was to answer "does this work item have a plan?" without reading object storage. The first attempt added one file column per model, holding the document's filename, so its presence meant a payload exists.
That broke in review, when readiness_score_feedback moved into the content payload. One column for a shared document says something is stored, not what, so a plan with only feedback reported content.
Two reviewers agreed on the fix: one document and one column pair per field. See !251354 (comment 3846581473) and !251354 (comment 3862419390)
Framework
In lib/gitlab/externally_stored_field.rb and lib/gitlab/markdown_cache/external_storage/extension.rb:
- Fields register against a named payload:
externally_stored_field(field, payload: field).externally_stored_payloadsmaps payloads to fields. mount_external_storagemounts one CarrierWave uploader per payload, with<payload>_fileand<payload>_file_storecolumns.- Loading, writing, clearing and destroy cleanup are per payload, so
contentno longer touchesreadiness_score_feedback. external_payload_filename(payload)defaults to<payload>.json. Writes reuse the name already in the column, so backfilled rows keep pointing at the document they already have. No blob moves.cache_markdown_fieldkeeps a field's_htmlin its markdown field's payload.
FileStoreMounter
app/models/concerns/file_store_mounter.rb held an allowlist of mount column names (file, signed_file, predicate_file), so every new field had to be added there. It now checks the file / <prefix>_file convention it was guarding, as suggested in review.
Consumers
Ci::Catalog::BundledResources::Versiondrops itsexternal_payload_filenameoverride; the new default already returnsreadme.json. The unusedReadmeUploader::FILENAMEconstant is removed.WorkItems::AgentPlangainshas_content?, which readscontent_filealone. It overrides the filename forcontent.payloadStoredbecomeshasContenton the agent plan widget. That field was never released, so there is no deprecation cycle.- Both models add
ignore_columns :file_storewithremove_with: '19.7', since each payload now carries its own store column.
Database
Two migrations add columns: content_file, content_file_store, readiness_score_feedback_file, readiness_score_feedback_file_store on work_item_agent_plans, and readme_file, readme_file_store on catalog_bundled_resource_versions. The earlier file column and its migrations are removed from this MR; they never shipped.
Four more backfill the pointer columns, along with the store each document was written to. content_file gets the name the combined document already used, so nothing moves in object storage, and readme_file gets readme.json, which is both the existing name and the new default.
Two run pre-deploy, because the code that reads these columns ships in the same deployment. The other two run the same statements again post-deploy, for rows the old code writes while it is still rolling out. Those read back empty, and editing one in that state overwrites the document that was there. Asked for in !251354 (comment 3959253894).
readiness_score_feedback is dropped, not migrated: splitting it out would mean reading every document, and it is unreleased and unread. Agreed in !251354 (comment 3874258348)
A known false positive in hasContent
The backfill sets content_file on every existing work_item_agent_plans row, including rows that only ever held the blank document written by the bug fixed in !257562 (merged). SQL cannot tell those apart, so they report hasContent: true until content is actually written. New rows are correct, because that bug is fixed.
This is accepted rather than worked around. Not backfilling makes hasContent exact but loses the content of existing beta plans, and the alternative of checking object storage from a migration was tried and dropped: the uploader that builds the storage key is EE only while db/post_migrate is shared with FOSS, and a wrong key deletes pointers to real content. Settled in !251354 (comment 3920505682) and confirmed again in !251354 (comment 3978526316).
References
- Issue: #616229
- Comments that led to splitting the payloads: !251354 (comment 3846581473) and !251354 (comment 3862419390)
- Dropping
readiness_score_feedback: !251354 (comment 3874258348) - Blank payload fix, merged separately at a reviewer's request: !257562 (merged)
- Follow-up, dropping the dead
file_storecolumn: #630732
Screenshots or screen recordings
No UI change, so no screenshots apply.
How to set up and validate locally
-
Run the concern and model specs:
bundle exec rspec spec/lib/gitlab/externally_stored_field_spec.rb ee/spec/models/work_items/agent_plan_spec.rb spec/models/ci/catalog/bundled_resources/version_spec.rb -
In a Rails console, find a work item with an agent plan. Check that
work_item.reset.get_widget(:agent_plan).has_content_for_widget?returns true without callingread_external_payload, whileagent_plan.contentdoes call it.
Test results: 319 examples, 0 failures across the concern, both models, the widget, the GraphQL type, both uploaders, the callbacks and the catalog services. RuboCop is clean. Request specs cannot run in this local environment: a missing sass gem fails every request spec, so ee/spec/requests/api/graphql/work_item_spec.rb is covered by CI.
GraphQL
New field on WorkItemWidgetAgentPlan:
- Field:
hasContent - Type: Boolean!
- Token scopes: api, read_api, ai_workflows
- Resolves from:
content_filecolumn, no object storage read
content, contentHtml, readinessScoreFeedback and readinessScoreFeedbackHtml carry FieldCallCount with a limit of 1, because reading them costs an object storage round trip. So a list of work items can only resolve them for one item. hasContent reads a column, so it can be asked for every item in a list. The widget requires :update_work_item.
Single work item:
query {
workItem(id: "gid://gitlab/WorkItem/1") {
features {
agentPlan {
hasContent
}
}
}
}Across a list, which content cannot do:
query {
group(fullPath: "my-group") {
workItems {
nodes {
id
widgets {
... on WorkItemWidgetAgentPlan {
hasContent
}
}
}
}
}
}The generated reference entry is in doc/api/graphql/reference/_index.md.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist.