@@ -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.
4063type 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.
5280func (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