CreateRequest previously read the user's request count outside the insert transaction, so two concurrent submissions at MaxRequests-1 could both pass the quota gate and end up at MaxRequests+1. Move the count inside the same transaction as the insert and acquire a per-user advisory lock so concurrent inserts serialize. The store reports ErrQuotaExceeded when the racing path catches the user at the limit and the service maps it back to QuotaError. normalizeListFilter previously reset limit to 50 when callers asked for more than 100, which is surprising. Clamp to the cap instead so a request for 150 returns 100 and a request for 1_000_000 still cannot hit the database with an unbounded scan. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
47 lines
1.9 KiB
Go
47 lines
1.9 KiB
Go
package requests
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
)
|
|
|
|
type Store interface {
|
|
GetSettings(ctx context.Context) (Settings, error)
|
|
UpdateSettings(ctx context.Context, settings Settings) (Settings, error)
|
|
GetUserLimit(ctx context.Context, userID int) (*UserLimit, error)
|
|
UpsertUserLimit(ctx context.Context, limit UserLimit) (*UserLimit, error)
|
|
CountUserRequestsSince(ctx context.Context, userID int, since time.Time) (int, error)
|
|
ListActiveByTMDB(ctx context.Context, mediaType MediaType, tmdbIDs []int) (map[int]*Request, error)
|
|
CreateRequest(ctx context.Context, input CreateRequestRecord) (*Request, error)
|
|
GetRequest(ctx context.Context, id string) (*Request, error)
|
|
ListReconciliationCandidates(ctx context.Context, limit int) ([]*Request, error)
|
|
ListMine(ctx context.Context, userID int, filter ListFilter) ([]*Request, error)
|
|
ListAdmin(ctx context.Context, filter ListFilter) ([]*Request, error)
|
|
SetStatus(ctx context.Context, id string, status Status, actor Viewer) (*Request, error)
|
|
MarkQueued(ctx context.Context, id string, update QueueUpdate, actor Viewer) (*Request, error)
|
|
SetOutcome(ctx context.Context, id string, outcome Outcome, actor Viewer, message string) (*Request, error)
|
|
ListIntegrations(ctx context.Context) ([]Integration, error)
|
|
UpsertIntegration(ctx context.Context, integration Integration) (*Integration, error)
|
|
UpsertIntegrations(ctx context.Context, integrations []Integration) ([]Integration, error)
|
|
}
|
|
|
|
type CreateRequestRecord struct {
|
|
ID string
|
|
Input CreateRequestInput
|
|
Status Status
|
|
Outcome Outcome
|
|
Requester Viewer
|
|
Now time.Time
|
|
// Quota, when non-nil, instructs the store to atomically verify the
|
|
// requester is below their per-user limit before inserting. The check
|
|
// runs inside the same transaction as the insert with a per-user
|
|
// advisory lock so concurrent submissions cannot both exceed the limit.
|
|
Quota *QuotaCheck
|
|
}
|
|
|
|
type QuotaCheck struct {
|
|
UserID int
|
|
WindowStart time.Time
|
|
MaxRequests int
|
|
}
|