mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
Generated
+1
@@ -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
|
||||
|
||||
Generated
+3
-2
@@ -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';
|
||||
@@ -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) {
|
||||
|
||||
Generated
+1
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user