Turn off Orbit when its trial ends
What does this MR do and why?
This MR turns off Orbit for a group when its Orbit trial ends. A cron job runs every hour on GitLab.com only, just after CustomersDot updates trial credits. It schedules one job per batch of 100 groups with Orbit on, and at most 5 of these jobs run at the same time.
- For each trial group with Orbit on, the job asks CustomersDot for the trial state.
- If the trial ended, the job removes the group from Orbit indexing and tracks a new internal event. Queries stop when the Orbit token expires (5 minutes or less).
- Beta groups, groups with the Orbit trial flag off, and ended trials that an admin turned back on are skipped.
| CustomersDot says | Result |
|---|---|
| Trial ended (date passed or 0 credits) | Orbit turns off |
| Trial active, eligible, or not eligible | No change |
| Error for one group | That group is skipped until the next run |
| No answer or rate limited (429) | That batch stops, and its groups are checked next run |
If the trial becomes active again and the owner turns Orbit back on, the job checks the group again.
There is no paid check yet (Fulfillment action A2), so the trial flag must stay off for customers until it exists.
References
- Epic: https://gitlab.com/gitlab-org/orbit/-/work_items/19
- Closes https://gitlab.com/gitlab-org/orbit/knowledge-graph/-/work_items/1338
Screenshots or screen recordings
Not a UI change.
Database review
The cron reads the enabled groups (about 3,500 on GitLab.com) in batches of 100. All queries use existing indexes, so there is no new index. Plans come from postgres.ai (gitlab-production-main).
| Query | Runs | Time | Buffers |
|---|---|---|---|
| Batch start | Once per cron run | 2.2 ms | 3 |
| Batch boundary | Once per batch | 0.6 ms | 7 |
| Batch range | Once per batch | 0.7 ms | 9 |
| Trial groups in the batch | Once per batch job | 1.0 ms | 55 |
| Load the groups | Once per batch job | 22 ms | 21 |
| Turn off Orbit | Once per ended trial | 1.2 ms | 8 |
| Record the trial end | Once per ended trial | 0.6 ms | 4 |
Batch start
SELECT "knowledge_graph_enabled_namespaces"."root_namespace_id" FROM "knowledge_graph_enabled_namespaces" ORDER BY "knowledge_graph_enabled_namespaces"."root_namespace_id" ASC LIMIT 1 Limit (cost=0.28..0.30 rows=1 width=8) (actual time=1.792..1.793 rows=1 loops=1)
Buffers: shared read=3
I/O Timings: read=1.738 write=0.000
-> Index Only Scan using index_knowledge_graph_enabled_namespaces_on_root_namespace_id on public.knowledge_graph_enabled_namespaces (cost=0.28..81.11 rows=3536 width=8) (actual time=1.790..1.791 rows=1 loops=1)
Heap Fetches: 0
Buffers: shared read=3
I/O Timings: read=1.738 write=0.000Batch boundary
SELECT "knowledge_graph_enabled_namespaces"."root_namespace_id" FROM "knowledge_graph_enabled_namespaces" WHERE "knowledge_graph_enabled_namespaces"."root_namespace_id" >= 3000000 ORDER BY "knowledge_graph_enabled_namespaces"."root_namespace_id" ASC LIMIT 1 OFFSET 100 Limit (cost=2.83..2.86 rows=1 width=8) (actual time=0.060..0.061 rows=1 loops=1)
Buffers: shared hit=7
I/O Timings: read=0.000 write=0.000
-> Index Only Scan using index_knowledge_graph_enabled_namespaces_on_root_namespace_id on public.knowledge_graph_enabled_namespaces (cost=0.28..88.71 rows=3465 width=8) (actual time=0.037..0.055 rows=101 loops=1)
Index Cond: (knowledge_graph_enabled_namespaces.root_namespace_id >= 3000000)
Heap Fetches: 6
Buffers: shared hit=7
I/O Timings: read=0.000 write=0.000Batch range
SELECT MIN(root_namespace_id), MAX(root_namespace_id) FROM "knowledge_graph_enabled_namespaces" WHERE "knowledge_graph_enabled_namespaces"."root_namespace_id" >= 3000000 AND "knowledge_graph_enabled_namespaces"."root_namespace_id" < 5000000 Result (cost=0.72..0.73 rows=1 width=16) (actual time=0.043..0.044 rows=1 loops=1)
Buffers: shared hit=9
I/O Timings: read=0.000 write=0.000
InitPlan 1
-> Limit (cost=0.28..0.36 rows=1 width=8) (actual time=0.033..0.033 rows=1 loops=1)
Buffers: shared hit=6
I/O Timings: read=0.000 write=0.000
-> Index Only Scan using index_knowledge_graph_enabled_namespaces_on_root_namespace_id on public.knowledge_graph_enabled_namespaces (cost=0.28..4.24 rows=48 width=8) (actual time=0.031..0.032 rows=1 loops=1)
Index Cond: ((knowledge_graph_enabled_namespaces.root_namespace_id >= 3000000) AND (knowledge_graph_enabled_namespaces.root_namespace_id < 5000000))
Heap Fetches: 0
Buffers: shared hit=6
I/O Timings: read=0.000 write=0.000
InitPlan 2
-> Limit (cost=0.28..0.36 rows=1 width=8) (actual time=0.006..0.007 rows=1 loops=1)
Buffers: shared hit=3
I/O Timings: read=0.000 write=0.000
-> Index Only Scan Backward using index_knowledge_graph_enabled_namespaces_on_root_namespace_id on public.knowledge_graph_enabled_namespaces knowledge_graph_enabled_namespaces_1 (cost=0.28..4.24 rows=48 width=8) (actual time=0.006..0.006 rows=1 loops=1)
Index Cond: ((knowledge_graph_enabled_namespaces_1.root_namespace_id >= 3000000) AND (knowledge_graph_enabled_namespaces_1.root_namespace_id < 5000000))
Heap Fetches: 0
Buffers: shared hit=3
I/O Timings: read=0.000 write=0.000Trial groups in the batch
SELECT "orbit_namespace_enrollments".* FROM "orbit_namespace_enrollments" WHERE "orbit_namespace_enrollments"."source" = 2 AND "orbit_namespace_enrollments"."trial_ended_at" IS NULL AND "orbit_namespace_enrollments"."namespace_id" IN (SELECT "knowledge_graph_enabled_namespaces"."root_namespace_id" FROM "knowledge_graph_enabled_namespaces" WHERE "knowledge_graph_enabled_namespaces"."root_namespace_id" BETWEEN 3000000 AND 5000000) Nested Loop (cost=0.43..31.10 rows=1 width=34) (actual time=0.086..0.087 rows=0 loops=1)
Buffers: shared hit=55
I/O Timings: read=0.000 write=0.000
-> Index Only Scan using index_knowledge_graph_enabled_namespaces_on_root_namespace_id on public.knowledge_graph_enabled_namespaces (cost=0.28..4.26 rows=49 width=8) (actual time=0.043..0.062 rows=46 loops=1)
Index Cond: ((knowledge_graph_enabled_namespaces.root_namespace_id >= 3000000) AND (knowledge_graph_enabled_namespaces.root_namespace_id <= 5000000))
Heap Fetches: 4
Buffers: shared hit=9
I/O Timings: read=0.000 write=0.000
-> Index Scan using orbit_namespace_enrollments_pkey on public.orbit_namespace_enrollments (cost=0.15..0.54 rows=1 width=34) (actual time=0.000..0.000 rows=0 loops=46)
Index Cond: (orbit_namespace_enrollments.namespace_id = knowledge_graph_enabled_namespaces.root_namespace_id)
Filter: ((orbit_namespace_enrollments.trial_ended_at IS NULL) AND (orbit_namespace_enrollments.source = 2))
Buffers: shared hit=46
I/O Timings: read=0.000 write=0.000Load the groups
SELECT "namespaces".* FROM "namespaces" WHERE "namespaces"."id" IN (9970, 2244, 9979, 6543, 4249) Index Scan using namespaces_pkey on public.namespaces (cost=0.57..14.58 rows=5 width=366) (actual time=5.237..17.796 rows=5 loops=1)
Index Cond: (namespaces.id = ANY ('{9970,2244,9979,6543,4249}'::integer[]))
Buffers: shared hit=9 read=12
I/O Timings: read=17.653 write=0.000Turn off Orbit
DELETE FROM "knowledge_graph_enabled_namespaces" WHERE "knowledge_graph_enabled_namespaces"."root_namespace_id" = 9970 Delete on public.knowledge_graph_enabled_namespaces (cost=0.28..3.30 rows=0 width=0) (actual time=0.714..0.715 rows=0 loops=1)
Buffers: shared hit=7 read=1 dirtied=2
WAL: records=1 fpi=1 bytes=8247
I/O Timings: read=0.632 write=0.000
-> Index Scan using index_knowledge_graph_enabled_namespaces_on_root_namespace_id on public.knowledge_graph_enabled_namespaces (cost=0.28..3.30 rows=1 width=6) (actual time=0.670..0.671 rows=1 loops=1)
Index Cond: (knowledge_graph_enabled_namespaces.root_namespace_id = 9970)
Buffers: shared hit=5 read=1
I/O Timings: read=0.632 write=0.000Record the trial end
UPDATE "orbit_namespace_enrollments" SET "trial_ended_at" = '2026-10-02 19:00:00+00', "updated_at" = '2026-10-02 19:00:00+00' WHERE "orbit_namespace_enrollments"."namespace_id" = 9970 Update on public.orbit_namespace_enrollments (cost=0.15..3.17 rows=0 width=0) (actual time=0.025..0.026 rows=0 loops=1)
Buffers: shared hit=4
I/O Timings: read=0.000 write=0.000
-> Index Scan using orbit_namespace_enrollments_pkey on public.orbit_namespace_enrollments (cost=0.15..3.17 rows=1 width=22) (actual time=0.024..0.024 rows=0 loops=1)
Index Cond: (orbit_namespace_enrollments.namespace_id = 9970)
Buffers: shared hit=4
I/O Timings: read=0.000 write=0.000Local test with 1,000 trial groups
Run against a local CustomersDot, with 5 batch jobs at the same time.
| Case | Result |
|---|---|
| 300 ended, 650 active, 50 flag off | 300 turned off, 50 never sent to CustomersDot |
| One batch, one job, 100 trial groups | About 15 s |
| CustomersDot hangs | Each batch stops after 1 call (4 s) |
| CustomersDot stopped | Each batch stops after 1 call (0.2 s) |
How to set up and validate locally
-
Start a local CustomersDot and point GDK at it.
-
Start a trial for a group with the
orbitStartTrialmutation. -
Expire the trial in CustomersDot.
-
Run the batch job for the group in the Rails console:
Analytics::Orbit::DisableEndedTrialsWorker.new.perform(group.id, group.id) -
The group now shows Orbit off and the trial state ended.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist.