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:

  1. The sharding key is namespace_id to namespaces, not organization_id. namespace_id is always not null and is the more specific key, matching gitlab_subscriptions and gitlab_subscription_histories. No organization_id column, no config/organizations/transfer_support.yml entry.
  2. credits_used and trial_type are nullable: credits come from CustomersDot and can be unavailable at capture time; trial type isn't stored on gitlab_subscriptions and 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_id on 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.yml as retention_window: 730, enforcement_strategy: drop_partition, enforcing: true; the partition manager drops monthly partitions older than 24 months (retain_for: 24.months on 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 changing retain_for.
  • Deletion: when a group is deleted, Groups::DestroyService removes the namespace row and the ON DELETE CASCADE foreign key drops its trial_usages rows. Snapshots hold nothing beyond the row (no uploads, counters or external state), so this is recorded as a cascade-only delete in spec/support/database/destroy_service_foreign_keys_todo.yml rather than declared with handles_removal_of, per Gitlab::HandlesRemovalOf. Declared in db/docs/data_retention/trial_usages.yml as indefinite_retention per 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" = 22

The 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 ms

The 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

Screenshots or screen recordings

No user-facing change.

How to set up and validate locally

  1. Run bin/rails db:migrate.
  2. Run bin/rspec ee/spec/models/gitlab_subscriptions/trial_usage_spec.rb.
  3. Run bundle exec rake db:seed_fu FILTER=99_trial_usages.
  4. Inspect the seeded row in a rails console with GitlabSubscriptions::TrialUsage.last.

MR acceptance checklist

🤖 Generated with Claude Code

Edited by Anas Shahid

Merge request reports

Loading
Loading