From 1cec4c4631dc9d6c0aca01a2787b4442008e85f0 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 10 Aug 2026 10:37:13 +0000 Subject: [PATCH 01/12] chore(coderd/notifications): sync formatting and rendered-template goldens with generators make fmt/go (gci) regroups the imports in smtp_internal_test.go, and make gen/golden-files re-renders the AI budget notification goldens with HTML-escaped apostrophes. Both were committed out of sync with the repo's pinned tools, so every local pre-commit run failed its unstaged check. No behavior change. --- coderd/notifications/dispatch/smtp_internal_test.go | 1 + .../smtp/TemplateAIBudgetLimitReachedAdmin.html.golden | 4 ---- .../smtp/TemplateAIBudgetLimitReachedUser.html.golden | 4 ++-- .../smtp/TemplateAIBudgetWarningUser.html.golden | 4 ++-- .../webhook/TemplateAIBudgetLimitReachedAdmin.json.golden | 4 ++-- 5 files changed, 7 insertions(+), 10 deletions(-) diff --git a/coderd/notifications/dispatch/smtp_internal_test.go b/coderd/notifications/dispatch/smtp_internal_test.go index 2e7dff8cbec..3557a42a0e3 100644 --- a/coderd/notifications/dispatch/smtp_internal_test.go +++ b/coderd/notifications/dispatch/smtp_internal_test.go @@ -9,6 +9,7 @@ import ( "github.com/coder/coder/v2/coderd/notifications/render" "github.com/coder/coder/v2/coderd/notifications/types" + markdown "github.com/coder/coder/v2/coderd/render" ) func TestSMTPHTMLTemplateEscapesAppearanceHelpers(t *testing.T) { diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden index 550fd9b56fd..f64981add03 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden @@ -17,8 +17,6 @@ User alice has reached their monthly AI budget limit ($1000.00). Subsequent= Effective group: Engineering -This limit is a per-user override. - AI budget period: July 1, 2026 - August 1, 2026 @@ -56,8 +54,6 @@ limit ($1000.00). Subsequent requests will be blocked.

Effective group: Engineering

-

This limit is a per-user override.

-

AI budget period: July 1, 2026 - August 1, 2026

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden index 37db6f733cc..94d00686923 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden @@ -30,7 +30,7 @@ Content-Type: text/html; charset=UTF-8 - Codestin Search App + Codestin Search App

- You've reached your monthly AI budget limit + You've reached your monthly AI budget limit

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden index 3927ab28e31..4d5ffdf4744 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 - Codestin Search App + Codestin Search App

- You're approaching your monthly AI budget limit + You're approaching your monthly AI budget limit

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden index 4315def7665..1523ba12285 100644 --- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden @@ -24,6 +24,6 @@ }, "title": "alice has reached their monthly AI budget limit", "title_markdown": "alice has reached their monthly AI budget limit", - "body": "User alice has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: Engineering\n\nThis limit is a per-user override.\n\nAI budget period: July 1, 2026 - August 1, 2026", - "body_markdown": "User **alice** has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: **Engineering**\n\nThis limit is a per-user override.\n\nAI budget period: July 1, 2026 - August 1, 2026" + "body": "User alice has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: Engineering\n\nAI budget period: July 1, 2026 - August 1, 2026", + "body_markdown": "User **alice** has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: **Engineering**\n\nAI budget period: July 1, 2026 - August 1, 2026" } \ No newline at end of file From 06c925f097b11d3ed0243267b15bcca048a0587f Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 10 Aug 2026 10:38:12 +0000 Subject: [PATCH 02/12] fix: enforce uniqueness and hour alignment for agent runtime usage events 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. Replace the non-unique partial index idx_usage_events_agent_runtime with a unique index of the same shape and add an hour-alignment CHECK constraint. 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. --- coderd/database/check_constraint.go | 1 + coderd/database/dump.sql | 5 ++- ...e_events_agent_runtime_invariants.down.sql | 9 ++++ ...age_events_agent_runtime_invariants.up.sql | 26 +++++++++++ coderd/database/querier.go | 8 +++- coderd/database/querier_test.go | 41 +++++++++++++++--- coderd/database/queries.sql.go | 8 +++- coderd/database/queries/usageevents.sql | 8 +++- coderd/database/unique_constraint.go | 1 + enterprise/coderd/usage/generator.go | 38 ++++++++-------- .../coderd/usage/generator_internal_test.go | 43 +++++++++++++++++++ 11 files changed, 155 insertions(+), 33 deletions(-) create mode 100644 coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql create mode 100644 coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql create mode 100644 enterprise/coderd/usage/generator_internal_test.go diff --git a/coderd/database/check_constraint.go b/coderd/database/check_constraint.go index 0402e4b8ee7..2f791242f5e 100644 --- a/coderd/database/check_constraint.go +++ b/coderd/database/check_constraint.go @@ -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 diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index d7595115c90..d70220b91ee 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -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); diff --git a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql new file mode 100644 index 00000000000..ed8b4ae78b8 --- /dev/null +++ b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql @@ -0,0 +1,9 @@ +-- IF EXISTS matches 000561's down migration, so rolling back out of order +-- (e.g. during an incident) is a no-op instead of a failure. +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; diff --git a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql new file mode 100644 index 00000000000..33c0c28f1a7 --- /dev/null +++ b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql @@ -0,0 +1,26 @@ +-- 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') + ); + +-- Replace the non-unique partial index with a unique one of the same shape, +-- so reads are served identically. 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 instead of being +-- counted twice (generateBucket in enterprise/coderd/usage/generator.go +-- handles the violation). +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'; diff --git a/coderd/database/querier.go b/coderd/database/querier.go index bbc90e02859..b90366b6096 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1160,8 +1160,12 @@ type sqlcQuerier interface { InsertTemplateVersionTerraformValuesByJobID(ctx context.Context, arg InsertTemplateVersionTerraformValuesByJobIDParams) error InsertTemplateVersionVariable(ctx context.Context, arg InsertTemplateVersionVariableParams) (TemplateVersionVariable, error) InsertTemplateVersionWorkspaceTag(ctx context.Context, arg InsertTemplateVersionWorkspaceTagParams) (TemplateVersionWorkspaceTag, error) - // Duplicate events are ignored intentionally to allow for multiple replicas to - // publish heartbeat events. + // Duplicate events are ignored intentionally to allow for multiple replicas + // to publish heartbeat events. The (id) arbiter scopes that tolerance to + // exact re-inserts of the same event: a duplicate hb_agent_runtime_v1 + // bucket under a different id raises on idx_usage_events_agent_runtime + // instead, which generateBucket in enterprise/coderd/usage/generator.go + // handles. InsertUsageEvent(ctx context.Context, arg InsertUsageEventParams) error InsertUser(ctx context.Context, arg InsertUserParams) (User, error) // InsertUserGroupsByID adds a user to all provided groups, if they exist. diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 44fc384b619..39d27c4475f 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -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) { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index f1029f44a15..d2551a623db 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -28708,8 +28708,12 @@ type InsertUsageEventParams struct { CreatedAt time.Time `db:"created_at" json:"created_at"` } -// Duplicate events are ignored intentionally to allow for multiple replicas to -// publish heartbeat events. +// Duplicate events are ignored intentionally to allow for multiple replicas +// to publish heartbeat events. The (id) arbiter scopes that tolerance to +// exact re-inserts of the same event: a duplicate hb_agent_runtime_v1 +// bucket under a different id raises on idx_usage_events_agent_runtime +// instead, which generateBucket in enterprise/coderd/usage/generator.go +// handles. func (q *sqlQuerier) InsertUsageEvent(ctx context.Context, arg InsertUsageEventParams) error { _, err := q.db.ExecContext(ctx, insertUsageEvent, arg.ID, diff --git a/coderd/database/queries/usageevents.sql b/coderd/database/queries/usageevents.sql index 8ba706b0c8a..03f435c5af7 100644 --- a/coderd/database/queries/usageevents.sql +++ b/coderd/database/queries/usageevents.sql @@ -1,6 +1,10 @@ -- name: InsertUsageEvent :exec --- Duplicate events are ignored intentionally to allow for multiple replicas to --- publish heartbeat events. +-- Duplicate events are ignored intentionally to allow for multiple replicas +-- to publish heartbeat events. The (id) arbiter scopes that tolerance to +-- exact re-inserts of the same event: a duplicate hb_agent_runtime_v1 +-- bucket under a different id raises on idx_usage_events_agent_runtime +-- instead, which generateBucket in enterprise/coderd/usage/generator.go +-- handles. INSERT INTO usage_events ( id, diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 4b1a4376f2d..23256ed3b86 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -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); diff --git a/enterprise/coderd/usage/generator.go b/enterprise/coderd/usage/generator.go index 91d911b57a7..a5b6788a214 100644 --- a/enterprise/coderd/usage/generator.go +++ b/enterprise/coderd/usage/generator.go @@ -48,8 +48,11 @@ const ( // Cron jobs, which sample live state when they fire, the Generator derives // events from data already persisted in the database, so it can // deterministically backfill hours missed while the deployment was down, -// zero-filling idle hours. Deterministic event IDs plus the database's -// ON CONFLICT (id) DO NOTHING make concurrent replicas safe without locking. +// zero-filling idle hours. Deterministic event IDs make concurrent replicas +// safe without locking: a re-insert of a committed bucket is a no-op via the +// insert's ON CONFLICT (id) arbiter, and two replicas racing an uncommitted +// bucket surface a unique violation that generateBucket recognizes as the +// other replica winning. // // Events are generated unconditionally in enterprise builds; the // publish_usage_data license flag only gates publishing to Tallyman. @@ -155,24 +158,12 @@ func (g *Generator) generateAgentRuntimeEvents(ctx context.Context) error { return xerrors.Errorf("list existing agent runtime events: %w", err) } // A row marks its bucket complete regardless of publish outcome, so a - // bucket whose event Tallyman permanently rejected is never - // regenerated (re-inserting under the deterministic ID is a no-op via - // ON CONFLICT (id) DO NOTHING). - // - // The runtime is not lost locally: the row still holds it, and the - // event can be re-queued for publishing with - // - // UPDATE usage_events - // SET published_at = NULL, publish_started_at = NULL, failure_message = NULL - // WHERE id = 'hb_agent_runtime_v1:'; - // - // That re-arm only has an effect while the bucket is inside the - // publisher's 30-day cutoff: SelectUsageEventsForPublishing also - // filters created_at > now - INTERVAL '30 days', and created_at is the - // bucket start, so past that the UPDATE reports success but the row is - // never picked up again. The release gate (Tallyman must accept this - // event type before coderd ships it) is what keeps permanent - // rejections exceptional. + // bucket whose event Tallyman permanently rejected is never regenerated + // (re-inserting under the deterministic ID is a no-op via the insert's + // ON CONFLICT (id) arbiter). The runtime is not lost locally: the row + // keeps it, and clearing the row's publish columns re-queues it while + // the bucket is within SelectUsageEventsForPublishing's 30-day + // created_at cutoff. existing := make(map[time.Time]struct{}, len(existingTimes)) for _, ts := range existingTimes { // created_at is always the exact bucket start for this event type; @@ -235,6 +226,13 @@ func (g *Generator) generateBucket(ctx context.Context, bucket time.Time) error // time) so daily rollups attribute backfilled hours to the correct day. stableID := string(usagetypes.UsageEventTypeHBAgentRuntimeV1) + ":" + bucket.Format(usageEventIDTimeFormat) err = g.ins.InsertHeartbeatUsageEvent(ctx, g.db, stableID, bucket, usagetypes.HBAgentRuntime{RuntimeMs: runtimeMs}) + if database.IsUniqueViolation(err, database.UniqueIndexUsageEventsAgentRuntime) { + // The insert's ON CONFLICT (id) arbiter only sees committed rows, so + // a concurrent replica inserting the same bucket can trip the bucket + // unique index instead. Either way a row for this bucket already + // exists, which is all generateBucket needs. + return nil + } if err != nil { return xerrors.Errorf("insert usage event: %w", err) } diff --git a/enterprise/coderd/usage/generator_internal_test.go b/enterprise/coderd/usage/generator_internal_test.go new file mode 100644 index 00000000000..fa6aeb63440 --- /dev/null +++ b/enterprise/coderd/usage/generator_internal_test.go @@ -0,0 +1,43 @@ +package usage + +import ( + "testing" + "time" + + "github.com/lib/pq" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbmock" + "github.com/coder/coder/v2/testutil" + "github.com/coder/quartz" +) + +// TestGenerateBucketUniqueViolation pins that a unique violation on the +// bucket index resolves the bucket as complete: another writer already +// recorded it. TestGeneratorConcurrentReplicas also reaches this path, but +// only when its goroutines actually interleave; this case cannot pass by +// scheduling accident. +func TestGenerateBucketUniqueViolation(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + ctrl := gomock.NewController(t) + mDB := dbmock.NewMockStore(ctrl) + gen := NewGenerator(quartz.NewMock(t), slogtest.Make(t, nil), mDB, NewDBInserter()) + + mDB.EXPECT(). + GetTotalChatMessageRuntimeMsInRange(gomock.Any(), gomock.Any()). + Return(int64(1000), nil) + mDB.EXPECT(). + InsertUsageEvent(gomock.Any(), gomock.Any()). + Return(&pq.Error{ + Code: "23505", // unique_violation + Constraint: string(database.UniqueIndexUsageEventsAgentRuntime), + }) + + bucket := time.Date(2025, 3, 10, 10, 0, 0, 0, time.UTC) + require.NoError(t, gen.generateBucket(ctx, bucket)) +} From 5ef08aafa51ccc53e35e55fef71b0ce40344d884 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 10 Aug 2026 12:04:06 +0000 Subject: [PATCH 03/12] revert: "chore(coderd/notifications): sync formatting and rendered-template goldens with generators" This reverts commit ed531d9d365720b4e394f4b422ea9776886c56a3. The gen/fmt drift it patched over came from main commit 07f79af65b2 (fix: markdown rendering improvements) having landed out of sync with its generators; main has since reverted that commit entirely in 8c2f7adeb1f (#27979), so the sync is obsolete and conflicts with the regenerated goldens on latest main. --- coderd/notifications/dispatch/smtp_internal_test.go | 3 ++- .../smtp/TemplateAIBudgetLimitReachedAdmin.html.golden | 4 ++++ .../smtp/TemplateAIBudgetLimitReachedUser.html.golden | 4 ++-- .../smtp/TemplateAIBudgetWarningUser.html.golden | 4 ++-- .../webhook/TemplateAIBudgetLimitReachedAdmin.json.golden | 4 ++-- 5 files changed, 12 insertions(+), 7 deletions(-) diff --git a/coderd/notifications/dispatch/smtp_internal_test.go b/coderd/notifications/dispatch/smtp_internal_test.go index 3557a42a0e3..03324f78573 100644 --- a/coderd/notifications/dispatch/smtp_internal_test.go +++ b/coderd/notifications/dispatch/smtp_internal_test.go @@ -7,9 +7,10 @@ import ( "github.com/stretchr/testify/require" + markdown "github.com/coder/coder/v2/coderd/render" + "github.com/coder/coder/v2/coderd/notifications/render" "github.com/coder/coder/v2/coderd/notifications/types" - markdown "github.com/coder/coder/v2/coderd/render" ) func TestSMTPHTMLTemplateEscapesAppearanceHelpers(t *testing.T) { diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden index f64981add03..550fd9b56fd 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden @@ -17,6 +17,8 @@ User alice has reached their monthly AI budget limit ($1000.00). Subsequent= Effective group: Engineering +This limit is a per-user override. + AI budget period: July 1, 2026 - August 1, 2026 @@ -54,6 +56,8 @@ limit ($1000.00). Subsequent requests will be blocked.

Effective group: Engineering

+

This limit is a per-user override.

+

AI budget period: July 1, 2026 - August 1, 2026

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden index 94d00686923..37db6f733cc 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden @@ -30,7 +30,7 @@ Content-Type: text/html; charset=UTF-8 - Codestin Search App + Codestin Search App

- You've reached your monthly AI budget limit + You've reached your monthly AI budget limit

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden index 4d5ffdf4744..3927ab28e31 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 - Codestin Search App + Codestin Search App

- You're approaching your monthly AI budget limit + You're approaching your monthly AI budget limit

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden index 1523ba12285..4315def7665 100644 --- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden @@ -24,6 +24,6 @@ }, "title": "alice has reached their monthly AI budget limit", "title_markdown": "alice has reached their monthly AI budget limit", - "body": "User alice has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: Engineering\n\nAI budget period: July 1, 2026 - August 1, 2026", - "body_markdown": "User **alice** has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: **Engineering**\n\nAI budget period: July 1, 2026 - August 1, 2026" + "body": "User alice has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: Engineering\n\nThis limit is a per-user override.\n\nAI budget period: July 1, 2026 - August 1, 2026", + "body_markdown": "User **alice** has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: **Engineering**\n\nThis limit is a per-user override.\n\nAI budget period: July 1, 2026 - August 1, 2026" } \ No newline at end of file From 41e1a2d3eecc05bef3f68aba31826b8827fd22ba Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 10 Aug 2026 13:21:33 +0000 Subject: [PATCH 04/12] updates for PR review --- .../000565_usage_events_agent_runtime_invariants.down.sql | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql index ed8b4ae78b8..8a6a0240988 100644 --- a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql +++ b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql @@ -1,5 +1,6 @@ --- IF EXISTS matches 000561's down migration, so rolling back out of order --- (e.g. during an incident) is a no-op instead of a failure. +-- IF EXISTS matches 000561's down migration: if the index is already gone +-- (e.g. rolling back out of order during an incident), tolerate the miss +-- 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) From 0c25db9ecd4ee693e86bf5fb22f4f297de353b2d Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 11 Aug 2026 06:16:52 +0000 Subject: [PATCH 05/12] chore(coderd/database): restore original InsertUsageEvent comment The expanded arbiter-scoping commentary is not needed on the insert query; the duplicate-bucket behavior is documented on the migration and in generateBucket where it is handled. --- coderd/database/querier.go | 8 ++------ coderd/database/queries.sql.go | 8 ++------ coderd/database/queries/usageevents.sql | 8 ++------ 3 files changed, 6 insertions(+), 18 deletions(-) diff --git a/coderd/database/querier.go b/coderd/database/querier.go index b90366b6096..bbc90e02859 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1160,12 +1160,8 @@ type sqlcQuerier interface { InsertTemplateVersionTerraformValuesByJobID(ctx context.Context, arg InsertTemplateVersionTerraformValuesByJobIDParams) error InsertTemplateVersionVariable(ctx context.Context, arg InsertTemplateVersionVariableParams) (TemplateVersionVariable, error) InsertTemplateVersionWorkspaceTag(ctx context.Context, arg InsertTemplateVersionWorkspaceTagParams) (TemplateVersionWorkspaceTag, error) - // Duplicate events are ignored intentionally to allow for multiple replicas - // to publish heartbeat events. The (id) arbiter scopes that tolerance to - // exact re-inserts of the same event: a duplicate hb_agent_runtime_v1 - // bucket under a different id raises on idx_usage_events_agent_runtime - // instead, which generateBucket in enterprise/coderd/usage/generator.go - // handles. + // Duplicate events are ignored intentionally to allow for multiple replicas to + // publish heartbeat events. InsertUsageEvent(ctx context.Context, arg InsertUsageEventParams) error InsertUser(ctx context.Context, arg InsertUserParams) (User, error) // InsertUserGroupsByID adds a user to all provided groups, if they exist. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index d2551a623db..f1029f44a15 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -28708,12 +28708,8 @@ type InsertUsageEventParams struct { CreatedAt time.Time `db:"created_at" json:"created_at"` } -// Duplicate events are ignored intentionally to allow for multiple replicas -// to publish heartbeat events. The (id) arbiter scopes that tolerance to -// exact re-inserts of the same event: a duplicate hb_agent_runtime_v1 -// bucket under a different id raises on idx_usage_events_agent_runtime -// instead, which generateBucket in enterprise/coderd/usage/generator.go -// handles. +// Duplicate events are ignored intentionally to allow for multiple replicas to +// publish heartbeat events. func (q *sqlQuerier) InsertUsageEvent(ctx context.Context, arg InsertUsageEventParams) error { _, err := q.db.ExecContext(ctx, insertUsageEvent, arg.ID, diff --git a/coderd/database/queries/usageevents.sql b/coderd/database/queries/usageevents.sql index 03f435c5af7..8ba706b0c8a 100644 --- a/coderd/database/queries/usageevents.sql +++ b/coderd/database/queries/usageevents.sql @@ -1,10 +1,6 @@ -- name: InsertUsageEvent :exec --- Duplicate events are ignored intentionally to allow for multiple replicas --- to publish heartbeat events. The (id) arbiter scopes that tolerance to --- exact re-inserts of the same event: a duplicate hb_agent_runtime_v1 --- bucket under a different id raises on idx_usage_events_agent_runtime --- instead, which generateBucket in enterprise/coderd/usage/generator.go --- handles. +-- Duplicate events are ignored intentionally to allow for multiple replicas to +-- publish heartbeat events. INSERT INTO usage_events ( id, From 6982b2fe68612b193f12a45ab4ae98be465ad8cd Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 11 Aug 2026 06:46:36 +0000 Subject: [PATCH 06/12] fix(coderd/database): renumber agent runtime invariants migration to 000567 Main landed its own 000565 and 000566 (oauth2 client type constraint and auth method backfill) after this branch was cut, making the migration number a duplicate that fails migrate setup. --- ....sql => 000567_usage_events_agent_runtime_invariants.down.sql} | 0 ...up.sql => 000567_usage_events_agent_runtime_invariants.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000565_usage_events_agent_runtime_invariants.down.sql => 000567_usage_events_agent_runtime_invariants.down.sql} (100%) rename coderd/database/migrations/{000565_usage_events_agent_runtime_invariants.up.sql => 000567_usage_events_agent_runtime_invariants.up.sql} (100%) diff --git a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql b/coderd/database/migrations/000567_usage_events_agent_runtime_invariants.down.sql similarity index 100% rename from coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql rename to coderd/database/migrations/000567_usage_events_agent_runtime_invariants.down.sql diff --git a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql b/coderd/database/migrations/000567_usage_events_agent_runtime_invariants.up.sql similarity index 100% rename from coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql rename to coderd/database/migrations/000567_usage_events_agent_runtime_invariants.up.sql From 99ab2350485b5bd483a47dc408788680b4c774c3 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 11 Aug 2026 07:12:28 +0000 Subject: [PATCH 07/12] chore(enterprise/coderd/usage): restore agent runtime requeue guidance comment --- enterprise/coderd/usage/generator.go | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/enterprise/coderd/usage/generator.go b/enterprise/coderd/usage/generator.go index a5b6788a214..af7fcd713ee 100644 --- a/enterprise/coderd/usage/generator.go +++ b/enterprise/coderd/usage/generator.go @@ -158,12 +158,24 @@ func (g *Generator) generateAgentRuntimeEvents(ctx context.Context) error { return xerrors.Errorf("list existing agent runtime events: %w", err) } // A row marks its bucket complete regardless of publish outcome, so a - // bucket whose event Tallyman permanently rejected is never regenerated - // (re-inserting under the deterministic ID is a no-op via the insert's - // ON CONFLICT (id) arbiter). The runtime is not lost locally: the row - // keeps it, and clearing the row's publish columns re-queues it while - // the bucket is within SelectUsageEventsForPublishing's 30-day - // created_at cutoff. + // bucket whose event Tallyman permanently rejected is never + // regenerated (re-inserting under the deterministic ID is a no-op via + // the insert's ON CONFLICT (id) arbiter). + // + // The runtime is not lost locally: the row still holds it, and the + // event can be re-queued for publishing with + // + // UPDATE usage_events + // SET published_at = NULL, publish_started_at = NULL, failure_message = NULL + // WHERE id = 'hb_agent_runtime_v1:'; + // + // That re-arm only has an effect while the bucket is inside the + // publisher's 30-day cutoff: SelectUsageEventsForPublishing also + // filters created_at > now - INTERVAL '30 days', and created_at is the + // bucket start, so past that the UPDATE reports success but the row is + // never picked up again. The release gate (Tallyman must accept this + // event type before coderd ships it) is what keeps permanent + // rejections exceptional. existing := make(map[time.Time]struct{}, len(existingTimes)) for _, ts := range existingTimes { // created_at is always the exact bucket start for this event type; From 92a01e2d31991925be48eaed6ccee01003e4995e Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 12 Aug 2026 13:32:48 +0000 Subject: [PATCH 08/12] fix(coderd/database): renumber agent runtime invariants migration to 000569 Main landed its own 000567 and 000568 (chat file purge indexes and service account notifications) after this branch was renumbered to 000567, making the migration number a duplicate that fails migrate setup in the test merge. --- ....sql => 000569_usage_events_agent_runtime_invariants.down.sql} | 0 ...up.sql => 000569_usage_events_agent_runtime_invariants.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000567_usage_events_agent_runtime_invariants.down.sql => 000569_usage_events_agent_runtime_invariants.down.sql} (100%) rename coderd/database/migrations/{000567_usage_events_agent_runtime_invariants.up.sql => 000569_usage_events_agent_runtime_invariants.up.sql} (100%) diff --git a/coderd/database/migrations/000567_usage_events_agent_runtime_invariants.down.sql b/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql similarity index 100% rename from coderd/database/migrations/000567_usage_events_agent_runtime_invariants.down.sql rename to coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql diff --git a/coderd/database/migrations/000567_usage_events_agent_runtime_invariants.up.sql b/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql similarity index 100% rename from coderd/database/migrations/000567_usage_events_agent_runtime_invariants.up.sql rename to coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql From fd1be2fba30fca0dbc8c122298078a4c88bc5a70 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 12 Aug 2026 13:45:24 +0000 Subject: [PATCH 09/12] chore(enterprise/coderd/usage): correct ON CONFLICT concurrency comments The (id) arbiter is not limited to committed rows: when a competing row's arbiter index entry is visible, PostgreSQL waits on that transaction and takes the DO NOTHING path if it commits. Only the narrower speculative-insertion race, before the arbiter entry exists, trips the bucket unique index. --- enterprise/coderd/usage/generator.go | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/enterprise/coderd/usage/generator.go b/enterprise/coderd/usage/generator.go index af7fcd713ee..cec94a1a511 100644 --- a/enterprise/coderd/usage/generator.go +++ b/enterprise/coderd/usage/generator.go @@ -49,10 +49,13 @@ const ( // events from data already persisted in the database, so it can // deterministically backfill hours missed while the deployment was down, // zero-filling idle hours. Deterministic event IDs make concurrent replicas -// safe without locking: a re-insert of a committed bucket is a no-op via the -// insert's ON CONFLICT (id) arbiter, and two replicas racing an uncommitted -// bucket surface a unique violation that generateBucket recognizes as the -// other replica winning. +// safe without locking: the insert's ON CONFLICT (id) arbiter turns a +// re-insert of a bucket into a no-op, even when the competing insert is +// still in flight (once its arbiter index entry is visible, PostgreSQL +// waits on that transaction and takes the DO NOTHING path if it commits). +// Only the narrow speculative-insertion race, before the competing row's +// arbiter entry exists, surfaces a bucket unique violation instead, which +// generateBucket recognizes as the other replica winning. // // Events are generated unconditionally in enterprise builds; the // publish_usage_data license flag only gates publishing to Tallyman. @@ -239,10 +242,13 @@ func (g *Generator) generateBucket(ctx context.Context, bucket time.Time) error stableID := string(usagetypes.UsageEventTypeHBAgentRuntimeV1) + ":" + bucket.Format(usageEventIDTimeFormat) err = g.ins.InsertHeartbeatUsageEvent(ctx, g.db, stableID, bucket, usagetypes.HBAgentRuntime{RuntimeMs: runtimeMs}) if database.IsUniqueViolation(err, database.UniqueIndexUsageEventsAgentRuntime) { - // The insert's ON CONFLICT (id) arbiter only sees committed rows, so - // a concurrent replica inserting the same bucket can trip the bucket - // unique index instead. Either way a row for this bucket already - // exists, which is all generateBucket needs. + // The insert's ON CONFLICT (id) arbiter absorbs most duplicate + // inserts, including in-flight ones: once a competing row's arbiter + // index entry is visible, PostgreSQL waits on that transaction and + // takes the DO NOTHING path if it commits. Only the narrow + // speculative-insertion race, before that entry exists, trips the + // bucket unique index instead. Either way a row for this bucket + // already exists, which is all generateBucket needs. return nil } if err != nil { From 33ff1b730e990b856575b5070b39d1ac55784d3e Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 13 Aug 2026 04:37:09 +0000 Subject: [PATCH 10/12] fix(coderd/notifications/dispatch): drop unused markdown import in smtp test The revert in cb36cfae2c5 left behind an unused "github.com/coder/coder/v2/coderd/render" import, breaking compilation of the dispatch test package and failing fmt, lint, and every test-go-pg job. Restore the import block to match main. --- coderd/notifications/dispatch/smtp_internal_test.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/coderd/notifications/dispatch/smtp_internal_test.go b/coderd/notifications/dispatch/smtp_internal_test.go index 03324f78573..2e7dff8cbec 100644 --- a/coderd/notifications/dispatch/smtp_internal_test.go +++ b/coderd/notifications/dispatch/smtp_internal_test.go @@ -7,8 +7,6 @@ import ( "github.com/stretchr/testify/require" - markdown "github.com/coder/coder/v2/coderd/render" - "github.com/coder/coder/v2/coderd/notifications/render" "github.com/coder/coder/v2/coderd/notifications/types" ) From 92bfe85a2a89f9438262f31e17c2cd59b465f516 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 14 Aug 2026 04:39:21 +0000 Subject: [PATCH 11/12] chore(coderd): trim historical framing from invariant comments Migration comments now state only the current invariant and rollback reason, and generateBucket defers the full ON CONFLICT concurrency contract to the Generator doc comment instead of repeating it. --- ...0569_usage_events_agent_runtime_invariants.down.sql | 5 ++--- ...000569_usage_events_agent_runtime_invariants.up.sql | 10 ++++------ enterprise/coderd/usage/generator.go | 10 +++------- 3 files changed, 9 insertions(+), 16 deletions(-) diff --git a/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql b/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql index 8a6a0240988..55d3b173185 100644 --- a/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql +++ b/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql @@ -1,6 +1,5 @@ --- IF EXISTS matches 000561's down migration: if the index is already gone --- (e.g. rolling back out of order during an incident), tolerate the miss --- instead of failing. +-- 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) diff --git a/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql b/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql index 33c0c28f1a7..7577b386f02 100644 --- a/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql +++ b/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql @@ -14,12 +14,10 @@ ALTER TABLE usage_events OR date_trunc('hour', (created_at AT TIME ZONE 'UTC')) = (created_at AT TIME ZONE 'UTC') ); --- Replace the non-unique partial index with a unique one of the same shape, --- so reads are served identically. 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 instead of being --- counted twice (generateBucket in enterprise/coderd/usage/generator.go --- handles the violation). +-- 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) diff --git a/enterprise/coderd/usage/generator.go b/enterprise/coderd/usage/generator.go index cec94a1a511..74e8ae9ac19 100644 --- a/enterprise/coderd/usage/generator.go +++ b/enterprise/coderd/usage/generator.go @@ -242,13 +242,9 @@ func (g *Generator) generateBucket(ctx context.Context, bucket time.Time) error stableID := string(usagetypes.UsageEventTypeHBAgentRuntimeV1) + ":" + bucket.Format(usageEventIDTimeFormat) err = g.ins.InsertHeartbeatUsageEvent(ctx, g.db, stableID, bucket, usagetypes.HBAgentRuntime{RuntimeMs: runtimeMs}) if database.IsUniqueViolation(err, database.UniqueIndexUsageEventsAgentRuntime) { - // The insert's ON CONFLICT (id) arbiter absorbs most duplicate - // inserts, including in-flight ones: once a competing row's arbiter - // index entry is visible, PostgreSQL waits on that transaction and - // takes the DO NOTHING path if it commits. Only the narrow - // speculative-insertion race, before that entry exists, trips the - // bucket unique index instead. Either way a row for this bucket - // already exists, which is all generateBucket needs. + // Another replica already created this bucket's row. The Generator + // doc comment explains why this race reaches the bucket unique + // index instead of the insert's ON CONFLICT (id) arbiter. return nil } if err != nil { From f67ff0120f190d29f5473fd9b316ac1e39a210d9 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 14 Aug 2026 05:16:23 +0000 Subject: [PATCH 12/12] fix(coderd/database): renumber agent runtime invariants migration to 000570 Main landed 000569_oauth2_scope_columns after the previous renumber to 000569, so the CI test merge contained two migrations with the same number and every migration-dependent job failed. dump.sql is unchanged because only the file number moves. --- ....sql => 000570_usage_events_agent_runtime_invariants.down.sql} | 0 ...up.sql => 000570_usage_events_agent_runtime_invariants.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000569_usage_events_agent_runtime_invariants.down.sql => 000570_usage_events_agent_runtime_invariants.down.sql} (100%) rename coderd/database/migrations/{000569_usage_events_agent_runtime_invariants.up.sql => 000570_usage_events_agent_runtime_invariants.up.sql} (100%) diff --git a/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql b/coderd/database/migrations/000570_usage_events_agent_runtime_invariants.down.sql similarity index 100% rename from coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql rename to coderd/database/migrations/000570_usage_events_agent_runtime_invariants.down.sql diff --git a/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql b/coderd/database/migrations/000570_usage_events_agent_runtime_invariants.up.sql similarity index 100% rename from coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql rename to coderd/database/migrations/000570_usage_events_agent_runtime_invariants.up.sql