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

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.000

Plan on postgres.ai

Batch 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.000

Plan on postgres.ai

Batch 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.000

Plan on postgres.ai

Trial 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.000

Plan on postgres.ai

Load 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.000

Plan on postgres.ai

Turn 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.000

Plan on postgres.ai

Record 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.000

Plan on postgres.ai

Local 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

  1. Start a local CustomersDot and point GDK at it.

  2. Start a trial for a group with the orbitStartTrial mutation.

  3. Expire the trial in CustomersDot.

  4. Run the batch job for the group in the Rails console:

    Analytics::Orbit::DisableEndedTrialsWorker.new.perform(group.id, group.id)
  5. The group now shows Orbit off and the trial state ended.

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist.

Edited by Jean-Gabriel Doyon

Merge request reports

Loading
Loading