Skip to content

Commit 3029328

Browse files
Merge P0-3+P0-4: storage-quota suspend constraint fix + infra revoke + auto-unsuspend
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2 parents f851f73 + e6ef7d5 commit 3029328

5 files changed

Lines changed: 783 additions & 31 deletions

File tree

internal/config/config.go

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,8 @@ type Config struct {
8080
// /internal/teams/:id/terminate against the api repo. Both must be
8181
// set for the terminator to act — otherwise the job short-circuits
8282
// each tick with a WARN.
83-
InstantAPIInternalURL string // INSTANT_API_INTERNAL_URL — base URL of api (cluster-local)
84-
WorkerInternalJWTSecret string // WORKER_INTERNAL_JWT_SECRET — HS256 shared with api
83+
InstantAPIInternalURL string // INSTANT_API_INTERNAL_URL — base URL of api (cluster-local)
84+
WorkerInternalJWTSecret string // WORKER_INTERNAL_JWT_SECRET — HS256 shared with api
8585

8686
// AES_KEY — 64-char hex key (32 bytes) used to decrypt
8787
// resources.connection_url for the customer-backup runner AND the real
@@ -110,6 +110,24 @@ type Config struct {
110110
// every platform dump in chronological order" without filtering customer
111111
// data.
112112
PlatformBackupS3Prefix string // PLATFORM_BACKUP_S3_PREFIX (default: "platform-backups/")
113+
114+
// Storage-quota enforcement: infra revoke/grant on suspend/unsuspend (P0-3/P0-4 fix).
115+
// These mirror the api's config for the same operations — the worker
116+
// needs direct access to customer infrastructure to revoke connections
117+
// (ACL SETUSER off / REVOKE CONNECT / revokeRolesFromUser) when a
118+
// resource exceeds its storage quota and to re-grant on auto-unsuspend.
119+
//
120+
// All three are optional — when absent the worker skips the infra-revoke
121+
// step (fail-open) and only flips the status row. The row flip still
122+
// gates the user's provisioning API calls; the infra revoke terminates
123+
// live TCP connections.
124+
//
125+
// CUSTOMER_DATABASE_URL — admin DSN for shared Postgres cluster.
126+
// MONGO_ADMIN_URI — admin URI for shared MongoDB cluster.
127+
// CUSTOMER_REDIS_URL — admin Redis URL for shared Redis cluster.
128+
CustomerDatabaseURL string // CUSTOMER_DATABASE_URL
129+
MongoAdminURI string // MONGO_ADMIN_URI
130+
CustomerRedisURL string // CUSTOMER_REDIS_URL
113131
}
114132

115133
// ErrMissingConfig is returned when a required env var is absent.
@@ -188,6 +206,13 @@ func Load() *Config {
188206
// with a YYYY-MM-DD path segment yields well-formed S3 keys without
189207
// the producer needing to know whether the parent ends in a slash.
190208
PlatformBackupS3Prefix: getenv("PLATFORM_BACKUP_S3_PREFIX", "platform-backups/"),
209+
210+
// Storage-quota suspend/unsuspend infra credentials (P0-3/P0-4 fix).
211+
// All three are optional — when absent the infra-revoke step is
212+
// skipped (fail-open) and only the status row is updated.
213+
CustomerDatabaseURL: os.Getenv("CUSTOMER_DATABASE_URL"),
214+
MongoAdminURI: os.Getenv("MONGO_ADMIN_URI"),
215+
CustomerRedisURL: os.Getenv("CUSTOMER_REDIS_URL"),
191216
}
192217

193218
// Fall back to the shared object-store bucket when the operator hasn't
@@ -222,14 +247,17 @@ func Load() *Config {
222247
"ses_from_set", cfg.SESFromEmail != "",
223248
"ses_template_count", len(cfg.SESTemplateNames),
224249
"aes_key_set", cfg.AESKey != "",
250+
"customer_db_set", cfg.CustomerDatabaseURL != "",
251+
"mongo_admin_set", cfg.MongoAdminURI != "",
252+
"customer_redis_set", cfg.CustomerRedisURL != "",
225253
)
226254
return cfg
227255
}
228256

229257
// parseBrevoTemplateIDs decodes the BREVO_TEMPLATE_IDS env var. The expected
230258
// shape is a JSON object mapping audit_log.kind → numeric Brevo template id:
231259
//
232-
// BREVO_TEMPLATE_IDS='{"subscription.upgraded": 12, "near_quota_wall": 7}'
260+
// BREVO_TEMPLATE_IDS='{"subscription.upgraded": 12, "near_quota_wall": 7}'
233261
//
234262
// Empty string returns an empty map (Brevo will then SkipNoTemplate on every
235263
// send — operator opted into "API key set, no templates yet"). A malformed
@@ -255,7 +283,7 @@ func parseBrevoTemplateIDs(raw string) map[string]int {
255283
// shape is a JSON object mapping audit_log.kind → SES template name (string,
256284
// not numeric — SES references templates by name unlike Brevo):
257285
//
258-
// SES_TEMPLATE_NAMES='{"subscription.upgraded": "tier-upgraded-v1", "near_quota_wall": "quota-wall-nudge-v1"}'
286+
// SES_TEMPLATE_NAMES='{"subscription.upgraded": "tier-upgraded-v1", "near_quota_wall": "quota-wall-nudge-v1"}'
259287
//
260288
// Empty string returns an empty map (SES will then SkipNoTemplate on every
261289
// send — operator opted into "AWS creds set, no templates yet"). A malformed

internal/jobs/quota.go

Lines changed: 186 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -35,33 +35,87 @@ type StorageQuotaChecker interface {
3535
CheckStorageQuota(ctx context.Context, db *sql.DB, resourceID uuid.UUID, limitMB int) (int64, bool, error)
3636
}
3737

38+
// resourceStatusSuspended / resourceStatusActive are named constants for the
39+
// two status values touched by the suspend/unsuspend lifecycle. Using constants
40+
// avoids scattered string literals and makes the "suspended → active" transition
41+
// greppable. (CLAUDE.md: "Use named constants, not inline strings".)
42+
const (
43+
resourceStatusSuspended = "suspended"
44+
resourceStatusActive = "active"
45+
)
46+
3847
// EnforceStorageQuotaWorker checks all active resources against their plan's
39-
// storage limit and suspends those that have exceeded it.
48+
// storage limit and suspends those that have exceeded it. It also scans
49+
// currently-suspended resources and unsuspends those whose usage has dropped
50+
// back below the limit.
51+
//
52+
// Two loops per Work() run:
53+
// 1. Suspend loop — scans status='active' postgres/redis/mongodb resources,
54+
// calls the provisioner-side infra revoke (REVOKE CONNECT / ACL SETUSER off
55+
// / revokeRolesFromUser), then flips status to 'suspended'.
56+
// 2. Unsuspend loop — scans status='suspended' resources, re-checks storage,
57+
// re-grants infra access, and flips status back to 'active' for those now
58+
// under limit.
59+
//
60+
// Both loops fail-open on infra errors (connectivity issues with the customer
61+
// DB / Redis / Mongo only affect the row flip as a fallback — the status flip
62+
// still lands). Fail-open matches CLAUDE.md convention #1.
4063
type EnforceStorageQuotaWorker struct {
4164
river.WorkerDefaults[EnforceStorageQuotaArgs]
42-
db *sql.DB
43-
plans PlanRegistry
65+
db *sql.DB
66+
plans PlanRegistry
67+
revoker ResourceInfraRevoker // nil = no infra revoke (status-row only)
4468
}
4569

4670
// NewEnforceStorageQuotaWorker constructs an EnforceStorageQuotaWorker.
47-
func NewEnforceStorageQuotaWorker(db *sql.DB, plans PlanRegistry) *EnforceStorageQuotaWorker {
48-
return &EnforceStorageQuotaWorker{db: db, plans: plans}
71+
// Pass nil for revoker to skip infra-level revoke/grant (status flip still
72+
// occurs). In production, pass NewDirectResourceRevoker(...) built from cfg.
73+
func NewEnforceStorageQuotaWorker(db *sql.DB, plans PlanRegistry, revoker ResourceInfraRevoker) *EnforceStorageQuotaWorker {
74+
return &EnforceStorageQuotaWorker{db: db, plans: plans, revoker: revoker}
4975
}
5076

51-
// Work scans all active postgres/redis/mongodb resources and suspends those over their quota.
77+
// Work scans all active postgres/redis/mongodb resources and suspends those
78+
// over their quota, then unsuspends any suspended resources that are now back
79+
// under their quota.
5280
func (w *EnforceStorageQuotaWorker) Work(ctx context.Context, job *river.Job[EnforceStorageQuotaArgs]) error {
5381
ctx, span := otel.Tracer("instant.dev/worker").Start(ctx, "job.enforce_storage_quota")
5482
defer span.End()
5583

84+
suspended, err := w.runSuspendLoop(ctx)
85+
if err != nil {
86+
return err
87+
}
88+
89+
unsuspended, err := w.runUnsuspendLoop(ctx)
90+
if err != nil {
91+
// Don't fail the job for unsuspend errors — suspends already landed.
92+
slog.Error("jobs.enforce_storage_quota.unsuspend_loop_error", "error", err)
93+
}
94+
95+
var jobID int64
96+
if job.JobRow != nil {
97+
jobID = job.ID
98+
}
99+
slog.Info("jobs.enforce_storage_quota.completed",
100+
"suspended_count", suspended,
101+
"unsuspended_count", unsuspended,
102+
"job_id", jobID,
103+
)
104+
return nil
105+
}
106+
107+
// runSuspendLoop scans status='active' resources, suspends over-quota ones,
108+
// and returns the count of newly suspended resources.
109+
func (w *EnforceStorageQuotaWorker) runSuspendLoop(ctx context.Context) (int, error) {
56110
rows, err := w.db.QueryContext(ctx, `
57111
SELECT id, token, resource_type, tier, storage_bytes
58112
FROM resources
59-
WHERE status = 'active'
113+
WHERE status = $1
60114
AND resource_type IN ('postgres', 'redis', 'mongodb')
61115
ORDER BY created_at
62-
`)
116+
`, resourceStatusActive)
63117
if err != nil {
64-
return fmt.Errorf("EnforceStorageQuotaWorker: query failed: %w", err)
118+
return 0, fmt.Errorf("EnforceStorageQuotaWorker.suspendLoop: query failed: %w", err)
65119
}
66120
defer rows.Close()
67121

@@ -83,7 +137,7 @@ func (w *EnforceStorageQuotaWorker) Work(ctx context.Context, job *river.Job[Enf
83137

84138
limitMB := w.plans.StorageLimitMB(tier, resourceType)
85139
if limitMB == -1 {
86-
continue // unlimited tier — skip
140+
continue // unlimited tier — never suspend
87141
}
88142

89143
uid, parseErr := uuid.Parse(id)
@@ -105,10 +159,29 @@ func (w *EnforceStorageQuotaWorker) Work(ctx context.Context, job *river.Job[Enf
105159
continue
106160
}
107161

162+
// Infra revoke FIRST, then status flip — matches the iron-rule order
163+
// from api/internal/handlers/resource.go Pause(): "provider-side FIRST
164+
// so the row stays active if infra fails; row flip is the commit."
165+
// Here we invert slightly: infra revoke is fail-open (logged warning,
166+
// not a hard error) so we always proceed to the status flip. This is
167+
// intentional: a row marked 'suspended' blocks new provisions from the
168+
// API even when the infra revoke is not available (customer DB down).
169+
if w.revoker != nil {
170+
if revokeErr := w.revoker.RevokeAccess(ctx, resourceType, token, ""); revokeErr != nil {
171+
// revoker implementations are fail-open (return nil on infra
172+
// error, log a WARN). A non-nil error here is unexpected —
173+
// log it but don't abort the row update.
174+
slog.Error("jobs.enforce_storage_quota.revoke_error",
175+
"resource_id", id, "token", token, "resource_type", resourceType,
176+
"error", revokeErr,
177+
)
178+
}
179+
}
180+
108181
_, updateErr := w.db.ExecContext(ctx, `
109-
UPDATE resources SET status = 'suspended'
110-
WHERE id = $1 AND status = 'active'
111-
`, id)
182+
UPDATE resources SET status = $1
183+
WHERE id = $2 AND status = $3
184+
`, resourceStatusSuspended, id, resourceStatusActive)
112185
if updateErr != nil {
113186
slog.Error("jobs.enforce_storage_quota.suspend_failed",
114187
"resource_id", id,
@@ -128,19 +201,111 @@ func (w *EnforceStorageQuotaWorker) Work(ctx context.Context, job *river.Job[Enf
128201
suspended++
129202
}
130203
if err := rows.Err(); err != nil {
131-
return fmt.Errorf("EnforceStorageQuotaWorker: rows error: %w", err)
204+
return suspended, fmt.Errorf("EnforceStorageQuotaWorker.suspendLoop: rows error: %w", err)
132205
}
133206

134-
var jobID int64
135-
if job.JobRow != nil {
136-
jobID = job.ID
137-
}
138-
slog.Info("jobs.enforce_storage_quota.completed",
207+
slog.Info("jobs.enforce_storage_quota.suspend_loop_done",
139208
"checked_count", checked,
140209
"suspended_count", suspended,
141-
"job_id", jobID,
142210
)
143-
return nil
211+
return suspended, nil
212+
}
213+
214+
// runUnsuspendLoop scans status='suspended' resources, re-checks quota, and
215+
// re-grants infra access + flips to 'active' for those now under their limit.
216+
// Returns the count of newly unsuspended resources.
217+
func (w *EnforceStorageQuotaWorker) runUnsuspendLoop(ctx context.Context) (int, error) {
218+
rows, err := w.db.QueryContext(ctx, `
219+
SELECT id, token, resource_type, tier, storage_bytes
220+
FROM resources
221+
WHERE status = $1
222+
AND resource_type IN ('postgres', 'redis', 'mongodb')
223+
ORDER BY created_at
224+
`, resourceStatusSuspended)
225+
if err != nil {
226+
return 0, fmt.Errorf("EnforceStorageQuotaWorker.unsuspendLoop: query failed: %w", err)
227+
}
228+
defer rows.Close()
229+
230+
unsuspended := 0
231+
232+
for rows.Next() {
233+
var (
234+
id string
235+
token string
236+
resourceType string
237+
tier string
238+
storageBytes int64
239+
)
240+
if scanErr := rows.Scan(&id, &token, &resourceType, &tier, &storageBytes); scanErr != nil {
241+
slog.Error("jobs.enforce_storage_quota.unsuspend_scan_error", "error", scanErr)
242+
continue
243+
}
244+
245+
limitMB := w.plans.StorageLimitMB(tier, resourceType)
246+
if limitMB == -1 {
247+
// Unlimited tier shouldn't have been suspended, but unsuspend
248+
// eagerly to self-heal any historical bad state.
249+
limitMB = 0 // treat as exceeded=false below
250+
}
251+
252+
uid, parseErr := uuid.Parse(id)
253+
if parseErr != nil {
254+
slog.Error("jobs.enforce_storage_quota.unsuspend_invalid_uuid", "id", id, "error", parseErr)
255+
continue
256+
}
257+
258+
var exceeded bool
259+
if limitMB > 0 {
260+
_, exceeded, err = checkStorageQuota(ctx, w.db, uid, limitMB)
261+
if err != nil {
262+
slog.Error("jobs.enforce_storage_quota.unsuspend_check_error",
263+
"resource_id", id, "error", err)
264+
continue // fail open — don't unsuspend on check error
265+
}
266+
}
267+
// limitMB == 0 means unlimited; exceeded stays false → will unsuspend.
268+
269+
if exceeded {
270+
continue // still over quota — remain suspended
271+
}
272+
273+
// Re-grant infra access before flipping the status row.
274+
if w.revoker != nil {
275+
if grantErr := w.revoker.GrantAccess(ctx, resourceType, token, ""); grantErr != nil {
276+
slog.Error("jobs.enforce_storage_quota.grant_error",
277+
"resource_id", id, "token", token, "resource_type", resourceType,
278+
"error", grantErr,
279+
)
280+
// Non-nil means unexpected path — still proceed with row flip.
281+
}
282+
}
283+
284+
_, updateErr := w.db.ExecContext(ctx, `
285+
UPDATE resources SET status = $1
286+
WHERE id = $2 AND status = $3
287+
`, resourceStatusActive, id, resourceStatusSuspended)
288+
if updateErr != nil {
289+
slog.Error("jobs.enforce_storage_quota.unsuspend_failed",
290+
"resource_id", id, "error", updateErr)
291+
continue
292+
}
293+
294+
slog.Info("jobs.enforce_storage_quota.unsuspended",
295+
"resource_id", id,
296+
"token", token,
297+
"resource_type", resourceType,
298+
"tier", tier,
299+
"storage_bytes", storageBytes,
300+
"limit_mb", limitMB,
301+
)
302+
unsuspended++
303+
}
304+
if err := rows.Err(); err != nil {
305+
return unsuspended, fmt.Errorf("EnforceStorageQuotaWorker.unsuspendLoop: rows error: %w", err)
306+
}
307+
308+
return unsuspended, nil
144309
}
145310

146311
// checkStorageQuota reads resources.storage_bytes and compares to limitMB.

0 commit comments

Comments
 (0)