Add trial_usages table and GitlabSubscriptions::TrialUsage model
What does this MR do and why?
First of five MRs for the post-trial usage card on the group Billing page (issue https://gitlab.com/gitlab-org/gitlab/-/work_items/625030). This MR adds only the storage layer: the trial_usages table and the GitlabSubscriptions::TrialUsage model. No worker writes to it yet and nothing reads it yet; the capture worker (MR 2) and the card (MR 3) follow. The split is described in this comment: https://gitlab.com/gitlab-org/gitlab/-/work_items/625030#note_3898888844
Compute minutes only aggregate per calendar month, and CustomersDot only returns trial credits while a trial is active. So usage is snapshotted around day 30 of the trial and kept for the card to read for 45 days after the trial ends. A table is the only way to retain that data past the trial window. Agreed in the issue thread.
Database
Migration: db/migrate/20260924104800_create_trial_usages.rb, milestone 19.5, Gitlab::Database::Migration[2.3], disable_ddl_transaction!, create_table ... if_not_exists: true so a retry after a failed foreign-key step does not fail on the existing table. The table is range-partitioned monthly by trial_ends_on (PARTITION BY RANGE (trial_ends_on), primary_key: [:id, :trial_ends_on]), and the foreign key to namespaces is added with add_concurrent_partitioned_foreign_key. The model includes PartitionedTable with partitioned_by :trial_ends_on, strategy: :monthly, retain_for: 24.months and is registered in config/initializers/postgres_partitioning.rb. trial_ends_on is the partition key because it is immutable, the unique index already contains it (so MR 2's upsert keeps working), the card's 45-day read prunes on it, and dropping a partition means exactly "trials that ended more than 24 months ago". The monthly strategy keeps 6 months of headroom, covering end dates up to 60 days out at capture time.
| Column | Type | Nullable | Notes |
|---|---|---|---|
id |
bigint | no | composite primary key with trial_ends_on (partition key) |
namespace_id |
bigint | no | FK to namespaces, ON DELETE CASCADE, via add_concurrent_foreign_key |
created_at / updated_at |
timestamptz | no | |
trial_starts_on |
date | no | |
trial_ends_on |
date | no | |
compute_minutes_month |
date | no | first day of the month the compute figure covers |
max_seats_used |
integer | no | default 0 |
compute_minutes_used |
integer | no | default 0 |
credits_used |
decimal(14,4) | yes | null when CustomersDot could not be read |
trial_type |
text, limit 255 | yes | mirrors CustomersDot's trial type string, e.g. ultimate_with_dap |
Unique index index_trial_usages_on_namespace_id_and_trial_ends_on on (namespace_id, trial_ends_on) also serves as the foreign key index. Four CHECK constraints back the model validations at the database level, since the MR 2 capture worker writes with an upsert that skips ActiveRecord validations: max_seats_used >= 0, compute_minutes_used >= 0, credits_used >= 0 (NULL passes), and trial_ends_on > trial_starts_on (same operator as the model's comparison validator). Columns are ordered by type size per the column ordering guideline.
Dictionary: db/docs/trial_usages.yml, gitlab_schema: gitlab_main_org, sharding_key: namespace_id: namespaces, feature_categories: acquisition, table_size: small.
Two deviations from the issue's sketch, for reviewers:
- The sharding key is
namespace_idtonamespaces, notorganization_id.namespace_idis always not null and is the more specific key, matchinggitlab_subscriptionsandgitlab_subscription_histories. Noorganization_idcolumn, noconfig/organizations/transfer_support.ymlentry. credits_usedandtrial_typeare nullable: credits come from CustomersDot and can be unavailable at capture time; trial type isn't stored ongitlab_subscriptionsand may not be derivable.
Growth and access:
- One row per namespace per trial. Measured on a Database Lab clone from 2026-09-24: about 3100 Ultimate trials end per day on GitLab.com (158,483 active in total), so roughly 280k rows at 3 months, 560k at 6, 1.1M at 12, at about 100 bytes per row. Still a small table by the dictionary classification, bounded at 24 monthly partitions by the retention policy.
- Writes: one insert per trial from a daily cron worker (MR 2), idempotent upsert on
(namespace_id, trial_ends_on). - Reads: one lookup by
namespace_idon group Billing page render, for Free owners within 45 days of trial end (MR 3), served by the unique index. - Retention: insert-only. Declared in
db/docs/data_retention/trial_usages.ymlasretention_window: 730,enforcement_strategy: drop_partition,enforcing: true; the partition manager drops monthly partitions older than 24 months (retain_for: 24.monthson the model), which reclaims disk space without row deletes. Rows also disappear with their namespace via the cascade foreign key. Chosen after the database review thread; can be lowered later by changingretain_for. - Deletion: when a group is deleted,
Groups::DestroyServiceremoves the namespace row and theON DELETE CASCADEforeign key drops itstrial_usagesrows. Snapshots hold nothing beyond the row (no uploads, counters or external state), so this is recorded as a cascade-only delete inspec/support/database/destroy_service_foreign_keys_todo.ymlrather than declared withhandles_removal_of, perGitlab::HandlesRemovalOf. Declared indb/docs/data_retention/trial_usages.ymlasindefinite_retentionper the Data Retention Policy Framework; the thread on the issue wants trial history kept for future re-trial logic, and no deletion mechanism exists yet to enforce a window.
Queries: the only query this MR defines is the for_namespace scope. Nothing calls it yet; the read path lands in MR 3.
SELECT "trial_usages".* FROM "trial_usages" WHERE "trial_usages"."namespace_id" = 22The table does not exist in production yet, so there is no Database Lab plan. Local plan against the seeded development database (one row), served by the unique index:
Index Scan using index_trial_usages_on_namespace_id_and_trial_ends_on on trial_usages (cost=0.15..4.20 rows=3 width=102) (actual time=0.037..0.038 rows=1 loops=1)
Index Cond: (namespace_id = 22)
Buffers: shared hit=5
Planning Time: 1.621 ms
Execution Time: 0.072 msThe db:gitlabcom-database-testing job runs the migration against a production clone and posts its timing to this MR.
Model ee/app/models/gitlab_subscriptions/trial_usage.rb: belongs_to :namespace, optional: false, includes EachBatch for the future worker. Validations: presence of the three dates, uniqueness of trial_ends_on scoped to namespace_id, trial_ends_on after trial_starts_on, trial_type max length 255, non-negative max_seats_used/compute_minutes_used, non-negative credits_used when present. Scope for_namespace. EE::Namespace gains has_many :trial_usages.
Also added: a factory, the model spec, an association assertion in ee/spec/models/ee/namespace_spec.rb, and a seed fixture ee/db/fixtures/development/99_trial_usages.rb so db:migrate:multi-version-upgrade has a row to exercise.
References
- Issue: https://gitlab.com/gitlab-org/gitlab/-/work_items/625030
- MR split plan: https://gitlab.com/gitlab-org/gitlab/-/work_items/625030#note_3898888844
Screenshots or screen recordings
No user-facing change.
How to set up and validate locally
- Run
bin/rails db:migrate. - Run
bin/rspec ee/spec/models/gitlab_subscriptions/trial_usage_spec.rb. - Run
bundle exec rake db:seed_fu FILTER=99_trial_usages. - Inspect the seeded row in a rails console with
GitlabSubscriptions::TrialUsage.last.
MR acceptance checklist
- I have evaluated the MR acceptance checklist for this MR.