Partition and rename the per-namespace billable usage table
What does this MR do and why?
Partitions and renames the per-namespace billable usage table ahead of the export rework agreed in database review on !256937 (merged).
The air-gapped usage export sums every per-namespace row in the licence term, so its cost grows with root namespaces x feature names x days. The rework moves the export onto an instance-wide daily totals table, maintained by a worker (next MR). This table keeps the per-namespace detail, which is retained for future namespace-level association, so it gets monthly partitions and a retention window, as the database review suggested while it is still empty.
billable_usage_daily_aggregatesis dropped and recreated asbillable_usage_daily_namespace_aggregates, partitioned monthly byusage_datewith three years of retention. It is written only behind thelocal_billing_persistenceandbilling_event_trackingflags on offline-licensed instances, so it holds no data anywhere.- The new name says the scope, next to the
billable_usage_daily_totalstable that follows. - The unique key puts
root_namespace_idlast:(usage_date, event_type, feature_qualified_name, operation_type, root_namespace_id). Its leading four columns are then the export key, so summing one day across namespaces, which the totals worker in !258358 (merged) does, can read one partition's index in key order. The planner may still prefer the primary key with a sort; the per-day query and its plan are in !258358 (merged). event_aggregate_uuidand its unique index are not carried over. The export derives its ownevent_id(!256937 (merged)), the totals table has none, and the uuid was a hash of the unique tuple, so nothing read it and its index enforced nothing the tuple index does not.- The model becomes
Utilization::BillableUsage::DailyNamespaceAggregate.
Related to https://gitlab.com/gitlab-org/customers-gitlab-com/-/work_items/18552.
Database
Drops an empty table and creates a partitioned one; no data migration. gitlab_main_cell_local,
table_size: small. The drop precedent is
20260710120000_repartition_p_duo_workflows_checkpoint_blobs_by_workflow_created_at; the
monthly date-keyed partitioning follows ai_events_counts.
CREATE TABLE billable_usage_daily_namespace_aggregates (
...
) PARTITION BY RANGE (usage_date);
ALTER TABLE ONLY billable_usage_daily_namespace_aggregates
ADD CONSTRAINT billable_usage_daily_namespace_aggregates_pkey PRIMARY KEY (id, usage_date);
CREATE UNIQUE INDEX index_billable_usage_daily_ns_aggs_on_unique_tuple
ON ONLY billable_usage_daily_namespace_aggregates
USING btree (usage_date, event_type, feature_qualified_name, operation_type, root_namespace_id) NULLS NOT DISTINCT;Partitions come from the partition manager (retain_for: 3.years, monthly strategy). After
migrating, sync_partitions created 43 monthly partitions, billable_usage_daily_namespace_aggregates_202310
through _202704 (three years back, six months ahead).
Retention is declared in db/docs/data_retention/billable_usage_daily_namespace_aggregates.yml
(drop_partition, 1095 days). The per-namespace detail is kept so usage can later be associated
with namespaces on the instance. Air-gapped subscriptions mostly run on multi-year terms, and three
years follows that data
while creating fewer empty past partitions up front; it can be lengthened later if the
namespace-level work needs more history. The export reads the totals table, which is kept
indefinitely, so the export does not depend on this window.
The write path's upsert
RecordAggregateService changes only its conflict target, which follows the new unique key,
and it no longer writes a uuid. The upsert against the partitioned parent, captured from the service:
INSERT INTO "billable_usage_daily_namespace_aggregates" ("usage_date","event_type","feature_qualified_name","operation_type","root_namespace_id","unit_of_measure","quantity","events_count","created_at","updated_at")
VALUES ('2026-09-10', 'secrets_read', 'secrets_read', NULL, 42, 'request', 1.0, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT ("usage_date","event_type","feature_qualified_name","operation_type","root_namespace_id")
DO UPDATE SET quantity = billable_usage_daily_namespace_aggregates.quantity + EXCLUDED.quantity,
events_count = billable_usage_daily_namespace_aggregates.events_count + EXCLUDED.events_count,
updated_at = EXCLUDED.updated_at
RETURNING "id","usage_date"That is the counter form; a snapshot sets quantity = EXCLUDED.quantity instead. Plans from the
test database, inside a rolled-back transaction. The unique tuple index is the conflict arbiter
in both cases; Postgres routes the row to the usage_date partition at execution, which
EXPLAIN does not show as a separate node.
Conflicting row (update):
Insert on billable_usage_daily_namespace_aggregates (actual time=0.031..0.032 rows=1 loops=1)
Conflict Resolution: UPDATE
Conflict Arbiter Indexes: index_billable_usage_daily_ns_aggs_on_unique_tuple
Tuples Inserted: 0
Conflicting Tuples: 1
Buffers: shared hit=15
-> Result (actual time=0.001..0.001 rows=1 loops=1)
Buffers: shared hit=1New row (insert):
Insert on billable_usage_daily_namespace_aggregates (actual time=0.029..0.029 rows=1 loops=1)
Conflict Resolution: UPDATE
Conflict Arbiter Indexes: index_billable_usage_daily_ns_aggs_on_unique_tuple
Tuples Inserted: 1
Conflicting Tuples: 0
Buffers: shared hit=17
-> Result (actual time=0.001..0.002 rows=1 loops=1)
Buffers: shared hit=1Development seed
ee/db/fixtures/development/99_billable_usage_daily_namespace_aggregates.rb seeds a few rows
across two months through RecordAggregateService, so they are written like real rows, and
only when the table is empty. It is here because the
renamed table counts as new, and run-dev-fixtures-ee requires new tables to be seeded.
Data deletion
The migration drops billable_usage_daily_aggregates and recreates it, partitioned and
renamed, so Danger flags it as data deletion.
- Records affected: expected to be none. The table is written only when both
local_billing_persistenceandbilling_event_trackingare enabled, bothdevelopmentflags withdefault_enabled: false, and only on an offline cloud licence; the write path is excluded on GitLab.com (Gitlab::BillingEvents::Client#local_persistence_enabled?). An instance can only hold rows if an administrator enabled both development flags. - Recovery. With no rows there is nothing to recover. On an instance that did enable the flags, the dropped rows could only be restored from a database backup taken before the migration.
- User impact: none. Nothing reads the table yet (the export is unreleased), and the write path continues against the new table after the migration.
Expected size and access
- Growth. One row per root namespace per usage type per day with activity, on offline-licensed instances only; empty on GitLab.com. Retention caps it at three years.
- Reads and writes. One upsert per billable event (unchanged). Reads will come from the
totals worker in !258358 (merged), which finds recently changed days by
updated_atand adds the index on that column with that query. - Availability risk. None expected: small, capped by partition drops, off hot paths.
Migration output
From the test database; the quote_column_name lines are trimmed.
db:migrate:
main: == 20260928093000 ReplaceBillableUsageDailyAggregatesWithPartitionedTable: migrating
main: -- drop_table(:billable_usage_daily_aggregates, {if_exists: true})
main: -> 0.0388s
main: -- create_table(:billable_usage_daily_namespace_aggregates, {primary_key: [:id, :usage_date], options: "PARTITION BY RANGE (usage_date)", if_not_exists: true})
main: -> 0.0066s
main: == 20260928093000 ReplaceBillableUsageDailyAggregatesWithPartitionedTable: migrated (0.0506s)db:rollback:
main: == 20260928093000 ReplaceBillableUsageDailyAggregatesWithPartitionedTable: reverting
main: -- drop_table(:billable_usage_daily_namespace_aggregates, {if_exists: true})
main: -> 0.0355s
main: -- create_table(:billable_usage_daily_aggregates, {if_not_exists: true})
main: -> 0.0068s
main: == 20260928093000 ReplaceBillableUsageDailyAggregatesWithPartitionedTable: reverted (0.0473s)Rolling back and dumping gives back master's db/structure.sql apart from the position of
ai_events_counts. That table's block moves in any fresh dump since master removed
Ai::EventsCount in 0324b5f9, so db:check-migrations fails on it for every migration MR
and is skipped here with pipeline:skip-check-migrations, as on !257679. None of this MR's
tables are in that diff. Rolling back on an instance that had written rows would lose them,
but none exist while the flags are off.
References
- Issue: https://gitlab.com/gitlab-org/customers-gitlab-com/-/work_items/18552
- Database review that prompted this: !256937 (merged) (note_3908065381)
- Table and write path: !248498 (merged), !249324 (merged) (merged)
- Next: !258358 (merged) adds
billable_usage_daily_totalsand its worker; then !256937 (merged) reads the totals.
How to set up and validate locally
bin/rails db:migrate
bin/rails runner 'Gitlab::Database::Partitioning.sync_partitions([Utilization::BillableUsage::DailyNamespaceAggregate])'bundle exec rspec ee/spec/models/utilization/billable_usage/daily_namespace_aggregate_spec.rb \
ee/spec/services/utilization/billable_usage/record_aggregate_service_spec.rb \
ee/spec/lib/ee/gitlab/billing_events/client_spec.rb
bundle exec rspec -r spec_helper spec/db/schema_spec.rb spec/db/docs_spec.rb spec/db/retention_policy_definitions_spec.rbMR acceptance checklist
Evaluate this MR against the MR acceptance checklist.