Add reusable IAM outbox capture for OAuth applications
What does this MR do and why?
Introduce a reusable transactional-outbox capture for IAM data replication and
apply it to Authn::OauthApplication, the first replicated entity. Every
create, update, and destroy produces an iam_outbox row. The iam_outbox table
and model are already on master; this MR is the capture half. Backfilling
existing applications (!246600) and the drain worker that delivers rows to the
IAM stores are follow-ups.
- Reusable concern
Authn::IamReplication::Outboxable. A model includes it, declaresiam_replicable entity_type: '...', and implementsiam_outbox_delete_payload. Future IAM entities plug in the same way. - The outbox row is written in the model's in-transaction callbacks
(
after_create/after_update/after_destroy), so it is atomic with the domain mutation: if the surrounding transaction rolls back, no row remains. Doorkeeper'sapplication_classisAuthn::OauthApplication, so the callbacks cover every create, update, and destroy path. - The drain kick is deferred to
after_commitand is currently a no-op (schedule_iam_outbox_drain); it starts enqueuing once the drain worker lands. The durable outbox row is the binding record. upsertpayload is empty (the drain re-reads Rails);deletepayload carries onlyuid. No secret or token ever reaches the payload: this is enforced by the concern and model building the payload, not by the (permissive) JSON schema.iam_outbox_delete_payloadis a required override, so a new entity cannot silently emit an unidentifiable delete row.- Organization transfer moves
organization_idwithupdate_all, which bypasses the callbacks.GroupsServiceandUsersServicetherefore record the upserts explicitly viaOutboxable.record_iam_outbox_upsertsafter the bulk update, keeping the IAM shard key correct.
How to set up and validate locally
- Enable the flag:
Feature.enable(:iam_data_replication) - Create or update an
Authn::OauthApplication. Confirm oneAuthn::IamOutboxrow withevent_type: 'upsert', the matchingorganization_id, and an emptypayload. - Destroy it. Confirm a
deleterow whosepayloadis{ "uid" => ... }. - Wrap a create in a rolled-back transaction and confirm no row remains.
- Transfer a group or user that owns an application
(
Organizations::Transfer::GroupsService/UsersService) and confirm a freshupsertrow exists with the neworganization_id.
Feature flag
iam_data_replication (wip, disabled by default). A single flag for the whole
IAM replication effort. It is an instance-wide gate
(Feature.enabled?(:iam_data_replication, :instance)) covering the runtime
outbox writes and the transfer capture. No changelog while disabled by default.
Query plan
See:
The bulk write is Authn::IamOutbox.insert_all!(rows) in record_iam_outbox_upserts
(app/models/concerns/authn/iam_replication/outboxable.rb:49), run inside
relation.each_batch (batch size 1000). The per-mutation write is the single-row
Authn::IamOutbox.create! in write_iam_outbox_event (:78), firing on every
create/update/destroy. Tested on the gitlab-production-main clone with 5,000
oauth_applications seeded on owner_id = 9970 (Namespace), organization_id = 1.
The real each_batch read filters ... AND id >= <batch_start>; the batched plans
below express <batch_start> as a MIN(id) sub-select (the InitPlan node), which
does not run in production where each_batch passes a literal id.
Batched read + insert (organization transfer)
SELECT — sub-batch boundary query (~6ms)
https://postgres.ai/console/gitlab/gitlab-production-main/sessions/54116/commands/156566
SELECT id FROM oauth_applications
WHERE owner_type = 'Namespace' AND owner_id IN (9970) AND organization_id = 1
AND id >= (SELECT MIN(id) FROM oauth_applications WHERE owner_type='Namespace' AND owner_id=9970 AND organization_id=1)
ORDER BY id ASC LIMIT 1 OFFSET 1000;Limit (cost=6.93..6.93 rows=1 width=4) (actual time=5.182..5.184 rows=1 loops=1)
Buffers: shared hit=2507
I/O Timings: read=0.000 write=0.000
InitPlan 1
-> Aggregate (cost=3.45..3.46 rows=1 width=4) (actual time=3.063..3.064 rows=1 loops=1)
Buffers: shared hit=1252
I/O Timings: read=0.000 write=0.000
-> Index Scan using index_oauth_applications_on_owner_id_and_owner_type on public.oauth_applications oauth_applications_1 (cost=0.43..3.45 rows=1 width=4) (actual time=0.010..2.746 rows=5001 loops=1)
Index Cond: ((oauth_applications_1.owner_id = 9970) AND ((oauth_applications_1.owner_type)::text = 'Namespace'::text))
Filter: (oauth_applications_1.organization_id = 1)
Buffers: shared hit=1252
I/O Timings: read=0.000 write=0.000
-> Sort (cost=3.46..3.47 rows=1 width=4) (actual time=5.115..5.151 rows=1001 loops=1)
Sort Key: oauth_applications.id
Sort Method: top-N heapsort Memory: 49kB
Buffers: shared hit=2507
I/O Timings: read=0.000 write=0.000
-> Index Scan using index_oauth_applications_on_owner_id_and_owner_type on public.oauth_applications (cost=0.43..3.45 rows=1 width=4) (actual time=3.091..4.689 rows=5001 loops=1)
Index Cond: ((oauth_applications.owner_id = 9970) AND ((oauth_applications.owner_type)::text = 'Namespace'::text))
Filter: ((oauth_applications.id >= (InitPlan 1).col1) AND (oauth_applications.organization_id = 1))
Buffers: shared hit=2504
I/O Timings: read=0.000 write=0.000
Settings: effective_cache_size = '472585MB', jit = 'off', random_page_cost = '1.5', work_mem = '230MB', seq_page_cost = '4'
Query ID: 3802975858039579686
Summary:
Time: 6.048 ms
- planning: 0.796 ms
- execution: 5.252 ms
- I/O read: 0.000 ms
- I/O write: 0.000 ms
Shared buffers:
- hits: 2507 (~19.60 MiB) from the buffer pool
- reads: 0 from the OS file cache, including disk I/O
- dirtied: 0
- writes: 0SELECT — sub-batch read (~6ms)
https://postgres.ai/console/gitlab/gitlab-production-main/sessions/54116/commands/156567
SELECT id, organization_id FROM oauth_applications
WHERE owner_type = 'Namespace' AND owner_id IN (9970) AND organization_id = 1
AND id >= (SELECT MIN(id) FROM oauth_applications WHERE owner_type='Namespace' AND owner_id=9970 AND organization_id=1)
ORDER BY id ASC LIMIT 1000;Limit (cost=6.92..6.93 rows=1 width=12) (actual time=5.057..5.150 rows=1000 loops=1)
Buffers: shared hit=2507
I/O Timings: read=0.000 write=0.000
InitPlan 1
-> Aggregate (cost=3.45..3.46 rows=1 width=4) (actual time=2.647..2.648 rows=1 loops=1)
Buffers: shared hit=1252
I/O Timings: read=0.000 write=0.000
-> Index Scan using index_oauth_applications_on_owner_id_and_owner_type on public.oauth_applications oauth_applications_1 (cost=0.43..3.45 rows=1 width=4) (actual time=0.018..2.375 rows=5001 loops=1)
Index Cond: ((oauth_applications_1.owner_id = 9970) AND ((oauth_applications_1.owner_type)::text = 'Namespace'::text))
Filter: (oauth_applications_1.organization_id = 1)
Buffers: shared hit=1252
I/O Timings: read=0.000 write=0.000
-> Sort (cost=3.46..3.47 rows=1 width=12) (actual time=5.056..5.090 rows=1000 loops=1)
Sort Key: oauth_applications.id
Sort Method: top-N heapsort Memory: 94kB
Buffers: shared hit=2507
I/O Timings: read=0.000 write=0.000
-> Index Scan using index_oauth_applications_on_owner_id_and_owner_type on public.oauth_applications (cost=0.43..3.45 rows=1 width=12) (actual time=2.684..4.328 rows=5001 loops=1)
Index Cond: ((oauth_applications.owner_id = 9970) AND ((oauth_applications.owner_type)::text = 'Namespace'::text))
Filter: ((oauth_applications.id >= (InitPlan 1).col1) AND (oauth_applications.organization_id = 1))
Buffers: shared hit=2504
I/O Timings: read=0.000 write=0.000
Settings: random_page_cost = '1.5', work_mem = '230MB', seq_page_cost = '4', effective_cache_size = '472585MB', jit = 'off'
Query ID: 1293947542003100633
Summary:
Time: 6.014 ms
- planning: 0.766 ms
- execution: 5.248 ms
- I/O read: 0.000 ms
- I/O write: 0.000 ms
Shared buffers:
- hits: 2507 (~19.60 MiB) from the buffer pool
- reads: 0 from the OS file cache, including disk I/O
- dirtied: 0
- writes: 0INSERT — sub-batch insert_all! (~25ms per 1000 rows)
https://postgres.ai/console/gitlab/gitlab-production-main/sessions/54116/commands/156568
INSERT INTO iam_outbox
(entity_type, entity_id, organization_id, event_type, payload, created_at, updated_at)
SELECT 'oauth_application', o.id, o.organization_id, 0, '{}'::jsonb, NOW(), NOW()
FROM oauth_applications o
WHERE o.owner_type = 'Namespace' AND o.owner_id IN (9970) AND o.organization_id = 1
AND o.id >= (SELECT MIN(id) FROM oauth_applications WHERE owner_type='Namespace' AND owner_id=9970 AND organization_id=1)
ORDER BY o.id ASC LIMIT 1000;Insert on public.iam_outbox (cost=6.93..6.95 rows=0 width=0) (actual time=17.614..17.617 rows=0 loops=1)
Buffers: shared hit=12658 dirtied=26 written=26
WAL: records=5104 fpi=0 bytes=497621
I/O Timings: read=0.000 write=2.471
-> Subquery Scan on "*SELECT*" (cost=6.93..6.95 rows=1 width=190) (actual time=7.322..8.192 rows=1000 loops=1)
Buffers: shared hit=3521
WAL: records=31 fpi=0 bytes=3069
I/O Timings: read=0.000 write=0.000
-> Limit (cost=6.93..6.93 rows=1 width=96) (actual time=7.237..7.369 rows=1000 loops=1)
Buffers: shared hit=2507
I/O Timings: read=0.000 write=0.000
InitPlan 1
-> Aggregate (cost=3.45..3.46 rows=1 width=4) (actual time=4.089..4.090 rows=1 loops=1)
Buffers: shared hit=1252
I/O Timings: read=0.000 write=0.000
-> Index Scan using index_oauth_applications_on_owner_id_and_owner_type on public.oauth_applications (cost=0.43..3.45 rows=1 width=4) (actual time=0.016..3.662 rows=5001 loops=1)
Index Cond: ((oauth_applications.owner_id = 9970) AND ((oauth_applications.owner_type)::text = 'Namespace'::text))
Filter: (oauth_applications.organization_id = 1)
Buffers: shared hit=1252
I/O Timings: read=0.000 write=0.000
-> Sort (cost=3.47..3.47 rows=1 width=96) (actual time=7.236..7.295 rows=1000 loops=1)
Sort Key: o.id
Sort Method: top-N heapsort Memory: 131kB
Buffers: shared hit=2507
I/O Timings: read=0.000 write=0.000
-> Index Scan using index_oauth_applications_on_owner_id_and_owner_type on public.oauth_applications o (cost=0.43..3.46 rows=1 width=96) (actual time=4.131..6.119 rows=5001 loops=1)
Index Cond: ((o.owner_id = 9970) AND ((o.owner_type)::text = 'Namespace'::text))
Filter: ((o.id >= (InitPlan 1).col1) AND (o.organization_id = 1))
Buffers: shared hit=2504
I/O Timings: read=0.000 write=0.000
Trigger RI_ConstraintTrigger_c_2290945145 for constraint fk_rails_432d441dae: time=6.272 calls=1000
Settings: effective_cache_size = '472585MB', jit = 'off', random_page_cost = '1.5', work_mem = '230MB', seq_page_cost = '4'
Query ID: -1529901244399655894
Summary:
Time: 25.232 ms
- planning: 1.142 ms
- execution: 24.090 ms
- I/O read: 0.000 ms
- I/O write: 2.471 ms
Shared buffers:
- hits: 12658 (~98.90 MiB) from the buffer pool
- reads: 0 from the OS file cache, including disk I/O
- dirtied: 26 (~208.00 KiB)
- writes: 26 (~208.00 KiB)INSERT (single-row, per-mutation)
INSERT — Authn::IamOutbox.create! (~2.3ms)
https://postgres.ai/console/gitlab/gitlab-production-main/sessions/54307/commands/156960
INSERT INTO iam_outbox (organization_id, entity_id, event_type, entity_type, payload, created_at, updated_at)
VALUES (1, 424242, 0, 'oauth_application', '{}'::jsonb, NOW(), NOW());Insert on public.iam_outbox (cost=0.00..0.02 rows=0 width=0) (actual time=0.520..0.520 rows=0 loops=1)
Buffers: shared hit=141
WAL: records=5 fpi=0 bytes=452
I/O Timings: read=0.000 write=0.000
-> Result (cost=0.00..0.02 rows=1 width=190) (actual time=0.073..0.074 rows=1 loops=1)
Buffers: shared hit=15
I/O Timings: read=0.000 write=0.000
Trigger RI_ConstraintTrigger_c_2290945145 for constraint fk_rails_432d441dae: time=1.701 calls=1
Settings: jit = 'off', random_page_cost = '1.5', work_mem = '230MB', seq_page_cost = '4', effective_cache_size = '472585MB'
Query ID: 6936637358172976415
Summary:
Time: 2.326 ms
- planning: 0.068 ms
- execution: 2.258 ms
- I/O read: 0.000 ms
- I/O write: 0.000 ms
Shared buffers:
- hits: 141 (~1.10 MiB) from the buffer pool
- reads: 0 from the OS file cache, including disk I/O
- dirtied: 0
- writes: 0Conclusion
- Reads use
index_oauth_applications_on_owner_id_and_owner_type;organization_idandid >= <batch_start>are applied as filters. No sequential scan, no new index required. - The
insert_all!is ~25ms per 1000-row batch intoiam_outbox(PK-only index), ~498KB WAL per batch. The per-row FK trigger onorganization_id(fk_rails_432d441dae->organizations) accounts for ~6ms of that; it is inherent to the FK and bounded by the 1000-row batch. - The single-row
create!(hot path, every create/update/destroy) is ~2.3ms with no scan (Resultnode), dominated by the samefk_rails_432d441daeFK check (~1.7ms, 1 call), so per-mutation cost is negligible. each_batchcaps each batch at 1000 rows, so per-statement cost stays flat regardless of how many applications a transfer moves.