Make Secrets Manager activation read-your-writes safe on replicas
What does this MR do and why?
Makes the Secrets Manager activation read-your-writes safe for requests that did not perform the write.
Cause. The provisioning worker flips the group or project manager row to active from Sidekiq. No web request is stuck to the primary for that write, so while a replica has not replayed it a request still reads provisioning, and every reader of secrets_manager.active? returns "Secrets manager is not active" for an active manager. On GitLab.com this showed as a red alert on the Secrets page right after a customer started the trial: the status poll read ACTIVE from a current replica, the secrets list query 200 ms later read a replica that was still about 10 s behind (Kibana evidence in the issue).
Fix.
BaseSecretsManager#stick_activation!: called by both provision services right afteractivate!, records the primary's WAL position for the owner with the load balancer's sticking API (:group_secrets_manager/:project_secrets_manager, keyed by the owner id).BaseSecretsManager#refreshed_after_activation: a no-op without replicas and unless the row still readsprovisioning(the only state a lagging replica can show for an activated manager); then it asks the load balancer for a caught-up replica (or the primary) and reloads the row from it. Returnsnilwhen the row was already deleted there (activated and deprovisioned within the lag window), which callers treat as inactive and the status resolvers resolve through the pending deprovision task.- Readers call it before checking
active?:GroupSecrets/ProjectSecretsListServiceandReadMetadataService(through asecrets_manager_active?helper on the base services), both secrets count resolvers, both status resolvers (so the poll flips to ACTIVE as soon as the primary has it),BaseSecretsPermissionvalidation,ApiAccess::IssueTokenServiceand its REST endpoint guard inAPI::SecretsManagement::AccessTokens, andUserPermissions::EffectiveCapabilitiesService.
Deprovisioning already handles the reverse direction with the phantom DEPROVISIONING record; this MR gives provisioning the equivalent.
Resolves https://gitlab.com/gitlab-org/gitlab/-/work_items/633014
Cost and caveats
- Only while the row reads
provisioning(the provisioning window), per reader: one RedisGETfor the recorded position; when a position exists, a WAL catch-up check against each configured replica (LoadBalancer#select_up_to_date_host) and a Redis unstick script once all have caught up; then one indexed single-rowSELECT. Nothing changes for active managers. - The stick record expires after 30 s (the sticking default). After that a provisioning manager behaves as before.
- Single-database installs (GDK default, the Linux package without load balancing) skip all of it: the method returns early when the load balancer is primary-only, and the writer records nothing there either.
How to set up and validate locally
Needs a replica and load balancing in GDK:
gdk config set postgresql.replica.enabled true
gdk config set load_balancing.enabled true
gdk reconfigure && gdk restart
echo "recovery_min_apply_delay = '10s'" >> postgresql-replica/data/postgresql.conf
gdk restart postgresql-replicaGDK lists the primary socket and the replica as the two read hosts, so reads alternate between a current and a 10 s stale host, the production shape. Restart rails-web and rails-background-jobs after switching branches: with load balancing on, a development code reload leaves the load-balancer constants unresolvable in both processes.
The production state is: row active on the primary with its position recorded, still provisioning on a lagging replica. To create it by hand on a provisioned group:
update group_secrets_managers set status = 0 where group_id = <id>on the primary; wait until the replica shows0.update group_secrets_managers set status = 1 where group_id = <id>; select pg_current_wal_insert_lsn()on the primary, thenSET database-load-balancing/write-location/main/group_secrets_manager/<id> <lsn> EX 30in the load-balancing Redis (whatstick_activation!records).- Within 10 s, query
{ groupSecrets(groupPath: "<path>", first: 20) { edges { node { name } } } }and the status a few times with a PAT.
On master, reads that land on the lagging host return the GraphQL error "Secrets manager is not active" and the status alternates. On this branch every read succeeds and the status reads ACTIVE.
The self-managed E2E harness runs exactly this as scenario saas.replica.stale_active (fails on master 7a8e5bd5, passes on this branch); it also asserts that the real worker recorded the position after a natural provisioning.
Specs
secrets_manager_examples.rb(shared by both model specs):#stick_activation!records under the owner id;#refreshed_after_activationis a no-op when active, re-reads when the primary has the activation, keepsprovisioningwhen nothing has changed.- Both
ProvisionServicespecs: the activation is recorded for readers on lagging replicas. Fails without thestick_activation!call. GroupSecrets::ListServicespec: a manager instance that still readsprovisioningwhile the primary is active lists the secrets. Fails without the helper with "Secrets manager is not active".API::SecretsManagement::AccessTokensrequest spec: the same stale instance still mints a token (404 without the endpoint change).- Shared model examples also cover the no-replica short-circuit and the deleted-row
nil.
Screenshots or screen recordings
Reproduced on a GDK with a 10 s delayed replica; the page is loaded inside the window where the primary has the manager active and the replica still reads provisioning (harness scenario saas.replica.stale_active_ui, details in the MR note).
Before (master 2744d309) |
After (this branch) |
|---|---|
![]() |
![]() |
Videos: before , after .
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.

