fix: enforce uniqueness and hour alignment for agent runtime usage events (#27983)

The usage generator writes `hb_agent_runtime_v1` rows with `created_at`
at the UTC hourly bucket start and exactly one row per bucket, but
nothing in the schema enforced either invariant. A duplicate bucket row
under a different id would be double-counted by any consumer summing
`runtime_ms`, and a misaligned `created_at` would skew which usage
period a bucket is attributed to.

This replaces the non-unique partial index
`idx_usage_events_agent_runtime` (from migration 000561) with a unique
index of the same shape and adds an hour-alignment `CHECK` constraint.
Both statements validate existing rows: every supported writer has
always produced conforming data, so a pre-existing violator is anomalous
and failing the migration loudly beats silently rewriting usage rows.
`generateBucket` treats a unique violation on the bucket index as
another replica having won the race, mirroring the existing `ON CONFLICT
(id)` no-op for committed rows.

The `coderd/notifications` sync commit and its revert cancel out (the
drift they addressed was fixed on main by #27979); the PR's net diff is
only the usage-event changes.

Part 1 of a 3-PR stack splitting up #27796 (see there for review
history). Stack: this PR → #27984 → #27985.
This commit is contained in:
Jaayden Halko
2026-08-17 16:23:45 +07:00
committed by GitHub
parent bf236bb340
commit 20c376a575
8 changed files with 132 additions and 10 deletions
+1
View File
@@ -57,6 +57,7 @@ const (
CheckTelemetryLockEventTypeConstraint CheckConstraint = "telemetry_lock_event_type_constraint" // telemetry_locks
CheckValidationMonotonicOrder CheckConstraint = "validation_monotonic_order" // template_version_parameters
CheckUsageEventTypeCheck CheckConstraint = "usage_event_type_check" // usage_events
CheckUsageEventsAgentRuntimeHourAligned CheckConstraint = "usage_events_agent_runtime_hour_aligned" // usage_events
CheckUserAIBudgetOverridesSpendLimitMicrosCheck CheckConstraint = "user_ai_budget_overrides_spend_limit_micros_check" // user_ai_budget_overrides
CheckUserAIProviderKeysAPIKeyCheck CheckConstraint = "user_ai_provider_keys_api_key_check" // user_ai_provider_keys
CheckUserSecretsEnabledRequiresTarget CheckConstraint = "user_secrets_enabled_requires_target" // user_secrets
+3 -2
View File
@@ -3552,7 +3552,8 @@ CREATE TABLE usage_events (
publish_started_at timestamp with time zone,
published_at timestamp with time zone,
failure_message text,
CONSTRAINT usage_event_type_check CHECK ((event_type = ANY (ARRAY['dc_managed_agents_v1'::text, 'hb_ai_seats_v1'::text, 'hb_agent_runtime_v1'::text])))
CONSTRAINT usage_event_type_check CHECK ((event_type = ANY (ARRAY['dc_managed_agents_v1'::text, 'hb_ai_seats_v1'::text, 'hb_agent_runtime_v1'::text]))),
CONSTRAINT usage_events_agent_runtime_hour_aligned CHECK (((event_type <> 'hb_agent_runtime_v1'::text) OR (date_trunc('hour'::text, timezone('UTC'::text, created_at)) = timezone('UTC'::text, created_at))))
);
COMMENT ON TABLE usage_events IS 'usage_events contains usage data that is collected from the product and potentially shipped to the usage collector service.';
@@ -4899,7 +4900,7 @@ CREATE INDEX idx_template_versions_has_ai_task ON template_versions USING btree
CREATE UNIQUE INDEX idx_unique_preset_name ON template_version_presets USING btree (name, template_version_id);
CREATE INDEX idx_usage_events_agent_runtime ON usage_events USING btree (event_type, created_at) WHERE (event_type = 'hb_agent_runtime_v1'::text);
CREATE UNIQUE INDEX idx_usage_events_agent_runtime ON usage_events USING btree (event_type, created_at) WHERE (event_type = 'hb_agent_runtime_v1'::text);
CREATE INDEX idx_usage_events_ai_seats ON usage_events USING btree (event_type, created_at) WHERE (event_type = 'hb_ai_seats_v1'::text);
@@ -0,0 +1,9 @@
-- IF EXISTS tolerates the index already being gone (e.g. rolling back out
-- of order during an incident) instead of failing.
DROP INDEX IF EXISTS idx_usage_events_agent_runtime;
CREATE INDEX idx_usage_events_agent_runtime
ON usage_events (event_type, created_at)
WHERE event_type = 'hb_agent_runtime_v1';
ALTER TABLE usage_events
DROP CONSTRAINT IF EXISTS usage_events_agent_runtime_hour_aligned;
@@ -0,0 +1,24 @@
-- The usage generator writes hb_agent_runtime_v1 rows with created_at at
-- the UTC hourly bucket start and exactly one row per bucket. Uniqueness
-- keeps any consumer that sums runtime_ms from counting a bucket twice;
-- the alignment CHECK protects the attribution model, which charges a
-- bucket to the usage period containing its start.
--
-- Both statements validate existing rows. Every supported writer has always
-- produced conforming data, so a pre-existing violator is anomalous and
-- failing the migration loudly beats silently rewriting usage rows.
ALTER TABLE usage_events
ADD CONSTRAINT usage_events_agent_runtime_hour_aligned
CHECK (
event_type <> 'hb_agent_runtime_v1'
OR date_trunc('hour', (created_at AT TIME ZONE 'UTC')) = (created_at AT TIME ZONE 'UTC')
);
-- Inserts keep their (id) arbiter: re-inserting a bucket under its
-- deterministic id stays a silent no-op, while a duplicate bucket row under
-- a different id raises a unique violation (generateBucket in
-- enterprise/coderd/usage/generator.go handles it).
DROP INDEX idx_usage_events_agent_runtime;
CREATE UNIQUE INDEX idx_usage_events_agent_runtime
ON usage_events (event_type, created_at)
WHERE event_type = 'hb_agent_runtime_v1';
+36 -5
View File
@@ -10911,11 +10911,10 @@ func TestUsageEventsTrigger(t *testing.T) {
insert("hb_agent_runtime_v1:2025-01-02_00:00:00", "hb_agent_runtime_v1", `{"runtime_ms": 250}`, day2)
requireDaily(`{"runtime_ms": 1500}`, `{"runtime_ms": 250}`)
// Re-inserting a bucket must not double-count it. The daily rollup
// sums runtime_ms, so idempotency rests on the aggregate trigger
// being AFTER INSERT: Postgres does not fire it for rows suppressed
// by ON CONFLICT (id) DO NOTHING. Concurrent replicas and backfill
// re-runs both take this path.
// Re-inserting a bucket under its deterministic id must not
// double-count it: the daily rollup's AFTER INSERT trigger does not
// fire for rows suppressed by the insert's ON CONFLICT (id)
// arbiter.
insert("hb_agent_runtime_v1:2025-01-01_00:00:00", "hb_agent_runtime_v1", `{"runtime_ms": 1000}`, day1)
requireDaily(`{"runtime_ms": 1500}`, `{"runtime_ms": 250}`)
@@ -10923,6 +10922,38 @@ func TestUsageEventsTrigger(t *testing.T) {
insert("hb-seats-1", "hb_ai_seats_v1", `{"count": 3}`, day2)
rows := getDailyRows(ctx, sqlDB)
require.Len(t, rows, 3)
// The same bucket under a different id is not an idempotent
// re-insert but a duplicate that would double any aggregate summing
// runtime_ms; the unique partial index
// idx_usage_events_agent_runtime rejects it loudly instead of the
// (id) arbiter silently dropping it.
err := db.InsertUsageEvent(ctx, database.InsertUsageEventParams{
ID: "different-id-same-bucket",
EventType: "hb_agent_runtime_v1",
EventData: []byte(`{"runtime_ms": 9999}`),
CreatedAt: day1,
})
require.True(t, database.IsUniqueViolation(err, database.UniqueIndexUsageEventsAgentRuntime),
"expected unique violation on idx_usage_events_agent_runtime, got %v", err)
// The rejected row must not have reached the daily rollup either.
rows = getDailyRows(ctx, sqlDB)
require.Len(t, rows, 3)
require.JSONEq(t, `{"runtime_ms": 1500}`, string(rows[0].UsageData))
// created_at must be the exact UTC hourly bucket start;
// usage_events_agent_runtime_hour_aligned rejects a misaligned row
// so it cannot skew the period a bucket is attributed to.
err = db.InsertUsageEvent(ctx, database.InsertUsageEventParams{
ID: "hb_agent_runtime_v1:misaligned",
EventType: "hb_agent_runtime_v1",
EventData: []byte(`{"runtime_ms": 100}`),
CreatedAt: day1.Add(30 * time.Minute),
})
require.ErrorContains(t, err, string(database.CheckUsageEventsAgentRuntimeHourAligned))
rows = getDailyRows(ctx, sqlDB)
require.Len(t, rows, 3)
require.JSONEq(t, `{"runtime_ms": 1500}`, string(rows[0].UsageData))
})
t.Run("UnknownEventType", func(t *testing.T) {
+1
View File
@@ -160,6 +160,7 @@ const (
UniqueIndexProvisionerDaemonsOrgNameOwnerKey UniqueConstraint = "idx_provisioner_daemons_org_name_owner_key" // CREATE UNIQUE INDEX idx_provisioner_daemons_org_name_owner_key ON provisioner_daemons USING btree (organization_id, name, lower(COALESCE((tags ->> 'owner'::text), ''::text)));
UniqueIndexTemplateVersionPresetsDefault UniqueConstraint = "idx_template_version_presets_default" // CREATE UNIQUE INDEX idx_template_version_presets_default ON template_version_presets USING btree (template_version_id) WHERE (is_default = true);
UniqueIndexUniquePresetName UniqueConstraint = "idx_unique_preset_name" // CREATE UNIQUE INDEX idx_unique_preset_name ON template_version_presets USING btree (name, template_version_id);
UniqueIndexUsageEventsAgentRuntime UniqueConstraint = "idx_usage_events_agent_runtime" // CREATE UNIQUE INDEX idx_usage_events_agent_runtime ON usage_events USING btree (event_type, created_at) WHERE (event_type = 'hb_agent_runtime_v1'::text);
UniqueIndexUsersEmail UniqueConstraint = "idx_users_email" // CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE ((deleted = false) AND (email <> ''::text));
UniqueIndexUsersUsername UniqueConstraint = "idx_users_username" // CREATE UNIQUE INDEX idx_users_username ON users USING btree (username) WHERE (deleted = false);
UniqueNotificationMessagesDedupeHashIndex UniqueConstraint = "notification_messages_dedupe_hash_idx" // CREATE UNIQUE INDEX notification_messages_dedupe_hash_idx ON notification_messages USING btree (dedupe_hash);