fix(office): keep budget alerts honest when a policy changes mid-check (#3532)
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -8,6 +8,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/office/costs"
|
||||
"github.com/kandev/kandev/internal/office/models"
|
||||
@@ -54,13 +58,14 @@ func newIdempotencyTestPolicy(scopeID string, limit int64) *models.BudgetPolicy
|
||||
}
|
||||
}
|
||||
|
||||
// monthlyPeriodKey mirrors the RFC3339-UTC month-start rendering the
|
||||
// monthlyPeriodKeyAt mirrors the RFC3339-UTC month-start rendering the
|
||||
// service computes internally, for tests that assert on stored claim rows.
|
||||
func monthlyPeriodKey() string {
|
||||
now := time.Now().UTC()
|
||||
func monthlyPeriodKeyAt(now time.Time) string {
|
||||
return time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func monthlyPeriodKey() string { return monthlyPeriodKeyAt(time.Now().UTC()) }
|
||||
|
||||
func TestCheckBudget_EvaluateTwice_AlertEmitsOnce(t *testing.T) {
|
||||
repo, _, execSQL := newBudgetTestRepo(t)
|
||||
ctx := context.Background()
|
||||
@@ -144,6 +149,10 @@ func TestCheckBudget_ExceededClaimsCompanionAlert(t *testing.T) {
|
||||
// own by a rollup query, only inferred via the companion claim.
|
||||
insertBudgetTestCostEvent(t, execSQL, "agent-companion", "task-1", 600)
|
||||
|
||||
// Captured before CheckBudget so this assertion can't compute a
|
||||
// different month boundary than the evaluation did if the two calls
|
||||
// straddle a UTC month rollover.
|
||||
periodKey := monthlyPeriodKey()
|
||||
results, err := svc.CheckBudget(ctx, "ws-1", "agent-companion", "proj-1")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckBudget: %v", err)
|
||||
@@ -158,7 +167,7 @@ func TestCheckBudget_ExceededClaimsCompanionAlert(t *testing.T) {
|
||||
var count int
|
||||
if err := queryRow(
|
||||
`SELECT COUNT(*) FROM office_budget_claims WHERE policy_id = ? AND period_key = ? AND level = 'alert'`,
|
||||
policy.ID, monthlyPeriodKey(),
|
||||
policy.ID, periodKey,
|
||||
).Scan(&count); err != nil {
|
||||
t.Fatalf("count companion claim: %v", err)
|
||||
}
|
||||
@@ -167,13 +176,58 @@ func TestCheckBudget_ExceededClaimsCompanionAlert(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckBudget_ExceededClaimHeld_AlertBandEvaluationSuppressed covers
|
||||
// AC-OFFICE-COSTS-003.12 directly: while an exceeded-level claim is held
|
||||
// for a policy, period and revision, an evaluation whose spend reaches only
|
||||
// the alert level must not emit budget.alert for that period and revision.
|
||||
// The exceeded claim's companion already holds the alert-level slot, so
|
||||
// this follows from the ordinary primary-key conflict on a second
|
||||
// alert-level insert, with no separate read of the exceeded claim.
|
||||
func TestCheckBudget_ExceededClaimHeld_AlertBandEvaluationSuppressed(t *testing.T) {
|
||||
repo, _, execSQL := newBudgetTestRepo(t)
|
||||
ctx := context.Background()
|
||||
createBudgetTestAgent(t, repo, "ws-1", "agent-exceeded-then-alert")
|
||||
agents := &repoAgents{repo: repo}
|
||||
spy := &budgetActivitySpy{}
|
||||
svc := costs.NewCostService(repo, logger.Default(), spy, agents, agents)
|
||||
|
||||
policy := newIdempotencyTestPolicy("agent-exceeded-then-alert", 1000)
|
||||
if err := svc.CreateBudgetPolicy(ctx, policy); err != nil {
|
||||
t.Fatalf("create policy: %v", err)
|
||||
}
|
||||
|
||||
periodKey := monthlyPeriodKey()
|
||||
if claimed, err := repo.ClaimExceeded(ctx, policy.ID, periodKey, policy.Revision); err != nil || !claimed {
|
||||
t.Fatalf("seed exceeded+companion claim: claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
|
||||
// Spend in the alert band only: >= 80% of the 1000 limit, below it.
|
||||
insertBudgetTestCostEvent(t, execSQL, "agent-exceeded-then-alert", "task-1", 900)
|
||||
|
||||
results, err := svc.CheckBudget(ctx, "ws-1", "agent-exceeded-then-alert", "proj-1")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckBudget: %v", err)
|
||||
}
|
||||
if !results[0].AlertFired {
|
||||
t.Fatal("expected spend to reach the alert level")
|
||||
}
|
||||
if results[0].AlertSubmitted {
|
||||
t.Fatal("budget.alert must not be submitted while an exceeded-level claim is held for this policy, period and revision")
|
||||
}
|
||||
if got := spy.count("budget.alert"); got != 0 {
|
||||
t.Fatalf("budget.alert emissions = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckBudget_CompanionAlertClaimFault_StillSubmitsExceeded covers the
|
||||
// AC-OFFICE-COSTS-002.5 fault-injection carve-out: when the companion
|
||||
// alert-level claim errors, the evaluation still submits budget.exceeded
|
||||
// (already earned by the exceeded-level claim) and the failure is logged
|
||||
// and counted like any other claim-store error. The test deliberately does
|
||||
// NOT assert the companion claim was recorded, per the spec's own
|
||||
// instruction — that is the failure this test injects.
|
||||
// AC-OFFICE-COSTS-003.10 fault-injection case: when the companion
|
||||
// alert-level insert errors, the whole atomic pair rolls back — no exceeded
|
||||
// claim row survives either — but the evaluation still submits
|
||||
// budget.exceeded (already earned by the exceeded-level insert winning
|
||||
// before the rollback) and the pair failure is logged and counted exactly
|
||||
// once, not once per row. The test deliberately does NOT assert the
|
||||
// companion claim was recorded, per the spec's own instruction — that is
|
||||
// the failure this test injects.
|
||||
func TestCheckBudget_CompanionAlertClaimFault_StillSubmitsExceeded(t *testing.T) {
|
||||
repo, _, execSQL := newBudgetTestRepo(t)
|
||||
ctx := context.Background()
|
||||
@@ -191,6 +245,7 @@ func TestCheckBudget_CompanionAlertClaimFault_StillSubmitsExceeded(t *testing.T)
|
||||
}
|
||||
insertBudgetTestCostEvent(t, execSQL, "agent-companion-fault", "task-1", 600)
|
||||
|
||||
before := claimFailureCount(t)
|
||||
results, err := svc.CheckBudget(ctx, "ws-1", "agent-companion-fault", "proj-1")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckBudget: %v", err)
|
||||
@@ -201,6 +256,9 @@ func TestCheckBudget_CompanionAlertClaimFault_StillSubmitsExceeded(t *testing.T)
|
||||
if got := spy.count("budget.exceeded"); got != 1 {
|
||||
t.Fatalf("budget.exceeded submissions = %d, want 1", got)
|
||||
}
|
||||
if delta := claimFailureCount(t) - before; delta != 1 {
|
||||
t.Fatalf("budget_claim_failures_total delta = %d, want 1 (the pair is one claim attempt)", delta)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckBudget_ClaimStoreFault_EmitsAndReportsSubmittedTrue covers
|
||||
@@ -246,12 +304,14 @@ func TestCheckBudget_ClaimStoreFault_EmitsAndReportsSubmittedTrue(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckBudget_ClaimStoreFault_ExceededPath_EmitsAndCountsTwicePerEval
|
||||
// TestCheckBudget_ClaimStoreFault_ExceededPath_EmitsAndCountsOncePerEval
|
||||
// extends TestCheckBudget_ClaimStoreFault_EmitsAndReportsSubmittedTrue to the
|
||||
// exceeded level: claimAndEmit's afterClaim hook claims the alert-level
|
||||
// companion right after the exceeded-level claim, so a broken store fails
|
||||
// both claim attempts once per evaluation (AC-OFFICE-COSTS-002.14).
|
||||
func TestCheckBudget_ClaimStoreFault_ExceededPath_EmitsAndCountsTwicePerEval(t *testing.T) {
|
||||
// exceeded level: AC-OFFICE-COSTS-003.10 makes the exceeded claim and its
|
||||
// alert-level companion one atomic attempt, so a broken store fails the
|
||||
// whole pair exactly once per evaluation — one log line, one counter
|
||||
// increment (AC-OFFICE-COSTS-003.14 amends AC-OFFICE-COSTS-002.14 to say
|
||||
// so), not once per row as it would if the two claims were independent.
|
||||
func TestCheckBudget_ClaimStoreFault_ExceededPath_EmitsAndCountsOncePerEval(t *testing.T) {
|
||||
repo, _, execSQL := newBudgetTestRepo(t)
|
||||
ctx := context.Background()
|
||||
createBudgetTestAgent(t, repo, "ws-1", "agent-fault-exc")
|
||||
@@ -283,9 +343,71 @@ func TestCheckBudget_ClaimStoreFault_ExceededPath_EmitsAndCountsTwicePerEval(t *
|
||||
if got := spy.count("budget.exceeded"); got != 2 {
|
||||
t.Fatalf("budget.exceeded = %d, want 2 (duplicates permitted under broken store)", got)
|
||||
}
|
||||
// exceeded claim + companion alert claim both fail => 2 increments per evaluation.
|
||||
if delta := claimFailureCount(t) - before; delta != 4 {
|
||||
t.Fatalf("budget_claim_failures_total delta = %d, want 4 (2 per exceeded eval)", delta)
|
||||
if delta := claimFailureCount(t) - before; delta != 2 {
|
||||
t.Fatalf("budget_claim_failures_total delta = %d, want 2 (1 per exceeded eval, the pair is one claim attempt)", delta)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckBudget_ClaimStoreFault_LogsLevelFieldMatchingEmission covers
|
||||
// AC-OFFICE-COSTS-003.14: the claim-store failure log's level field names
|
||||
// the level whose emission the failed claim governs — "alert" for a
|
||||
// standalone alert-band claim, "exceeded" for the atomic pair — never a
|
||||
// sentinel shared by both.
|
||||
func TestCheckBudget_ClaimStoreFault_LogsLevelFieldMatchingEmission(t *testing.T) {
|
||||
core, logs := observer.New(zapcore.ErrorLevel)
|
||||
log, err := logger.NewFromZap(zap.New(core))
|
||||
if err != nil {
|
||||
t.Fatalf("create observer logger: %v", err)
|
||||
}
|
||||
injected := errors.New("injected claim store failure")
|
||||
|
||||
repo, _, execSQL := newBudgetTestRepo(t)
|
||||
ctx := context.Background()
|
||||
agents := &repoAgents{repo: repo}
|
||||
spy := &budgetActivitySpy{}
|
||||
|
||||
createBudgetTestAgent(t, repo, "ws-1", "agent-log-alert")
|
||||
alertFaulty := &claimFaultRepo{Repository: repo, failLevels: map[string]error{"alert": injected}}
|
||||
alertSvc := costs.NewCostService(alertFaulty, log, spy, agents, agents)
|
||||
alertPolicy := newIdempotencyTestPolicy("agent-log-alert", 1000)
|
||||
if err := alertSvc.CreateBudgetPolicy(ctx, alertPolicy); err != nil {
|
||||
t.Fatalf("create alert policy: %v", err)
|
||||
}
|
||||
insertBudgetTestCostEvent(t, execSQL, "agent-log-alert", "task-1", 850)
|
||||
if _, err := alertSvc.CheckBudget(ctx, "ws-1", "agent-log-alert", "proj-1"); err != nil {
|
||||
t.Fatalf("CheckBudget (alert): %v", err)
|
||||
}
|
||||
|
||||
createBudgetTestAgent(t, repo, "ws-1", "agent-log-exceeded")
|
||||
exceededFaulty := &claimFaultRepo{Repository: repo, failLevels: map[string]error{"exceeded": injected}}
|
||||
exceededSvc := costs.NewCostService(exceededFaulty, log, spy, agents, agents)
|
||||
exceededPolicy := newIdempotencyTestPolicy("agent-log-exceeded", 500)
|
||||
if err := exceededSvc.CreateBudgetPolicy(ctx, exceededPolicy); err != nil {
|
||||
t.Fatalf("create exceeded policy: %v", err)
|
||||
}
|
||||
insertBudgetTestCostEvent(t, execSQL, "agent-log-exceeded", "task-1", 600)
|
||||
if _, err := exceededSvc.CheckBudget(ctx, "ws-1", "agent-log-exceeded", "proj-1"); err != nil {
|
||||
t.Fatalf("CheckBudget (exceeded): %v", err)
|
||||
}
|
||||
|
||||
entries := logs.FilterMessage("budget claim store failure").All()
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("claim-store-failure log entries = %d, want 2", len(entries))
|
||||
}
|
||||
var sawAlert, sawExceeded bool
|
||||
for _, e := range entries {
|
||||
switch e.ContextMap()["level"] {
|
||||
case "alert":
|
||||
sawAlert = true
|
||||
case "exceeded":
|
||||
sawExceeded = true
|
||||
}
|
||||
}
|
||||
if !sawAlert {
|
||||
t.Fatal(`expected a log entry with level="alert" for the standalone alert-band claim failure`)
|
||||
}
|
||||
if !sawExceeded {
|
||||
t.Fatal(`expected a log entry with level="exceeded" for the atomic pair's claim failure`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,34 +568,6 @@ func TestCheckBudget_ConcurrentEvaluation_EmitsOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckBudgetAndEvaluateProjectBudget_ShareAlertClaim covers the two
|
||||
// callers that can evaluate a project policy. They must use the same durable
|
||||
// claim so a cost event followed by task reassignment emits one alert.
|
||||
func TestCheckBudgetAndEvaluateProjectBudget_ShareAlertClaim(t *testing.T) {
|
||||
spy := &budgetActivitySpy{}
|
||||
svc, _, execSQL := newBudgetTestServiceWithActivity(t, spy)
|
||||
ctx := context.Background()
|
||||
|
||||
policy := newIdempotencyTestPolicy("proj-shared-claim", 1000)
|
||||
policy.ScopeType = "project"
|
||||
if err := svc.CreateBudgetPolicy(ctx, policy); err != nil {
|
||||
t.Fatalf("create policy: %v", err)
|
||||
}
|
||||
insertBudgetTestTask(t, execSQL, "task-shared-claim", "ws-1", "proj-shared-claim")
|
||||
insertBudgetTestCostEvent(t, execSQL, "agent-shared-claim", "task-shared-claim", 850)
|
||||
|
||||
if _, err := svc.CheckBudget(ctx, "ws-1", "agent-shared-claim", "proj-shared-claim"); err != nil {
|
||||
t.Fatalf("CheckBudget: %v", err)
|
||||
}
|
||||
if err := svc.EvaluateProjectBudget(ctx, "ws-1", "proj-shared-claim"); err != nil {
|
||||
t.Fatalf("EvaluateProjectBudget: %v", err)
|
||||
}
|
||||
|
||||
if got := spy.count("budget.alert"); got != 1 {
|
||||
t.Fatalf("budget.alert submissions across callers = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckBudget_PeriodKeyMatchesSpendBoundary covers AC-OFFICE-COSTS-002.6a:
|
||||
// the claim recorded for a monthly policy is keyed to the same window
|
||||
// boundary the spend rollup used, rendered as RFC3339 UTC.
|
||||
@@ -490,9 +584,14 @@ func TestCheckBudget_PeriodKeyMatchesSpendBoundary(t *testing.T) {
|
||||
}
|
||||
insertBudgetTestCostEvent(t, execSQL, "agent-period", "task-1", 850)
|
||||
|
||||
// The evaluation reads the clock once. Accept the month containing either
|
||||
// endpoint because a test can straddle a UTC month rollover while the
|
||||
// evaluation runs.
|
||||
before := time.Now().UTC()
|
||||
if _, err := svc.CheckBudget(ctx, "ws-1", "agent-period", "proj-1"); err != nil {
|
||||
t.Fatalf("CheckBudget: %v", err)
|
||||
}
|
||||
after := time.Now().UTC()
|
||||
|
||||
var gotKey string
|
||||
if err := queryRow(
|
||||
@@ -500,8 +599,12 @@ func TestCheckBudget_PeriodKeyMatchesSpendBoundary(t *testing.T) {
|
||||
).Scan(&gotKey); err != nil {
|
||||
t.Fatalf("query claim period_key: %v", err)
|
||||
}
|
||||
if gotKey != monthlyPeriodKey() {
|
||||
t.Fatalf("claim period_key = %q, want %q (must match the spend rollup's boundary)", gotKey, monthlyPeriodKey())
|
||||
wantKeys := map[string]struct{}{
|
||||
monthlyPeriodKeyAt(before): {},
|
||||
monthlyPeriodKeyAt(after): {},
|
||||
}
|
||||
if _, ok := wantKeys[gotKey]; !ok {
|
||||
t.Fatalf("claim period_key = %q, want one of %v (must match the spend rollup's boundary)", gotKey, wantKeys)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -554,7 +657,7 @@ func TestCheckBudget_SpendBelowLevelKeepsExistingClaim(t *testing.T) {
|
||||
}
|
||||
|
||||
periodKey := monthlyPeriodKey()
|
||||
if claimed, err := repo.Claim(ctx, policy.ID, periodKey, "alert"); err != nil || !claimed {
|
||||
if claimed, err := repo.Claim(ctx, policy.ID, periodKey, "alert", policy.Revision); err != nil || !claimed {
|
||||
t.Fatalf("seed claim: claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
|
||||
@@ -564,7 +667,7 @@ func TestCheckBudget_SpendBelowLevelKeepsExistingClaim(t *testing.T) {
|
||||
t.Fatalf("CheckBudget: %v", err)
|
||||
}
|
||||
|
||||
claimed, err := repo.Claim(ctx, policy.ID, periodKey, "alert")
|
||||
claimed, err := repo.Claim(ctx, policy.ID, periodKey, "alert", policy.Revision)
|
||||
if err != nil {
|
||||
t.Fatalf("re-claim: %v", err)
|
||||
}
|
||||
|
||||
@@ -138,43 +138,27 @@ func (s *CostService) evaluatePolicy(
|
||||
if policy.ActionOnExceed == budgetActionPauseAgent && policy.ScopeType == scopeAgent {
|
||||
result.AgentPaused = s.pauseAgentForBudget(ctx, policy.ScopeID)
|
||||
}
|
||||
result.ExceededSubmitted = s.claimAndEmit(ctx, workspaceID, policy, spent, periodKey, claimLevelExceeded,
|
||||
// A spend that jumps straight from below the alert level to
|
||||
// above the limit must also hold the alert-level claim before
|
||||
// budget.exceeded is emitted, so a concurrent evaluation
|
||||
// landing in the alert band cannot win that claim in the gap
|
||||
// and emit a budget.alert describing a reduction in spend. The
|
||||
// outcome is discarded (claimed or already-claimed are equally
|
||||
// fine here); a claim-store error is still logged and counted
|
||||
// like any other.
|
||||
func() { s.claimCompanionAlert(ctx, policy, periodKey) })
|
||||
result.ExceededSubmitted = s.claimExceededAndEmit(ctx, workspaceID, policy, spent, periodKey)
|
||||
case spent >= threshold:
|
||||
result.AlertFired = true
|
||||
result.AlertSubmitted = s.claimAndEmit(ctx, workspaceID, policy, spent, periodKey, claimLevelAlert, nil)
|
||||
result.AlertSubmitted = s.claimAndEmit(ctx, workspaceID, policy, spent, periodKey, claimLevelAlert)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// claimAndEmit resolves the three outcomes of claiming (policy, periodKey,
|
||||
// level) and emits that level's activity row when the claim allows it.
|
||||
// Reports whether this evaluation submitted the row. afterClaim, when
|
||||
// non-nil, runs immediately after the claim and before the emission
|
||||
// decision, regardless of the claim's outcome — the seam the exceeded
|
||||
// level's alert-level companion claim uses so both claims land before
|
||||
// either emission is decided.
|
||||
// level) at the policy's current revision, and emits that level's activity
|
||||
// row when the claim allows it. Reports whether this evaluation submitted
|
||||
// the row.
|
||||
func (s *CostService) claimAndEmit(
|
||||
ctx context.Context,
|
||||
workspaceID string,
|
||||
policy *BudgetPolicy,
|
||||
spent int64,
|
||||
periodKey, level string,
|
||||
afterClaim func(),
|
||||
) bool {
|
||||
claimed, err := s.repo.Claim(ctx, policy.ID, periodKey, level)
|
||||
if afterClaim != nil {
|
||||
afterClaim()
|
||||
}
|
||||
claimed, err := s.repo.Claim(ctx, policy.ID, periodKey, level, policy.Revision)
|
||||
if err != nil {
|
||||
s.recordClaimFailure(policy.ID, level, err)
|
||||
s.emitLevel(ctx, workspaceID, policy, spent, level)
|
||||
@@ -187,15 +171,29 @@ func (s *CostService) claimAndEmit(
|
||||
return true
|
||||
}
|
||||
|
||||
// claimCompanionAlert records the alert-level claim alongside an
|
||||
// exceeded-level emission, so a spend that jumps straight past the limit
|
||||
// still de-escalates correctly. Its outcome never changes the
|
||||
// budget.exceeded emission; only a claim-store error is reported, exactly
|
||||
// like any other claim attempt.
|
||||
func (s *CostService) claimCompanionAlert(ctx context.Context, policy *BudgetPolicy, periodKey string) {
|
||||
if _, err := s.repo.Claim(ctx, policy.ID, periodKey, claimLevelAlert); err != nil {
|
||||
s.recordClaimFailure(policy.ID, claimLevelAlert, err)
|
||||
// claimExceededAndEmit resolves the atomic exceeded-plus-companion claim
|
||||
// pair at the policy's current revision and emits budget.exceeded when the
|
||||
// exceeded-level row was won. A claim-store error anywhere in the pair —
|
||||
// including on the companion insert — fails the whole pair open: the error
|
||||
// is logged and counted once, and budget.exceeded still emits.
|
||||
func (s *CostService) claimExceededAndEmit(
|
||||
ctx context.Context,
|
||||
workspaceID string,
|
||||
policy *BudgetPolicy,
|
||||
spent int64,
|
||||
periodKey string,
|
||||
) bool {
|
||||
claimed, err := s.repo.ClaimExceeded(ctx, policy.ID, periodKey, policy.Revision)
|
||||
if err != nil {
|
||||
s.recordClaimFailure(policy.ID, claimLevelExceeded, err)
|
||||
s.emitLevel(ctx, workspaceID, policy, spent, claimLevelExceeded)
|
||||
return true
|
||||
}
|
||||
if !claimed {
|
||||
return false
|
||||
}
|
||||
s.emitLevel(ctx, workspaceID, policy, spent, claimLevelExceeded)
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *CostService) emitLevel(
|
||||
|
||||
@@ -74,14 +74,34 @@ type claimFaultRepo struct {
|
||||
missLevels map[string]bool
|
||||
}
|
||||
|
||||
func (r *claimFaultRepo) Claim(ctx context.Context, policyID, periodKey, level string) (bool, error) {
|
||||
func (r *claimFaultRepo) Claim(ctx context.Context, policyID, periodKey, level string, revision int64) (bool, error) {
|
||||
if err, ok := r.failLevels[level]; ok {
|
||||
return false, err
|
||||
}
|
||||
if r.missLevels[level] {
|
||||
return false, nil
|
||||
}
|
||||
return r.Repository.Claim(ctx, policyID, periodKey, level)
|
||||
return r.Repository.Claim(ctx, policyID, periodKey, level, revision)
|
||||
}
|
||||
|
||||
// ClaimExceeded overrides the atomic pair the same way Claim does, keyed by
|
||||
// the level whose outcome is being faulted: "exceeded" fails or misses the
|
||||
// pair before either insert is attempted (mirroring a fenced exceeded
|
||||
// insert refused or store-faulted); "alert" fails or misses only the
|
||||
// companion half, after a real exceeded insert has already run for real,
|
||||
// mirroring AC-OFFICE-COSTS-003.10's atomic rollback of the whole pair on a
|
||||
// companion-side error.
|
||||
func (r *claimFaultRepo) ClaimExceeded(ctx context.Context, policyID, periodKey string, revision int64) (bool, error) {
|
||||
if err, ok := r.failLevels["exceeded"]; ok {
|
||||
return false, err
|
||||
}
|
||||
if r.missLevels["exceeded"] {
|
||||
return false, nil
|
||||
}
|
||||
if err, ok := r.failLevels["alert"]; ok {
|
||||
return false, err
|
||||
}
|
||||
return r.Repository.ClaimExceeded(ctx, policyID, periodKey, revision)
|
||||
}
|
||||
|
||||
// callOrderRecorder records events in the order they occur, for tests that
|
||||
@@ -113,12 +133,24 @@ type orderRecordingRepo struct {
|
||||
order *callOrderRecorder
|
||||
}
|
||||
|
||||
func (r *orderRecordingRepo) Claim(ctx context.Context, policyID, periodKey, level string) (bool, error) {
|
||||
claimed, err := r.Repository.Claim(ctx, policyID, periodKey, level)
|
||||
func (r *orderRecordingRepo) Claim(ctx context.Context, policyID, periodKey, level string, revision int64) (bool, error) {
|
||||
claimed, err := r.Repository.Claim(ctx, policyID, periodKey, level, revision)
|
||||
r.order.record("claim:" + level)
|
||||
return claimed, err
|
||||
}
|
||||
|
||||
// ClaimExceeded records both of the atomic pair's levels immediately after
|
||||
// the (single) real call returns, preserving the ordering property the test
|
||||
// cares about: both claim events precede the emission decision. It cannot
|
||||
// observe the two inserts individually from outside the transaction, but it
|
||||
// doesn't need to — only their position relative to the emission matters.
|
||||
func (r *orderRecordingRepo) ClaimExceeded(ctx context.Context, policyID, periodKey string, revision int64) (bool, error) {
|
||||
claimed, err := r.Repository.ClaimExceeded(ctx, policyID, periodKey, revision)
|
||||
r.order.record("claim:exceeded")
|
||||
r.order.record("claim:alert")
|
||||
return claimed, err
|
||||
}
|
||||
|
||||
// orderRecordingActivity implements shared.ActivityLogger and records each
|
||||
// submitted action into the same callOrderRecorder as orderRecordingRepo,
|
||||
// so a test can assert the interleaving of claims and emissions.
|
||||
@@ -260,7 +292,7 @@ func insertBudgetTestCostEvent(t *testing.T, execSQL func(string, ...interface{}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
execSQL(
|
||||
`INSERT INTO office_cost_events (id, agent_profile_id, task_id, cost_subcents, occurred_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
uuid.NewString(), agentID, taskID, costSubcents, now, now,
|
||||
)
|
||||
}
|
||||
@@ -283,13 +315,12 @@ func insertBudgetTestCostEventAt(
|
||||
|
||||
func TestCheckBudget_PeriodWindowsExcludeOlderSpend(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
period models.BudgetPeriod
|
||||
olderBy time.Duration
|
||||
wantLimit bool
|
||||
name string
|
||||
period models.BudgetPeriod
|
||||
older time.Duration
|
||||
}{
|
||||
{name: "daily", period: models.BudgetPeriodDaily, olderBy: 48 * time.Hour},
|
||||
{name: "yearly", period: models.BudgetPeriodYearly, olderBy: 400 * 24 * time.Hour},
|
||||
{name: "daily", period: models.BudgetPeriodDaily, older: 48 * time.Hour},
|
||||
{name: "yearly", period: models.BudgetPeriodYearly, older: 400 * 24 * time.Hour},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
@@ -311,7 +342,7 @@ func TestCheckBudget_PeriodWindowsExcludeOlderSpend(t *testing.T) {
|
||||
t.Fatalf("create policy: %v", err)
|
||||
}
|
||||
|
||||
insertBudgetTestCostEventAt(t, execSQL, "agent-1", "task-1", 600, time.Now().UTC().Add(-tc.olderBy))
|
||||
insertBudgetTestCostEventAt(t, execSQL, "agent-1", "task-1", 600, time.Now().UTC().Add(-tc.older))
|
||||
results, err := svc.CheckBudget(ctx, "ws-1", "agent-1", "project-1")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckBudget: %v", err)
|
||||
@@ -319,8 +350,8 @@ func TestCheckBudget_PeriodWindowsExcludeOlderSpend(t *testing.T) {
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("results = %d, want 1", len(results))
|
||||
}
|
||||
if results[0].LimitExceed != tc.wantLimit {
|
||||
t.Errorf("LimitExceed = %t, want %t", results[0].LimitExceed, tc.wantLimit)
|
||||
if results[0].LimitExceed {
|
||||
t.Fatalf("%s spend outside the window must not exceed the limit", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -593,6 +624,34 @@ func TestEvaluateProjectBudget_AlertAtThreshold(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckBudgetAndEvaluateProjectBudget_ShareAlertClaim covers the two
|
||||
// callers that can evaluate a project policy. They must use the same durable
|
||||
// claim so a cost event followed by task reassignment emits one alert.
|
||||
func TestCheckBudgetAndEvaluateProjectBudget_ShareAlertClaim(t *testing.T) {
|
||||
spy := &budgetActivitySpy{}
|
||||
svc, _, execSQL := newBudgetTestServiceWithActivity(t, spy)
|
||||
ctx := context.Background()
|
||||
|
||||
policy := newIdempotencyTestPolicy("proj-shared-claim", 1000)
|
||||
policy.ScopeType = models.BudgetScopeProject
|
||||
if err := svc.CreateBudgetPolicy(ctx, policy); err != nil {
|
||||
t.Fatalf("create policy: %v", err)
|
||||
}
|
||||
insertBudgetTestTask(t, execSQL, "task-shared-claim", "ws-1", "proj-shared-claim")
|
||||
insertBudgetTestCostEvent(t, execSQL, "agent-shared-claim", "task-shared-claim", 850)
|
||||
|
||||
if _, err := svc.CheckBudget(ctx, "ws-1", "agent-shared-claim", "proj-shared-claim"); err != nil {
|
||||
t.Fatalf("CheckBudget: %v", err)
|
||||
}
|
||||
if err := svc.EvaluateProjectBudget(ctx, "ws-1", "proj-shared-claim"); err != nil {
|
||||
t.Fatalf("EvaluateProjectBudget: %v", err)
|
||||
}
|
||||
|
||||
if got := spy.count("budget.alert"); got != 1 {
|
||||
t.Fatalf("budget.alert submissions across callers = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluateProjectBudget_ExceededAtLimit covers the limit-exceeded branch.
|
||||
func TestEvaluateProjectBudget_ExceededAtLimit(t *testing.T) {
|
||||
spy := &budgetActivitySpy{}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package costs_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/kandev/kandev/internal/office/costs"
|
||||
)
|
||||
|
||||
// newBudgetTestRouter mounts the budget routes over a fresh CostService, for
|
||||
// tests that need to observe the HTTP-level wire contract rather than the
|
||||
// service API directly.
|
||||
func newBudgetTestRouter(t *testing.T) *gin.Engine {
|
||||
t.Helper()
|
||||
svc, _, _ := newBudgetTestService(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
costs.NewHandler(svc).RegisterRoutes(router.Group("/api/v1"))
|
||||
return router
|
||||
}
|
||||
|
||||
func doBudgetRequest(t *testing.T, router *gin.Engine, method, path, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// TestCreateBudget_ResponseCarriesServerAssignedRevision covers
|
||||
// AC-OFFICE-COSTS-003.2 and .4 on the wire: the 201 response's budget
|
||||
// carries "revision": 1, never the column default leaking through as 0,
|
||||
// and a client-presented revision in the create body has no effect (the
|
||||
// request DTO has no such field, so this is enforced structurally, but the
|
||||
// response must still report what the row actually holds).
|
||||
func TestCreateBudget_ResponseCarriesServerAssignedRevision(t *testing.T) {
|
||||
router := newBudgetTestRouter(t)
|
||||
|
||||
rec := doBudgetRequest(t, router, http.MethodPost, "/api/v1/workspaces/ws-1/budgets", `{
|
||||
"scope_type": "workspace",
|
||||
"limit_subcents": 1000,
|
||||
"period": "monthly",
|
||||
"alert_threshold_pct": 80,
|
||||
"action_on_exceed": "notify_only",
|
||||
"revision": 999
|
||||
}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201 (body: %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Budget struct {
|
||||
ID string `json:"id"`
|
||||
Revision int64 `json:"revision"`
|
||||
} `json:"budget"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode body %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
if body.Budget.Revision != 1 {
|
||||
t.Fatalf("create response revision = %d, want 1 (a presented 999 must not survive)", body.Budget.Revision)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"revision"`) {
|
||||
t.Fatalf("response body must serialize revision as a visible field, got %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateBudget_ClientRevisionFieldHasNoEffect covers
|
||||
// AC-OFFICE-COSTS-003.7: the update request body has no revision field, so
|
||||
// a client-supplied "revision" in the PATCH JSON is inert, and the response
|
||||
// reports the server's own bump (2, after the create's 1) regardless of
|
||||
// what the caller sent.
|
||||
func TestUpdateBudget_ClientRevisionFieldHasNoEffect(t *testing.T) {
|
||||
router := newBudgetTestRouter(t)
|
||||
|
||||
createRec := doBudgetRequest(t, router, http.MethodPost, "/api/v1/workspaces/ws-1/budgets", `{
|
||||
"scope_type": "workspace",
|
||||
"limit_subcents": 1000,
|
||||
"period": "monthly",
|
||||
"alert_threshold_pct": 80,
|
||||
"action_on_exceed": "notify_only"
|
||||
}`)
|
||||
var created struct {
|
||||
Budget struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"budget"`
|
||||
}
|
||||
if err := json.Unmarshal(createRec.Body.Bytes(), &created); err != nil {
|
||||
t.Fatalf("decode create body %q: %v", createRec.Body.String(), err)
|
||||
}
|
||||
|
||||
updateRec := doBudgetRequest(t, router, http.MethodPatch, "/api/v1/budgets/"+created.Budget.ID, `{
|
||||
"limit_subcents": 2000,
|
||||
"revision": 999999
|
||||
}`)
|
||||
if updateRec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body: %s)", updateRec.Code, updateRec.Body.String())
|
||||
}
|
||||
|
||||
var updated struct {
|
||||
Budget struct {
|
||||
Revision int64 `json:"revision"`
|
||||
LimitSubcents int64 `json:"limit_subcents"`
|
||||
} `json:"budget"`
|
||||
}
|
||||
if err := json.Unmarshal(updateRec.Body.Bytes(), &updated); err != nil {
|
||||
t.Fatalf("decode update body %q: %v", updateRec.Body.String(), err)
|
||||
}
|
||||
if updated.Budget.Revision != 2 {
|
||||
t.Fatalf("updated revision = %d, want 2 (a presented 999999 must not survive)", updated.Budget.Revision)
|
||||
}
|
||||
if updated.Budget.LimitSubcents != 2000 {
|
||||
t.Fatalf("limit_subcents = %d, want 2000", updated.Budget.LimitSubcents)
|
||||
}
|
||||
}
|
||||
@@ -44,8 +44,13 @@ type Repository interface {
|
||||
GetWorkspaceBudgetDefault(ctx context.Context, workspaceID string) (limitSubcents int64, found bool, err error)
|
||||
SetWorkspaceBudgetDefault(ctx context.Context, workspaceID string, limitSubcents int64) error
|
||||
// Claim atomically records that (policyID, periodKey, level) may emit its
|
||||
// budget notification. See docs/specs/office/requirements/costs.md.
|
||||
Claim(ctx context.Context, policyID, periodKey, level string) (bool, error)
|
||||
// budget notification, fenced to revision. See
|
||||
// docs/specs/budget-claim-revision-fencing/spec.md.
|
||||
Claim(ctx context.Context, policyID, periodKey, level string, revision int64) (bool, error)
|
||||
// ClaimExceeded atomically records the exceeded-level claim and, only
|
||||
// when this call wins it, the alert-level companion claim, both fenced
|
||||
// to revision. See docs/specs/budget-claim-revision-fencing/spec.md.
|
||||
ClaimExceeded(ctx context.Context, policyID, periodKey string, revision int64) (bool, error)
|
||||
}
|
||||
|
||||
// CostService handles cost recording, summaries, and budget evaluation.
|
||||
|
||||
@@ -375,6 +375,10 @@ type BudgetPolicy struct {
|
||||
ActionOnExceed BudgetActionOnExceed `json:"action_on_exceed" db:"action_on_exceed"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
// Revision identifies which immutable set of the fields above a budget
|
||||
// claim was evaluated against (REQ-OFFICE-COSTS-003). Server-assigned:
|
||||
// starts at 1 and increases by exactly 1 on each successful update.
|
||||
Revision int64 `json:"revision" db:"revision"`
|
||||
}
|
||||
|
||||
// SpendWindow reports one scope's priced spend and degradation state for a
|
||||
|
||||
@@ -76,6 +76,24 @@ type Repository struct {
|
||||
// before the policy row update, so a test can prove the two are
|
||||
// transactional without a fault-injecting driver.
|
||||
failBudgetPolicyUpdateErr error
|
||||
|
||||
// failBudgetClaimsRecreateErr is a test-only failpoint: when set,
|
||||
// recreateBudgetClaimsForRevision returns it instead of touching the
|
||||
// database, so a test can prove initSchema surfaces the error rather
|
||||
// than swallowing it.
|
||||
failBudgetClaimsRecreateErr error
|
||||
|
||||
// failBudgetClaimsRecreateAfterDropErr is a test-only failpoint: when
|
||||
// set, recreateBudgetClaimsForRevision returns it after DROP TABLE has
|
||||
// run but before CREATE TABLE, inside the same transaction, so a test
|
||||
// can prove the two are atomic rather than two independent statements.
|
||||
failBudgetClaimsRecreateAfterDropErr error
|
||||
|
||||
// failBudgetExceededCompanionErr is a test-only failpoint: when set,
|
||||
// ClaimExceeded returns it after the exceeded-level insert has run but
|
||||
// before the companion alert-level insert, so a test can prove the
|
||||
// pair rolls back together.
|
||||
failBudgetExceededCompanionErr error
|
||||
}
|
||||
|
||||
// NewWithDB creates a new office repository with existing database connections.
|
||||
@@ -126,6 +144,12 @@ func (r *Repository) initSchema() error {
|
||||
if err := r.createCoreTables(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Runs after createCoreTables (which creates office_budget_claims) so
|
||||
// it probes the real table rather than racing its creation. Its error
|
||||
// is returned, not swallowed: see recreateBudgetClaimsForRevision.
|
||||
if err := r.recreateBudgetClaimsForRevision(); err != nil {
|
||||
return fmt.Errorf("recreate office_budget_claims: %w", err)
|
||||
}
|
||||
if err := r.createExtensionTables(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -338,7 +362,8 @@ func (r *Repository) createCostTables() error {
|
||||
alert_threshold_pct INTEGER DEFAULT 80,
|
||||
action_on_exceed TEXT DEFAULT 'notify_only',
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
updated_at TIMESTAMP NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
-- Built-in default spend ceiling (REQ-OFFICE-BUDGET-003): a stable
|
||||
@@ -349,26 +374,34 @@ func (r *Repository) createCostTables() error {
|
||||
limit_subcents INTEGER NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
);
|
||||
` + budgetClaimsDDL)
|
||||
return err
|
||||
}
|
||||
|
||||
-- office_budget_claims must be created after office_budget_policies:
|
||||
-- its foreign key references that table, and PostgreSQL rejects a
|
||||
-- forward reference at CREATE TABLE time even though SQLite tolerates
|
||||
-- it. One row per (policy, evaluation period, level) that has already
|
||||
-- produced its budget.alert / budget.exceeded notification; the
|
||||
-- primary key is the concurrency control for a claim attempt.
|
||||
-- ON DELETE CASCADE removes a policy's claims on every deletion path
|
||||
-- without a matching code change at any of them.
|
||||
// budgetClaimsDDL is the final office_budget_claims shape: one row per
|
||||
// (policy, evaluation period, level, policy revision) that has already
|
||||
// produced its budget.alert / budget.exceeded notification. revision is
|
||||
// part of the primary key, not just a fence input, so a claim written
|
||||
// against a revision a concurrent update has already superseded can never
|
||||
// match a later evaluation's fenced insert. Used both for fresh-database
|
||||
// creation here and for the recreate in recreateBudgetClaimsForRevision, so
|
||||
// a fresh database and a migrated one converge. office_budget_claims must
|
||||
// be created after office_budget_policies: its foreign key references that
|
||||
// table, and PostgreSQL rejects a forward reference at CREATE TABLE time
|
||||
// even though SQLite tolerates it. ON DELETE CASCADE removes a policy's
|
||||
// claims on every deletion path without a matching code change at any of
|
||||
// them.
|
||||
const budgetClaimsDDL = `
|
||||
CREATE TABLE IF NOT EXISTS office_budget_claims (
|
||||
policy_id TEXT NOT NULL,
|
||||
period_key TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
claimed_at TIMESTAMP NOT NULL,
|
||||
PRIMARY KEY (policy_id, period_key, level),
|
||||
PRIMARY KEY (policy_id, period_key, level, revision),
|
||||
FOREIGN KEY (policy_id) REFERENCES office_budget_policies(id) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
`
|
||||
|
||||
func (r *Repository) createRunTables() error {
|
||||
_, err := r.db.Exec(`
|
||||
|
||||
@@ -63,6 +63,7 @@ func (r *Repository) runMigrations() error {
|
||||
r.migrateWakeWaveColumns()
|
||||
r.migrate.Apply("task_workspace_groups.ownership_generation",
|
||||
`ALTER TABLE task_workspace_groups ADD COLUMN ownership_generation INTEGER NOT NULL DEFAULT 1`)
|
||||
r.migrateBudgetPolicyRevision()
|
||||
r.migrateWorkspacePauseSkipAttribution()
|
||||
r.migrateLoopLivenessCausationID()
|
||||
if err := r.migrate.Err(); err != nil {
|
||||
@@ -86,6 +87,17 @@ func (r *Repository) backfillRoutineTriggerTimezones() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateBudgetPolicyRevision adds office_budget_policies.revision for
|
||||
// databases created before REQ-OFFICE-COSTS-003. The DEFAULT 1 backfills
|
||||
// every existing row exactly like createCostTables' inline column, so a
|
||||
// fresh database and a migrated one converge. A boot replay is a no-op:
|
||||
// db.IsDuplicateColumnError is the only local classifier (ADR 0027), reused
|
||||
// via MigrateLogger.Apply rather than adding a new one.
|
||||
func (r *Repository) migrateBudgetPolicyRevision() {
|
||||
_ = r.migrate.Apply("office_budget_policies.revision",
|
||||
`ALTER TABLE office_budget_policies ADD COLUMN revision INTEGER NOT NULL DEFAULT 1`)
|
||||
}
|
||||
|
||||
// migrateWorkspacePauseSkipAttribution adds the columns a blocked routine
|
||||
// fire uses to record why it was skipped and which pause blocked it. The
|
||||
// partial unique index runs after both ADD COLUMNs so it never executes
|
||||
|
||||
@@ -16,7 +16,8 @@ import (
|
||||
// discard and the policy row update are one transaction
|
||||
// (AC-OFFICE-COSTS-002.8): when the row update fails after the discard has
|
||||
// already run, the whole transaction rolls back, so the claim survives
|
||||
// alongside the un-updated policy. failBudgetPolicyUpdateErr is a
|
||||
// alongside the un-updated policy, and the policy's revision does not
|
||||
// advance either (AC-OFFICE-COSTS-003.6). failBudgetPolicyUpdateErr is a
|
||||
// test-only failpoint (see base.go) standing in for a fault-injecting
|
||||
// driver.
|
||||
func TestUpdateBudgetPolicy_FailedUpdateRollsBackDiscard(t *testing.T) {
|
||||
@@ -53,7 +54,7 @@ func TestUpdateBudgetPolicy_FailedUpdateRollsBackDiscard(t *testing.T) {
|
||||
if err := repo.CreateBudgetPolicy(ctx, policy); err != nil {
|
||||
t.Fatalf("create policy: %v", err)
|
||||
}
|
||||
if _, err := repo.Claim(ctx, policy.ID, "2026-09-01T00:00:00Z", "alert"); err != nil {
|
||||
if _, err := repo.Claim(ctx, policy.ID, "2026-09-01T00:00:00Z", "alert", policy.Revision); err != nil {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
|
||||
@@ -80,4 +81,7 @@ func TestUpdateBudgetPolicy_FailedUpdateRollsBackDiscard(t *testing.T) {
|
||||
if stored.LimitSubcents != 1000 {
|
||||
t.Fatalf("limit_subcents after rolled-back update = %d, want unchanged 1000", stored.LimitSubcents)
|
||||
}
|
||||
if stored.Revision != 1 {
|
||||
t.Fatalf("revision after rolled-back update = %d, want unchanged 1 (a rolled-back update must not consume a revision number)", stored.Revision)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ func TestPostgresBudgetClaims(t *testing.T) {
|
||||
t.Fatalf("create budget policy: %v", err)
|
||||
}
|
||||
|
||||
claimed, err := repo.Claim(ctx, policy.ID, "2026-09-01T00:00:00Z", "alert")
|
||||
claimed, err := repo.Claim(ctx, policy.ID, "2026-09-01T00:00:00Z", "alert", policy.Revision)
|
||||
if err != nil {
|
||||
t.Fatalf("first claim: %v", err)
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func TestPostgresBudgetClaims(t *testing.T) {
|
||||
t.Fatal("first claim should win")
|
||||
}
|
||||
|
||||
claimed, err = repo.Claim(ctx, policy.ID, "2026-09-01T00:00:00Z", "alert")
|
||||
claimed, err = repo.Claim(ctx, policy.ID, "2026-09-01T00:00:00Z", "alert", policy.Revision)
|
||||
if err != nil {
|
||||
t.Fatalf("second claim: %v", err)
|
||||
}
|
||||
@@ -72,9 +72,9 @@ func TestPostgresBudgetClaims(t *testing.T) {
|
||||
t.Fatal("second claim on the same (policy, period, level) must not win")
|
||||
}
|
||||
|
||||
// A foreign-key violation (no such policy) must classify as an ordinary
|
||||
// miss, not a store error — db.IsForeignKeyViolation's PostgreSQL branch.
|
||||
claimed, err = repo.Claim(ctx, uuid.NewString(), "lifetime", "alert")
|
||||
// A claim for a nonexistent policy must be an ordinary fenced miss, not a
|
||||
// store error. The INSERT ... SELECT fence prevents an FK violation here.
|
||||
claimed, err = repo.Claim(ctx, uuid.NewString(), "lifetime", "alert", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("claim against a nonexistent policy must not be a store error, got: %v", err)
|
||||
}
|
||||
@@ -95,3 +95,190 @@ func TestPostgresBudgetClaims(t *testing.T) {
|
||||
t.Fatalf("claim count after policy delete = %d, want 0 (FK ON DELETE CASCADE)", claimCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostgresBudgetClaims_RevisionFencingAndFourColumnKey is the PostgreSQL
|
||||
// twin of the revision-fencing coverage in budget_revision_fencing_test.go:
|
||||
// under READ COMMITTED a claim's WHERE EXISTS fence check and the row it
|
||||
// inserts both need proving on a real PostgreSQL backend, not just SQLite's
|
||||
// single-writer connection. It covers both halves called out by the fence
|
||||
// design: the fence itself refuses an insert against a superseded revision
|
||||
// (a stale evaluation cannot emit an obsolete claim), and revision being
|
||||
// part of the primary key means a claim row already recorded for an old
|
||||
// revision does not collide with — and so cannot suppress — a fresh
|
||||
// evaluation's claim at the new revision. Skips unless
|
||||
// KANDEV_TEST_POSTGRES_DSN is set.
|
||||
func TestPostgresBudgetClaims_RevisionFencingAndFourColumnKey(t *testing.T) {
|
||||
db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t))
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := taskrepo.NewWithDB(db, db, nil); err != nil {
|
||||
t.Fatalf("init task repo: %v", err)
|
||||
}
|
||||
repo, err := sqlite.NewWithDB(db, db, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("boot: %v", err)
|
||||
}
|
||||
|
||||
policy := &models.BudgetPolicy{
|
||||
WorkspaceID: "pg-budget-fencing-ws",
|
||||
ScopeType: "workspace",
|
||||
ScopeID: "pg-budget-fencing-ws",
|
||||
LimitSubcents: 1000,
|
||||
Period: "monthly",
|
||||
AlertThresholdPct: 80,
|
||||
ActionOnExceed: "notify_only",
|
||||
}
|
||||
if err := repo.CreateBudgetPolicy(ctx, policy); err != nil {
|
||||
t.Fatalf("create budget policy: %v", err)
|
||||
}
|
||||
staleRevision := policy.Revision
|
||||
|
||||
// A claim recorded at the current revision, before any update.
|
||||
claimed, err := repo.Claim(ctx, policy.ID, "lifetime", "alert", staleRevision)
|
||||
if err != nil {
|
||||
t.Fatalf("claim at revision %d: %v", staleRevision, err)
|
||||
}
|
||||
if !claimed {
|
||||
t.Fatal("claim at the current revision should win")
|
||||
}
|
||||
|
||||
if err := repo.UpdateBudgetPolicy(ctx, policy); err != nil {
|
||||
t.Fatalf("update policy: %v", err)
|
||||
}
|
||||
if policy.Revision != staleRevision+1 {
|
||||
t.Fatalf("revision after update = %d, want %d", policy.Revision, staleRevision+1)
|
||||
}
|
||||
|
||||
// The fence: a claim presented with the now-superseded revision must be
|
||||
// refused, even though it targets a different period so it collides
|
||||
// with nothing already in the table.
|
||||
claimed, err = repo.Claim(ctx, policy.ID, "stale-period", "alert", staleRevision)
|
||||
if err != nil {
|
||||
t.Fatalf("stale-revision claim must be an ordinary miss, got error: %v", err)
|
||||
}
|
||||
if claimed {
|
||||
t.Fatal("a claim presented with a superseded revision must not win")
|
||||
}
|
||||
|
||||
// A surviving old-revision row must not suppress a fresh claim. The
|
||||
// direct insert models a claim that was already committed by an older
|
||||
// evaluation while the policy update moved the current revision.
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
INSERT INTO office_budget_claims (policy_id, period_key, level, revision, claimed_at)
|
||||
VALUES ($1, 'lifetime', 'alert', $2, NOW())
|
||||
`, policy.ID, staleRevision); err != nil {
|
||||
t.Fatalf("seed surviving old-revision claim: %v", err)
|
||||
}
|
||||
|
||||
// The four-column key lets the current revision claim the same period and
|
||||
// level without colliding with that old-revision row.
|
||||
claimed, err = repo.Claim(ctx, policy.ID, "lifetime", "alert", policy.Revision)
|
||||
if err != nil {
|
||||
t.Fatalf("claim at the new revision: %v", err)
|
||||
}
|
||||
if !claimed {
|
||||
t.Fatal("claim at the current (post-update) revision should win")
|
||||
}
|
||||
|
||||
var claimCount int
|
||||
if err := db.GetContext(ctx, &claimCount,
|
||||
`SELECT COUNT(*) FROM office_budget_claims WHERE policy_id = $1`, policy.ID,
|
||||
); err != nil {
|
||||
t.Fatalf("count claims: %v", err)
|
||||
}
|
||||
if claimCount != 2 {
|
||||
t.Fatalf("claim rows for policy = %d, want 2 (old and current revisions must coexist)", claimCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostgresBudgetClaims_LegacyTableRecreated verifies the destructive
|
||||
// legacy-table cutover on a real PostgreSQL backend. The old three-column
|
||||
// table is replaced in one transaction, and a later boot must replay without
|
||||
// replacing the new table again. Skips unless KANDEV_TEST_POSTGRES_DSN is set.
|
||||
func TestPostgresBudgetClaims_LegacyTableRecreated(t *testing.T) {
|
||||
db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t))
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := taskrepo.NewWithDB(db, db, nil); err != nil {
|
||||
t.Fatalf("init task repo: %v", err)
|
||||
}
|
||||
repo, err := sqlite.NewWithDB(db, db, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("initial boot: %v", err)
|
||||
}
|
||||
|
||||
policy := &models.BudgetPolicy{
|
||||
WorkspaceID: "pg-budget-legacy-ws",
|
||||
ScopeType: "workspace",
|
||||
ScopeID: "pg-budget-legacy-ws",
|
||||
LimitSubcents: 1000,
|
||||
Period: "monthly",
|
||||
AlertThresholdPct: 80,
|
||||
ActionOnExceed: "notify_only",
|
||||
}
|
||||
if err := repo.CreateBudgetPolicy(ctx, policy); err != nil {
|
||||
t.Fatalf("create budget policy: %v", err)
|
||||
}
|
||||
|
||||
// Replace the current table with the pre-revision three-column shape and
|
||||
// leave a row in it. The next office boot must take the recreate path.
|
||||
if _, err := db.ExecContext(ctx, `DROP TABLE office_budget_claims`); err != nil {
|
||||
t.Fatalf("drop current claims table: %v", err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
CREATE TABLE office_budget_claims (
|
||||
policy_id TEXT NOT NULL,
|
||||
period_key TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
claimed_at TIMESTAMP NOT NULL,
|
||||
PRIMARY KEY (policy_id, period_key, level),
|
||||
FOREIGN KEY (policy_id) REFERENCES office_budget_policies(id) ON DELETE CASCADE
|
||||
)
|
||||
`); err != nil {
|
||||
t.Fatalf("create legacy claims table: %v", err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
INSERT INTO office_budget_claims (policy_id, period_key, level, claimed_at)
|
||||
VALUES ($1, 'legacy', 'alert', NOW())
|
||||
`, policy.ID); err != nil {
|
||||
t.Fatalf("seed legacy claim: %v", err)
|
||||
}
|
||||
|
||||
if _, err := sqlite.NewWithDB(db, db, nil); err != nil {
|
||||
t.Fatalf("boot that recreates legacy claims table: %v", err)
|
||||
}
|
||||
|
||||
var revisionColumns int
|
||||
if err := db.GetContext(ctx, &revisionColumns, `
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'office_budget_claims'
|
||||
AND column_name = 'revision'
|
||||
`); err != nil {
|
||||
t.Fatalf("probe recreated claims table: %v", err)
|
||||
}
|
||||
if revisionColumns != 1 {
|
||||
t.Fatalf("office_budget_claims.revision columns = %d, want 1", revisionColumns)
|
||||
}
|
||||
|
||||
claimed, err := repo.Claim(ctx, policy.ID, "replayed", "alert", policy.Revision)
|
||||
if err != nil {
|
||||
t.Fatalf("claim after legacy recreate: %v", err)
|
||||
}
|
||||
if !claimed {
|
||||
t.Fatal("claim after legacy recreate should win")
|
||||
}
|
||||
|
||||
if _, err := sqlite.NewWithDB(db, db, nil); err != nil {
|
||||
t.Fatalf("replay boot after recreate: %v", err)
|
||||
}
|
||||
var claimCount int
|
||||
if err := db.GetContext(ctx, &claimCount,
|
||||
`SELECT COUNT(*) FROM office_budget_claims WHERE policy_id = $1`, policy.ID,
|
||||
); err != nil {
|
||||
t.Fatalf("count claims after replay: %v", err)
|
||||
}
|
||||
if claimCount != 1 {
|
||||
t.Fatalf("claim rows after replay = %d, want 1", claimCount)
|
||||
}
|
||||
}
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
||||
"github.com/kandev/kandev/internal/db"
|
||||
runssqlite "github.com/kandev/kandev/internal/runs/repository/sqlite"
|
||||
)
|
||||
|
||||
// TestRecreateBudgetClaimsForRevision_FailureIsVisible covers the other
|
||||
// half of AC-OFFICE-COSTS-003.3a: a failed recreate must reach the caller
|
||||
// that initializes the schema, not be logged and swallowed the way the
|
||||
// neighbouring runMigrations()/activateRunOutcome() calls are.
|
||||
// failBudgetClaimsRecreateErr is a test-only failpoint (see base.go)
|
||||
// standing in for a fault-injecting driver; the repo is built by hand
|
||||
// (mirroring NewWithDB) so the failpoint can be set before initSchema runs,
|
||||
// while the database still carries the pre-.3 three-column claims table the
|
||||
// probe needs to find revision absent in.
|
||||
func TestRecreateBudgetClaimsForRevision_FailureIsVisible(t *testing.T) {
|
||||
sqlDB, err := sqlx.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
|
||||
if _, err := sqlDB.Exec(`
|
||||
CREATE TABLE office_budget_policies (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL,
|
||||
scope_type TEXT NOT NULL,
|
||||
scope_id TEXT NOT NULL,
|
||||
limit_subcents INTEGER NOT NULL,
|
||||
period TEXT NOT NULL,
|
||||
alert_threshold_pct INTEGER DEFAULT 80,
|
||||
action_on_exceed TEXT DEFAULT 'notify_only',
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
);
|
||||
CREATE TABLE office_budget_claims (
|
||||
policy_id TEXT NOT NULL,
|
||||
period_key TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
claimed_at TIMESTAMP NOT NULL,
|
||||
PRIMARY KEY (policy_id, period_key, level),
|
||||
FOREIGN KEY (policy_id) REFERENCES office_budget_policies(id) ON DELETE CASCADE
|
||||
);
|
||||
`); err != nil {
|
||||
t.Fatalf("seed legacy office_budget_claims: %v", err)
|
||||
}
|
||||
|
||||
repo := &Repository{
|
||||
Repository: runssqlite.NewWithDB(sqlDB, sqlDB),
|
||||
db: sqlDB,
|
||||
ro: sqlDB,
|
||||
migrate: db.NewRequiredMigrateLogger(sqlDB, nil),
|
||||
failBudgetClaimsRecreateErr: errors.New("injected recreate failure"),
|
||||
}
|
||||
|
||||
if err := repo.initSchema(); err == nil {
|
||||
t.Fatal("expected initSchema to surface the injected recreate failure, got nil error")
|
||||
}
|
||||
}
|
||||
|
||||
// budgetClaimsHasRevisionColumn reports whether office_budget_claims
|
||||
// currently declares a revision column, distinguishing the old three-column
|
||||
// shape from the final four-column one.
|
||||
func budgetClaimsHasRevisionColumn(t *testing.T, sqlDB *sqlx.DB) bool {
|
||||
t.Helper()
|
||||
var count int
|
||||
if err := sqlDB.Get(&count,
|
||||
`SELECT COUNT(*) FROM pragma_table_info('office_budget_claims') WHERE name = 'revision'`,
|
||||
); err != nil {
|
||||
t.Fatalf("probe office_budget_claims columns: %v", err)
|
||||
}
|
||||
return count == 1
|
||||
}
|
||||
|
||||
// TestRecreateBudgetClaimsForRevision_AtomicAcrossDropAndCreate covers the
|
||||
// crash-safety half of AC-OFFICE-COSTS-003.3a: DROP and CREATE must commit
|
||||
// or fail together, not as two independent statements. A boot interrupted
|
||||
// between them must not leave office_budget_claims absent, because a
|
||||
// missing table and an old-shape table both probe as "revision absent" and
|
||||
// an unconditional DROP TABLE (no IF EXISTS) on an already-missing table
|
||||
// would error forever on every later boot. failBudgetClaimsRecreateAfterDropErr
|
||||
// (see base.go) fires after DROP has run but before CREATE, inside the same
|
||||
// transaction, standing in for a process crash at that point.
|
||||
func TestRecreateBudgetClaimsForRevision_AtomicAcrossDropAndCreate(t *testing.T) {
|
||||
sqlDB, err := sqlx.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
|
||||
if _, err := sqlDB.Exec(`
|
||||
CREATE TABLE office_budget_policies (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL,
|
||||
scope_type TEXT NOT NULL,
|
||||
scope_id TEXT NOT NULL,
|
||||
limit_subcents INTEGER NOT NULL,
|
||||
period TEXT NOT NULL,
|
||||
alert_threshold_pct INTEGER DEFAULT 80,
|
||||
action_on_exceed TEXT DEFAULT 'notify_only',
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
);
|
||||
CREATE TABLE office_budget_claims (
|
||||
policy_id TEXT NOT NULL,
|
||||
period_key TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
claimed_at TIMESTAMP NOT NULL,
|
||||
PRIMARY KEY (policy_id, period_key, level),
|
||||
FOREIGN KEY (policy_id) REFERENCES office_budget_policies(id) ON DELETE CASCADE
|
||||
);
|
||||
`); err != nil {
|
||||
t.Fatalf("seed legacy office_budget_claims: %v", err)
|
||||
}
|
||||
|
||||
repo := &Repository{
|
||||
Repository: runssqlite.NewWithDB(sqlDB, sqlDB),
|
||||
db: sqlDB,
|
||||
ro: sqlDB,
|
||||
migrate: db.NewRequiredMigrateLogger(sqlDB, nil),
|
||||
}
|
||||
|
||||
repo.failBudgetClaimsRecreateAfterDropErr = errors.New("injected crash between drop and create")
|
||||
if err := repo.initSchema(); err == nil {
|
||||
t.Fatal("expected initSchema to surface the injected failure, got nil error")
|
||||
}
|
||||
if budgetClaimsHasRevisionColumn(t, sqlDB) {
|
||||
t.Fatal("a failure between DROP and CREATE must not leave the new shape partially applied")
|
||||
}
|
||||
var count int
|
||||
if err := sqlDB.Get(&count,
|
||||
`SELECT COUNT(*) FROM pragma_table_info('office_budget_claims')`,
|
||||
); err != nil {
|
||||
t.Fatalf("count office_budget_claims columns: %v", err)
|
||||
}
|
||||
if count == 0 {
|
||||
t.Fatal("a failure between DROP and CREATE must not leave office_budget_claims absent; the DROP must roll back too")
|
||||
}
|
||||
|
||||
repo.failBudgetClaimsRecreateAfterDropErr = nil
|
||||
if err := repo.initSchema(); err != nil {
|
||||
t.Fatalf("retry boot after the injected failure must self-heal, got: %v", err)
|
||||
}
|
||||
if !budgetClaimsHasRevisionColumn(t, sqlDB) {
|
||||
t.Fatal("retry boot must complete the recreate and leave the revision column present")
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
||||
settingsstore "github.com/kandev/kandev/internal/agent/settings/store"
|
||||
"github.com/kandev/kandev/internal/office/models"
|
||||
)
|
||||
|
||||
// TestClaimExceeded_CompanionFailure_RollsBackWholePair proves the
|
||||
// exceeded-level claim and its alert-level companion are one transaction
|
||||
// (AC-OFFICE-COSTS-003.10): when the companion insert fails after the
|
||||
// exceeded insert has already run, the whole transaction rolls back, so
|
||||
// neither row survives. failBudgetExceededCompanionErr is a test-only
|
||||
// failpoint (see base.go) standing in for a fault-injecting driver.
|
||||
func TestClaimExceeded_CompanionFailure_RollsBackWholePair(t *testing.T) {
|
||||
db, err := sqlx.Open("sqlite3", ":memory:?_foreign_keys=on")
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if _, err := db.Exec(`PRAGMA foreign_keys = ON`); err != nil {
|
||||
t.Fatalf("pragma fk: %v", err)
|
||||
}
|
||||
if _, _, err := settingsstore.Provide(db, db, nil); err != nil {
|
||||
t.Fatalf("settings store init: %v", err)
|
||||
}
|
||||
repo, err := NewWithDB(db, db, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("new repo: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
policy := &models.BudgetPolicy{
|
||||
WorkspaceID: "ws-companion-rollback",
|
||||
ScopeType: "workspace",
|
||||
ScopeID: "ws-companion-rollback",
|
||||
LimitSubcents: 1000,
|
||||
Period: "monthly",
|
||||
AlertThresholdPct: 80,
|
||||
ActionOnExceed: "notify_only",
|
||||
}
|
||||
if err := repo.CreateBudgetPolicy(ctx, policy); err != nil {
|
||||
t.Fatalf("create policy: %v", err)
|
||||
}
|
||||
|
||||
repo.failBudgetExceededCompanionErr = errors.New("injected companion failure")
|
||||
claimed, err := repo.ClaimExceeded(ctx, policy.ID, "lifetime", policy.Revision)
|
||||
if err == nil {
|
||||
t.Fatal("expected the injected companion failure to surface")
|
||||
}
|
||||
if claimed {
|
||||
t.Fatal("a failed pair must report claimed=false")
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := db.QueryRow(
|
||||
`SELECT COUNT(*) FROM office_budget_claims WHERE policy_id = ?`, policy.ID,
|
||||
).Scan(&count); err != nil {
|
||||
t.Fatalf("count claims: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("claim rows after a rolled-back pair = %d, want 0 (neither row must survive)", count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
package sqlite_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
||||
"github.com/kandev/kandev/internal/office/repository/sqlite"
|
||||
)
|
||||
|
||||
// TestBudgetPolicyRevisionMigration_LegacyRowsBackfillToOne covers
|
||||
// AC-OFFICE-COSTS-003.1: a database created before the revision column
|
||||
// exists gets it added as NOT NULL DEFAULT 1, an existing row is backfilled
|
||||
// to 1, and a boot replay is a no-op (no new local error classifier is
|
||||
// exercised beyond db.IsDuplicateColumnError, ADR 0027).
|
||||
func TestBudgetPolicyRevisionMigration_LegacyRowsBackfillToOne(t *testing.T) {
|
||||
db, err := sqlx.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
// Seed the pre-.3 office_budget_policies shape (no revision column) and
|
||||
// a legacy row, before the repo (and its migration) ever runs.
|
||||
if _, err := db.Exec(`
|
||||
CREATE TABLE office_budget_policies (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL,
|
||||
scope_type TEXT NOT NULL,
|
||||
scope_id TEXT NOT NULL,
|
||||
limit_subcents INTEGER NOT NULL,
|
||||
period TEXT NOT NULL,
|
||||
alert_threshold_pct INTEGER DEFAULT 80,
|
||||
action_on_exceed TEXT DEFAULT 'notify_only',
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
)`); err != nil {
|
||||
t.Fatalf("seed legacy office_budget_policies: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO office_budget_policies (
|
||||
id, workspace_id, scope_type, scope_id, limit_subcents, period, created_at, updated_at
|
||||
) VALUES ('legacy-1', 'ws-legacy', 'workspace', 'ws-legacy', 1000, 'monthly', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
`); err != nil {
|
||||
t.Fatalf("seed legacy row: %v", err)
|
||||
}
|
||||
|
||||
if _, err := sqlite.NewWithDB(db, db, nil); err != nil {
|
||||
t.Fatalf("first boot: %v", err)
|
||||
}
|
||||
|
||||
var revision int64
|
||||
if err := db.QueryRow(`SELECT revision FROM office_budget_policies WHERE id = 'legacy-1'`).Scan(&revision); err != nil {
|
||||
t.Fatalf("select revision on legacy row: %v", err)
|
||||
}
|
||||
if revision != 1 {
|
||||
t.Fatalf("legacy row revision = %d, want 1", revision)
|
||||
}
|
||||
|
||||
// Boot replay must be a no-op, not an error.
|
||||
if _, err := sqlite.NewWithDB(db, db, nil); err != nil {
|
||||
t.Fatalf("second boot (replay): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// legacyBudgetClaimsFixture builds an in-memory database still carrying
|
||||
// PR #3287's three-column office_budget_claims shape (no revision, no FK
|
||||
// dependency needed for these assertions), so the AC-OFFICE-COSTS-003.3a
|
||||
// recreate actually has to run rather than being short-circuited by a fresh
|
||||
// database's inline final-shape DDL.
|
||||
func legacyBudgetClaimsFixture(t *testing.T) *sqlx.DB {
|
||||
t.Helper()
|
||||
db, err := sqlx.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
if _, err := db.Exec(`
|
||||
CREATE TABLE office_budget_policies (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL,
|
||||
scope_type TEXT NOT NULL,
|
||||
scope_id TEXT NOT NULL,
|
||||
limit_subcents INTEGER NOT NULL,
|
||||
period TEXT NOT NULL,
|
||||
alert_threshold_pct INTEGER DEFAULT 80,
|
||||
action_on_exceed TEXT DEFAULT 'notify_only',
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
);
|
||||
CREATE TABLE office_budget_claims (
|
||||
policy_id TEXT NOT NULL,
|
||||
period_key TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
claimed_at TIMESTAMP NOT NULL,
|
||||
PRIMARY KEY (policy_id, period_key, level),
|
||||
FOREIGN KEY (policy_id) REFERENCES office_budget_policies(id) ON DELETE CASCADE
|
||||
);
|
||||
`); err != nil {
|
||||
t.Fatalf("seed legacy office_budget_claims: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// TestRecreateBudgetClaims_OldShapeDatabase_GuardedAcrossBoots covers
|
||||
// AC-OFFICE-COSTS-003.3a: booting against a database still carrying the
|
||||
// pre-.3 three-column claim table runs the recreate (destroying whatever
|
||||
// claims existed before that boot, which AC-OFFICE-COSTS-003.3 expressly
|
||||
// permits); a claim written *after* that boot survives a second boot, which
|
||||
// must probe, find revision present, and do nothing.
|
||||
func TestRecreateBudgetClaims_OldShapeDatabase_GuardedAcrossBoots(t *testing.T) {
|
||||
db := legacyBudgetClaimsFixture(t)
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO office_budget_policies (
|
||||
id, workspace_id, scope_type, scope_id, limit_subcents, period, created_at, updated_at
|
||||
) VALUES ('p1', 'ws-1', 'workspace', 'ws-1', 1000, 'monthly', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
`); err != nil {
|
||||
t.Fatalf("seed policy: %v", err)
|
||||
}
|
||||
// A claim held before the cutover boot: the recreate is permitted to
|
||||
// destroy it (AC-OFFICE-COSTS-003.3's accepted first-run behavior).
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO office_budget_claims (policy_id, period_key, level, claimed_at)
|
||||
VALUES ('p1', 'lifetime', 'alert', CURRENT_TIMESTAMP)
|
||||
`); err != nil {
|
||||
t.Fatalf("seed pre-cutover claim: %v", err)
|
||||
}
|
||||
|
||||
repo, err := sqlite.NewWithDB(db, db, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("first boot (cutover): %v", err)
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM office_budget_claims`).Scan(&count); err != nil {
|
||||
t.Fatalf("count claims after cutover: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("claims after cutover = %d, want 0 (pre-cutover claims are destroyed by the recreate)", count)
|
||||
}
|
||||
|
||||
var revisionColExists int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('office_budget_claims') WHERE name = 'revision'`).
|
||||
Scan(&revisionColExists); err != nil {
|
||||
t.Fatalf("probe revision column: %v", err)
|
||||
}
|
||||
if revisionColExists != 1 {
|
||||
t.Fatal("office_budget_claims.revision must exist after the cutover boot")
|
||||
}
|
||||
|
||||
// A claim written after the cutover boot must survive a second boot.
|
||||
ctx := context.Background()
|
||||
if claimed, err := repo.Claim(ctx, "p1", "lifetime", "alert", 1); err != nil || !claimed {
|
||||
t.Fatalf("post-cutover claim: claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
|
||||
if _, err := sqlite.NewWithDB(db, db, nil); err != nil {
|
||||
t.Fatalf("second boot (replay): %v", err)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM office_budget_claims`).Scan(&count); err != nil {
|
||||
t.Fatalf("count claims after replay: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("claims after replay boot = %d, want 1 (the probe must no-op on a second boot)", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateBudgetPolicy_AssignsRevisionOne_IgnoringPresentedValue covers
|
||||
// AC-OFFICE-COSTS-003.4: revision is always 1 on create, regardless of any
|
||||
// value already set on the policy struct passed in, and the assigned value
|
||||
// is written back so the caller sees what the row actually holds.
|
||||
func TestCreateBudgetPolicy_AssignsRevisionOne_IgnoringPresentedValue(t *testing.T) {
|
||||
repo, db := newBudgetClaimsRepoWithFK(t)
|
||||
ctx := context.Background()
|
||||
|
||||
policy := createTestBudgetPolicy(t, repo, "ws-revision-create")
|
||||
policy.Revision = 777 // presented before a hypothetical re-create; must not survive.
|
||||
policy.ID = "" // force a fresh insert
|
||||
if err := repo.CreateBudgetPolicy(ctx, policy); err != nil {
|
||||
t.Fatalf("create policy: %v", err)
|
||||
}
|
||||
if policy.Revision != 1 {
|
||||
t.Fatalf("revision returned to caller = %d, want 1", policy.Revision)
|
||||
}
|
||||
|
||||
var stored int64
|
||||
if err := db.QueryRow(`SELECT revision FROM office_budget_policies WHERE id = ?`, policy.ID).Scan(&stored); err != nil {
|
||||
t.Fatalf("select stored revision: %v", err)
|
||||
}
|
||||
if stored != 1 {
|
||||
t.Fatalf("stored revision = %d, want 1", stored)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateBudgetPolicy_NonexistentPolicy_ReturnsErrorNoCommit covers the
|
||||
// AC-OFFICE-COSTS-003.5 tightening: updating a policy id that no longer
|
||||
// exists rolls the transaction back and reports an error, a behavior change
|
||||
// from #3287 where the update ignored its affected-row count and reported
|
||||
// success.
|
||||
func TestUpdateBudgetPolicy_NonexistentPolicy_ReturnsErrorNoCommit(t *testing.T) {
|
||||
repo, _ := newBudgetClaimsRepoWithFK(t)
|
||||
ctx := context.Background()
|
||||
|
||||
policy := createTestBudgetPolicy(t, repo, "ws-revision-missing")
|
||||
if err := repo.DeleteBudgetPolicy(ctx, policy.ID); err != nil {
|
||||
t.Fatalf("delete policy: %v", err)
|
||||
}
|
||||
|
||||
policy.LimitSubcents = 9999
|
||||
if err := repo.UpdateBudgetPolicy(ctx, policy); err == nil {
|
||||
t.Fatal("expected an error updating a policy id that no longer exists")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateBudgetPolicy_ConcurrentUpdates_RevisionAdvancesByTwo covers
|
||||
// concurrency case 6 of the forced-to-invent pass: two concurrent updates
|
||||
// each bump by 1, and the final revision is r+2 with no increment lost —
|
||||
// serialized by SQLite's single-writer connection, exactly as production
|
||||
// runs (internal/db/pool.go).
|
||||
func TestUpdateBudgetPolicy_ConcurrentUpdates_RevisionAdvancesByTwo(t *testing.T) {
|
||||
repo, db := newBudgetClaimsRepoWithFK(t)
|
||||
ctx := context.Background()
|
||||
policy := createTestBudgetPolicy(t, repo, "ws-revision-concurrent")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
for i := 0; i < 2; i++ {
|
||||
go func(limit int64) {
|
||||
defer wg.Done()
|
||||
p, err := repo.GetBudgetPolicy(ctx, policy.ID)
|
||||
if err != nil {
|
||||
t.Errorf("get policy: %v", err)
|
||||
return
|
||||
}
|
||||
p.LimitSubcents = limit
|
||||
if err := repo.UpdateBudgetPolicy(ctx, p); err != nil {
|
||||
t.Errorf("update policy: %v", err)
|
||||
}
|
||||
}(int64(2000 + i))
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
var revision int64
|
||||
if err := db.QueryRow(`SELECT revision FROM office_budget_policies WHERE id = ?`, policy.ID).Scan(&revision); err != nil {
|
||||
t.Fatalf("select final revision: %v", err)
|
||||
}
|
||||
if revision != 3 {
|
||||
t.Fatalf("final revision = %d, want 3 (started at 1, two updates each +1)", revision)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaim_RaceA_StaleRevisionRefused_FreshRevisionSucceeds covers
|
||||
// AC-OFFICE-COSTS-003.8/.9/.15 (the verification.md "Race A regression"
|
||||
// bullet): a claim presenting a superseded revision is refused as an
|
||||
// ordinary miss (no error), and the slot for the new revision stays open so
|
||||
// a claim at the current revision still wins.
|
||||
func TestClaim_RaceA_StaleRevisionRefused_FreshRevisionSucceeds(t *testing.T) {
|
||||
repo, _ := newBudgetClaimsRepoWithFK(t)
|
||||
ctx := context.Background()
|
||||
policy := createTestBudgetPolicy(t, repo, "ws-race-a")
|
||||
staleRevision := policy.Revision // 1
|
||||
|
||||
// Concurrent UpdateBudgetPolicy commits, bumping the stored revision to 2.
|
||||
policy.LimitSubcents = 2000
|
||||
if err := repo.UpdateBudgetPolicy(ctx, policy); err != nil {
|
||||
t.Fatalf("update policy: %v", err)
|
||||
}
|
||||
if policy.Revision != staleRevision+1 {
|
||||
t.Fatalf("revision after update = %d, want %d", policy.Revision, staleRevision+1)
|
||||
}
|
||||
|
||||
// The stale evaluation's claim, fenced to the superseded revision, must
|
||||
// be refused with no error.
|
||||
claimed, err := repo.Claim(ctx, policy.ID, "lifetime", "alert", staleRevision)
|
||||
if err != nil {
|
||||
t.Fatalf("stale claim must not be a store error, got: %v", err)
|
||||
}
|
||||
if claimed {
|
||||
t.Fatal("a claim fenced to a superseded revision must not win")
|
||||
}
|
||||
|
||||
// The slot for the new revision must still be open.
|
||||
claimed, err = repo.Claim(ctx, policy.ID, "lifetime", "alert", policy.Revision)
|
||||
if err != nil {
|
||||
t.Fatalf("fresh-revision claim: %v", err)
|
||||
}
|
||||
if !claimed {
|
||||
t.Fatal("a claim fenced to the current revision must win even after a stale claim was refused")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaim_InvalidInput_FailsOpenNotSilently covers AC-OFFICE-COSTS-003.13:
|
||||
// each of the four primary-key components, when invalid, is a claim-store
|
||||
// error (never the silent miss AC-OFFICE-COSTS-003.9 reserves for a refused
|
||||
// fence), so the guard must run before the fenced statement rather than as
|
||||
// part of it.
|
||||
func TestClaim_InvalidInput_FailsOpenNotSilently(t *testing.T) {
|
||||
repo, _ := newBudgetClaimsRepoWithFK(t)
|
||||
ctx := context.Background()
|
||||
policy := createTestBudgetPolicy(t, repo, "ws-invalid-input")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
policyID, period string
|
||||
level string
|
||||
revision int64
|
||||
}{
|
||||
{"zero revision", policy.ID, "lifetime", "alert", 0},
|
||||
{"negative revision", policy.ID, "lifetime", "alert", -1},
|
||||
{"empty policy id", "", "lifetime", "alert", policy.Revision},
|
||||
{"empty period key", policy.ID, "", "alert", policy.Revision},
|
||||
{"empty level", policy.ID, "lifetime", "", policy.Revision},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
claimed, err := repo.Claim(ctx, tc.policyID, tc.period, tc.level, tc.revision)
|
||||
if err == nil {
|
||||
t.Fatalf("expected a claim-store error for %s, got claimed=%v err=nil", tc.name, claimed)
|
||||
}
|
||||
if claimed {
|
||||
t.Fatalf("an errored claim attempt must report claimed=false, got true for %s", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaimExceeded_HappyPath_BothRowsExistAtSameRevision covers
|
||||
// AC-OFFICE-COSTS-003.10/.11 on a healthy store: a successful ClaimExceeded
|
||||
// call commits both the exceeded-level and alert-level companion rows at
|
||||
// the same policy, period and revision.
|
||||
func TestClaimExceeded_HappyPath_BothRowsExistAtSameRevision(t *testing.T) {
|
||||
repo, db := newBudgetClaimsRepoWithFK(t)
|
||||
ctx := context.Background()
|
||||
policy := createTestBudgetPolicy(t, repo, "ws-exceeded-happy")
|
||||
|
||||
claimed, err := repo.ClaimExceeded(ctx, policy.ID, "lifetime", policy.Revision)
|
||||
if err != nil {
|
||||
t.Fatalf("claim exceeded: %v", err)
|
||||
}
|
||||
if !claimed {
|
||||
t.Fatal("first exceeded claim should win")
|
||||
}
|
||||
|
||||
for _, level := range []string{"exceeded", "alert"} {
|
||||
var count int
|
||||
if err := db.QueryRow(
|
||||
`SELECT COUNT(*) FROM office_budget_claims WHERE policy_id = ? AND period_key = 'lifetime' AND level = ? AND revision = ?`,
|
||||
policy.ID, level, policy.Revision,
|
||||
).Scan(&count); err != nil {
|
||||
t.Fatalf("count %s claim: %v", level, err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("%s claim rows = %d, want 1", level, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaimExceeded_CompanionConflictIsPairSuccess covers the first
|
||||
// verification.md "pair outcomes that are successes, not failures" case:
|
||||
// with an alert-level claim already held, an over-limit evaluation's
|
||||
// companion insert conflicts, but the pair still commits and the exceeded
|
||||
// claim is won.
|
||||
func TestClaimExceeded_CompanionConflictIsPairSuccess(t *testing.T) {
|
||||
repo, _ := newBudgetClaimsRepoWithFK(t)
|
||||
ctx := context.Background()
|
||||
policy := createTestBudgetPolicy(t, repo, "ws-companion-conflict")
|
||||
|
||||
if claimed, err := repo.Claim(ctx, policy.ID, "lifetime", "alert", policy.Revision); err != nil || !claimed {
|
||||
t.Fatalf("seed alert claim: claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
|
||||
claimed, err := repo.ClaimExceeded(ctx, policy.ID, "lifetime", policy.Revision)
|
||||
if err != nil {
|
||||
t.Fatalf("claim exceeded: %v", err)
|
||||
}
|
||||
if !claimed {
|
||||
t.Fatal("exceeded claim must still win despite the companion insert conflicting")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaimExceeded_AlreadyHeld_NoCompanionAttempted covers the second
|
||||
// verification.md "pair outcomes" case: with an exceeded-level claim
|
||||
// already held, a second over-limit evaluation's exceeded insert writes
|
||||
// nothing, and no companion insert is attempted.
|
||||
func TestClaimExceeded_AlreadyHeld_NoCompanionAttempted(t *testing.T) {
|
||||
repo, db := newBudgetClaimsRepoWithFK(t)
|
||||
ctx := context.Background()
|
||||
policy := createTestBudgetPolicy(t, repo, "ws-exceeded-already-held")
|
||||
|
||||
if claimed, err := repo.ClaimExceeded(ctx, policy.ID, "lifetime", policy.Revision); err != nil || !claimed {
|
||||
t.Fatalf("first exceeded claim: claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
// Remove the companion so a second, incorrectly-attempted companion
|
||||
// insert would be observable.
|
||||
if _, err := db.Exec(
|
||||
`DELETE FROM office_budget_claims WHERE policy_id = ? AND level = 'alert'`, policy.ID,
|
||||
); err != nil {
|
||||
t.Fatalf("remove companion: %v", err)
|
||||
}
|
||||
|
||||
claimed, err := repo.ClaimExceeded(ctx, policy.ID, "lifetime", policy.Revision)
|
||||
if err != nil {
|
||||
t.Fatalf("second exceeded claim: %v", err)
|
||||
}
|
||||
if claimed {
|
||||
t.Fatal("a second exceeded claim at an already-held (policy, period, revision) must not win")
|
||||
}
|
||||
|
||||
var companionCount int
|
||||
if err := db.QueryRow(
|
||||
`SELECT COUNT(*) FROM office_budget_claims WHERE policy_id = ? AND level = 'alert'`, policy.ID,
|
||||
).Scan(&companionCount); err != nil {
|
||||
t.Fatalf("count companion rows: %v", err)
|
||||
}
|
||||
if companionCount != 0 {
|
||||
t.Fatal("no companion insert must be attempted when the exceeded insert itself writes no row")
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,11 @@ import (
|
||||
"github.com/kandev/kandev/internal/office/models"
|
||||
)
|
||||
|
||||
// CreateBudgetPolicy creates a new budget policy.
|
||||
// CreateBudgetPolicy creates a new budget policy. Its revision is always
|
||||
// assigned 1, regardless of any value already set on policy — including a
|
||||
// nonzero one presented for creation. The assigned value is written back
|
||||
// onto policy so the caller (the create response) reports what the row
|
||||
// actually holds rather than leaning on the column's DEFAULT 1.
|
||||
func (r *Repository) CreateBudgetPolicy(ctx context.Context, policy *models.BudgetPolicy) error {
|
||||
if policy.ID == "" {
|
||||
policy.ID = uuid.New().String()
|
||||
@@ -21,15 +25,16 @@ func (r *Repository) CreateBudgetPolicy(ctx context.Context, policy *models.Budg
|
||||
now := time.Now().UTC()
|
||||
policy.CreatedAt = now
|
||||
policy.UpdatedAt = now
|
||||
policy.Revision = 1
|
||||
|
||||
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
||||
INSERT INTO office_budget_policies (
|
||||
id, workspace_id, scope_type, scope_id, limit_subcents, period,
|
||||
alert_threshold_pct, action_on_exceed, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
alert_threshold_pct, action_on_exceed, created_at, updated_at, revision
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`), policy.ID, policy.WorkspaceID, policy.ScopeType, policy.ScopeID,
|
||||
policy.LimitSubcents, policy.Period, policy.AlertThresholdPct,
|
||||
policy.ActionOnExceed, policy.CreatedAt, policy.UpdatedAt)
|
||||
policy.ActionOnExceed, policy.CreatedAt, policy.UpdatedAt, policy.Revision)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -78,6 +83,15 @@ func (r *Repository) UpdateBudgetPolicy(ctx context.Context, policy *models.Budg
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// updateBudgetPolicyTx discards the policy's claims, bumps its revision by
|
||||
// exactly 1 in-SQL, and reads the new revision back onto policy, all in the
|
||||
// caller's transaction. The bump is expressed as revision = revision + 1
|
||||
// rather than a value computed from a prior read, so two concurrent updates
|
||||
// can never write the same revision. If no row matches id — the policy was
|
||||
// deleted between the caller's read and this write — the RETURNING
|
||||
// read-back yields sql.ErrNoRows, which propagates as an error so the
|
||||
// caller rolls the whole transaction back rather than committing an empty
|
||||
// update.
|
||||
func (r *Repository) updateBudgetPolicyTx(ctx context.Context, tx *sqlx.Tx, policy *models.BudgetPolicy) error {
|
||||
if _, err := tx.ExecContext(ctx, tx.Rebind(
|
||||
`DELETE FROM office_budget_claims WHERE policy_id = ?`), policy.ID); err != nil {
|
||||
@@ -86,14 +100,20 @@ func (r *Repository) updateBudgetPolicyTx(ctx context.Context, tx *sqlx.Tx, poli
|
||||
if r.failBudgetPolicyUpdateErr != nil {
|
||||
return r.failBudgetPolicyUpdateErr
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, tx.Rebind(`
|
||||
err := tx.QueryRowContext(ctx, tx.Rebind(`
|
||||
UPDATE office_budget_policies SET
|
||||
scope_type = ?, scope_id = ?, limit_subcents = ?, period = ?,
|
||||
alert_threshold_pct = ?, action_on_exceed = ?, updated_at = ?
|
||||
alert_threshold_pct = ?, action_on_exceed = ?, updated_at = ?,
|
||||
revision = revision + 1
|
||||
WHERE id = ?
|
||||
RETURNING revision
|
||||
`), policy.ScopeType, policy.ScopeID, policy.LimitSubcents, policy.Period,
|
||||
policy.AlertThresholdPct, policy.ActionOnExceed, policy.UpdatedAt, policy.ID)
|
||||
policy.AlertThresholdPct, policy.ActionOnExceed, policy.UpdatedAt, policy.ID,
|
||||
).Scan(&policy.Revision)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return fmt.Errorf("update policy: not found: %s", policy.ID)
|
||||
}
|
||||
return fmt.Errorf("update policy: %w", err)
|
||||
}
|
||||
return nil
|
||||
@@ -107,27 +127,207 @@ func (r *Repository) DeleteBudgetPolicy(ctx context.Context, id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Claim atomically records that (policyID, periodKey, level) may emit its
|
||||
// budget notification. claimed=true means this call won and the caller
|
||||
// should emit; claimed=false with a nil error means an earlier evaluation
|
||||
// already holds the claim, or the referenced policy no longer exists — a
|
||||
// foreign-key violation is deliberately reported as an ordinary miss, not a
|
||||
// store failure.
|
||||
func (r *Repository) Claim(ctx context.Context, policyID, periodKey, level string) (bool, error) {
|
||||
res, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
||||
INSERT INTO office_budget_claims (policy_id, period_key, level, claimed_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(policy_id, period_key, level) DO NOTHING
|
||||
`), policyID, periodKey, level, time.Now().UTC())
|
||||
// budgetClaimLevelExceeded and budgetClaimLevelAlert are the claim-level
|
||||
// literals ClaimExceeded writes for its atomic pair. Level is otherwise
|
||||
// caller-supplied data, not a package concept, but ClaimExceeded's exported
|
||||
// signature does not take a level parameter, so its two writes need names
|
||||
// of their own.
|
||||
const (
|
||||
budgetClaimLevelExceeded = "exceeded"
|
||||
budgetClaimLevelAlert = "alert"
|
||||
)
|
||||
|
||||
// execRebinder is the subset of *sqlx.DB / *sqlx.Tx a fenced claim insert
|
||||
// needs, so the same statement can run standalone or inside ClaimExceeded's
|
||||
// transaction.
|
||||
type execRebinder interface {
|
||||
ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error)
|
||||
Rebind(string) string
|
||||
}
|
||||
|
||||
// validateClaimInput reports whether a claim attempt's primary-key
|
||||
// components are all well-formed. Every failure here is a programming
|
||||
// error, not a superseded snapshot, so the caller treats it as a
|
||||
// claim-store error and fails open rather than as the silent miss a
|
||||
// refused fence produces.
|
||||
func validateClaimInput(policyID, periodKey, level string, revision int64) error {
|
||||
if revision <= 0 {
|
||||
return fmt.Errorf("claim: revision must be positive, got %d", revision)
|
||||
}
|
||||
if policyID == "" {
|
||||
return fmt.Errorf("claim: policy id must not be empty")
|
||||
}
|
||||
if periodKey == "" {
|
||||
return fmt.Errorf("claim: period key must not be empty")
|
||||
}
|
||||
if level == "" {
|
||||
return fmt.Errorf("claim: level must not be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// claimInsertFenced inserts a claim row only if policyID still holds
|
||||
// exactly revision at the moment the statement runs, checked as part of the
|
||||
// same statement so no evaluation can observe a matching revision and then
|
||||
// insert against a superseded one. A policy that no longer exists at all
|
||||
// fails the same EXISTS check, which is how a deleted policy's claim
|
||||
// attempt is refused without ever reaching the foreign-key constraint. The
|
||||
// FK violation classifier is kept as a defensive fallback only: dropping it
|
||||
// would let a genuine foreign-key error read as an ordinary miss.
|
||||
func claimInsertFenced(ctx context.Context, ex execRebinder, policyID, periodKey, level string, revision int64) (bool, error) {
|
||||
res, err := ex.ExecContext(ctx, ex.Rebind(`
|
||||
INSERT INTO office_budget_claims (policy_id, period_key, level, revision, claimed_at)
|
||||
SELECT ?, ?, ?, ?, ?
|
||||
WHERE EXISTS (SELECT 1 FROM office_budget_policies WHERE id = ? AND revision = ?)
|
||||
ON CONFLICT(policy_id, period_key, level, revision) DO NOTHING
|
||||
`), policyID, periodKey, level, revision, time.Now().UTC(), policyID, revision)
|
||||
if err != nil {
|
||||
if db.IsForeignKeyViolation(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return rowsAffectedOne(res)
|
||||
}
|
||||
|
||||
// claimInsertCompanion inserts the alert-level companion row for an
|
||||
// already-won exceeded claim, keyed to the same revision with no fence of
|
||||
// its own — its key already names the revision, so a row written against a
|
||||
// revision that has since moved is inert rather than wrong. Any error,
|
||||
// including a foreign-key violation from a policy deleted mid-transaction,
|
||||
// is returned unmodified so ClaimExceeded rolls the whole pair back rather
|
||||
// than committing a lone exceeded claim.
|
||||
func claimInsertCompanion(ctx context.Context, tx *sqlx.Tx, policyID, periodKey string, revision int64) (bool, error) {
|
||||
res, err := tx.ExecContext(ctx, tx.Rebind(`
|
||||
INSERT INTO office_budget_claims (policy_id, period_key, level, revision, claimed_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(policy_id, period_key, level, revision) DO NOTHING
|
||||
`), policyID, periodKey, budgetClaimLevelAlert, revision, time.Now().UTC())
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return rowsAffectedOne(res)
|
||||
}
|
||||
|
||||
func rowsAffectedOne(res sql.Result) (bool, error) {
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return affected == 1, nil
|
||||
}
|
||||
|
||||
// Claim atomically records that (policyID, periodKey, level) may emit its
|
||||
// budget notification, fenced to revision: the claim is recorded only while
|
||||
// revision is still the policy's stored revision. claimed=true means this
|
||||
// call won and the caller should emit; claimed=false with a nil error means
|
||||
// an earlier evaluation already holds the claim, the referenced policy no
|
||||
// longer exists, or the presented revision has been superseded — all three
|
||||
// are the same ordinary miss.
|
||||
func (r *Repository) Claim(ctx context.Context, policyID, periodKey, level string, revision int64) (bool, error) {
|
||||
if err := validateClaimInput(policyID, periodKey, level, revision); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return claimInsertFenced(ctx, r.db, policyID, periodKey, level, revision)
|
||||
}
|
||||
|
||||
// ClaimExceeded atomically records the exceeded-level claim and, only when
|
||||
// that claim is won by this call, its alert-level companion claim, in one
|
||||
// transaction: once it commits, either both rows exist for that policy,
|
||||
// period and revision, or this call wrote neither. The fence is evaluated
|
||||
// once, on the exceeded insert; the companion insert carries no fence of
|
||||
// its own. Returns whether the exceeded-level row was inserted by this
|
||||
// call — the companion's own outcome (won, or an ordinary conflict with an
|
||||
// alert-level claim an earlier evaluation already holds) never changes it.
|
||||
func (r *Repository) ClaimExceeded(ctx context.Context, policyID, periodKey string, revision int64) (bool, error) {
|
||||
if err := validateClaimInput(policyID, periodKey, budgetClaimLevelExceeded, revision); err != nil {
|
||||
return false, err
|
||||
}
|
||||
tx, err := r.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
claimed, err := r.claimExceededTx(ctx, tx, policyID, periodKey, revision)
|
||||
if err != nil {
|
||||
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||
return false, fmt.Errorf("%w; rollback: %v", err, rollbackErr)
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
if !claimed {
|
||||
return false, tx.Commit()
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *Repository) claimExceededTx(
|
||||
ctx context.Context, tx *sqlx.Tx, policyID, periodKey string, revision int64,
|
||||
) (bool, error) {
|
||||
claimed, err := claimInsertFenced(ctx, tx, policyID, periodKey, budgetClaimLevelExceeded, revision)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !claimed {
|
||||
// Refused by the fence, or conflicting with an exceeded claim
|
||||
// already held: no emission to protect, so the companion insert
|
||||
// must not be attempted.
|
||||
return false, nil
|
||||
}
|
||||
if r.failBudgetExceededCompanionErr != nil {
|
||||
return false, r.failBudgetExceededCompanionErr
|
||||
}
|
||||
if _, err := claimInsertCompanion(ctx, tx, policyID, periodKey, revision); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// recreateBudgetClaimsForRevision takes effect at most once per database:
|
||||
// it probes whether office_budget_claims already declares revision and does
|
||||
// nothing when it does, which is every boot after the first on a fresh
|
||||
// database (createCostTables already declares the final shape there) and
|
||||
// every boot after the first successful recreate on an upgraded one. On a
|
||||
// database still carrying the pre-revision three-column table, it drops and
|
||||
// recreates office_budget_claims with the four-column primary key the
|
||||
// current schema requires, as one transaction: DROP and CREATE either both
|
||||
// commit or neither does, so a boot interrupted between them leaves the
|
||||
// table exactly as it was rather than absent, and a retried boot always
|
||||
// finds the table in a state the probe can classify. It is not expressed as
|
||||
// an r.migrate.Apply statement: that runner re-executes its SQL on every
|
||||
// boot and is idempotent only because an already-exists error is
|
||||
// swallowed, which would destroy claim state on every boot here. It
|
||||
// returns its error to its caller rather than swallowing it, so a failed
|
||||
// recreate is visible rather than silently leaving the old three-column
|
||||
// key in place.
|
||||
func (r *Repository) recreateBudgetClaimsForRevision() error {
|
||||
exists, err := db.ColumnExists(r.db, "office_budget_claims", "revision")
|
||||
if err != nil {
|
||||
return fmt.Errorf("probe office_budget_claims.revision: %w", err)
|
||||
}
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
if r.failBudgetClaimsRecreateErr != nil {
|
||||
return r.failBudgetClaimsRecreateErr
|
||||
}
|
||||
tx, err := r.db.Beginx()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin office_budget_claims recreate: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(`DROP TABLE IF EXISTS office_budget_claims`); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("drop office_budget_claims: %w", err)
|
||||
}
|
||||
if r.failBudgetClaimsRecreateAfterDropErr != nil {
|
||||
_ = tx.Rollback()
|
||||
return r.failBudgetClaimsRecreateAfterDropErr
|
||||
}
|
||||
if _, err := tx.Exec(budgetClaimsDDL); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("recreate office_budget_claims: %w", err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
@@ -37,10 +37,16 @@ func newBudgetClaimsRepoWithFK(t *testing.T) (*sqlite.Repository, *sqlx.DB) {
|
||||
}
|
||||
// office_task_tree_holds and friends carry an FK to the task-package
|
||||
// owned "tasks" table; with foreign_keys=ON that table must exist (even
|
||||
// empty) for a workspace-wide DELETE to validate the constraint.
|
||||
// empty) for a workspace-wide DELETE to validate the constraint. title/
|
||||
// description/identifier are included so migrateTaskFTS's backfill (which
|
||||
// selects them unconditionally once a "tasks" table exists) doesn't fail
|
||||
// schema init on this minimal fixture.
|
||||
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL DEFAULT ''
|
||||
workspace_id TEXT NOT NULL DEFAULT '',
|
||||
title TEXT DEFAULT '',
|
||||
description TEXT DEFAULT '',
|
||||
identifier TEXT DEFAULT ''
|
||||
)`); err != nil {
|
||||
t.Fatalf("create tasks: %v", err)
|
||||
}
|
||||
@@ -84,7 +90,7 @@ func TestClaim_FirstCallWinsSecondCallDoesNotReclaim(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
policy := createTestBudgetPolicy(t, repo, "ws-claim")
|
||||
|
||||
claimed, err := repo.Claim(ctx, policy.ID, "2026-09-01T00:00:00Z", "alert")
|
||||
claimed, err := repo.Claim(ctx, policy.ID, "2026-09-01T00:00:00Z", "alert", policy.Revision)
|
||||
if err != nil {
|
||||
t.Fatalf("first claim: %v", err)
|
||||
}
|
||||
@@ -92,7 +98,7 @@ func TestClaim_FirstCallWinsSecondCallDoesNotReclaim(t *testing.T) {
|
||||
t.Fatal("first claim should win")
|
||||
}
|
||||
|
||||
claimed, err = repo.Claim(ctx, policy.ID, "2026-09-01T00:00:00Z", "alert")
|
||||
claimed, err = repo.Claim(ctx, policy.ID, "2026-09-01T00:00:00Z", "alert", policy.Revision)
|
||||
if err != nil {
|
||||
t.Fatalf("second claim: %v", err)
|
||||
}
|
||||
@@ -106,15 +112,15 @@ func TestClaim_DifferentPeriodOrLevelIsIndependent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
policy := createTestBudgetPolicy(t, repo, "ws-claim-2")
|
||||
|
||||
if claimed, err := repo.Claim(ctx, policy.ID, "2026-09-01T00:00:00Z", "alert"); err != nil || !claimed {
|
||||
if claimed, err := repo.Claim(ctx, policy.ID, "2026-09-01T00:00:00Z", "alert", policy.Revision); err != nil || !claimed {
|
||||
t.Fatalf("claim september alert: claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
// A different period (new window) is unclaimed (AC-OFFICE-COSTS-002.7).
|
||||
if claimed, err := repo.Claim(ctx, policy.ID, "2026-10-01T00:00:00Z", "alert"); err != nil || !claimed {
|
||||
if claimed, err := repo.Claim(ctx, policy.ID, "2026-10-01T00:00:00Z", "alert", policy.Revision); err != nil || !claimed {
|
||||
t.Fatalf("claim october alert: claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
// A different level in the same period is unclaimed.
|
||||
if claimed, err := repo.Claim(ctx, policy.ID, "2026-09-01T00:00:00Z", "exceeded"); err != nil || !claimed {
|
||||
if claimed, err := repo.Claim(ctx, policy.ID, "2026-09-01T00:00:00Z", "exceeded", policy.Revision); err != nil || !claimed {
|
||||
t.Fatalf("claim september exceeded: claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
}
|
||||
@@ -123,7 +129,7 @@ func TestClaim_ForeignKeyViolationReturnsFalseWithNoError(t *testing.T) {
|
||||
repo, _ := newBudgetClaimsRepoWithFK(t)
|
||||
ctx := context.Background()
|
||||
|
||||
claimed, err := repo.Claim(ctx, uuid.NewString(), "lifetime", "alert")
|
||||
claimed, err := repo.Claim(ctx, uuid.NewString(), "lifetime", "alert", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("claim against a nonexistent policy must not be a store error, got: %v", err)
|
||||
}
|
||||
@@ -137,7 +143,7 @@ func TestDeleteBudgetPolicy_CascadesClaims(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
policy := createTestBudgetPolicy(t, repo, "ws-cascade-1")
|
||||
|
||||
if _, err := repo.Claim(ctx, policy.ID, "lifetime", "alert"); err != nil {
|
||||
if _, err := repo.Claim(ctx, policy.ID, "lifetime", "alert", policy.Revision); err != nil {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
if got := countBudgetClaims(t, db, policy.ID); got != 1 {
|
||||
@@ -157,7 +163,7 @@ func TestDeleteWorkspaceData_CascadesBudgetClaims(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
policy := createTestBudgetPolicy(t, repo, "ws-cascade-2")
|
||||
|
||||
if _, err := repo.Claim(ctx, policy.ID, "lifetime", "alert"); err != nil {
|
||||
if _, err := repo.Claim(ctx, policy.ID, "lifetime", "alert", policy.Revision); err != nil {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
|
||||
@@ -174,7 +180,7 @@ func TestDeleteBudgetPoliciesForRemovedScopes_CascadesClaims(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
policy := createTestBudgetPolicy(t, repo, "ws-cascade-3")
|
||||
|
||||
if _, err := repo.Claim(ctx, policy.ID, "lifetime", "alert"); err != nil {
|
||||
if _, err := repo.Claim(ctx, policy.ID, "lifetime", "alert", policy.Revision); err != nil {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
|
||||
@@ -193,7 +199,7 @@ func TestUpdateBudgetPolicy_DiscardsClaims(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
policy := createTestBudgetPolicy(t, repo, "ws-update-1")
|
||||
|
||||
if _, err := repo.Claim(ctx, policy.ID, "2026-09-01T00:00:00Z", "alert"); err != nil {
|
||||
if _, err := repo.Claim(ctx, policy.ID, "2026-09-01T00:00:00Z", "alert", policy.Revision); err != nil {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
if got := countBudgetClaims(t, db, policy.ID); got != 1 {
|
||||
@@ -204,6 +210,9 @@ func TestUpdateBudgetPolicy_DiscardsClaims(t *testing.T) {
|
||||
if err := repo.UpdateBudgetPolicy(ctx, policy); err != nil {
|
||||
t.Fatalf("update budget policy: %v", err)
|
||||
}
|
||||
if policy.Revision != 2 {
|
||||
t.Fatalf("revision returned to caller = %d, want 2", policy.Revision)
|
||||
}
|
||||
if got := countBudgetClaims(t, db, policy.ID); got != 0 {
|
||||
t.Fatalf("claim count after update = %d, want 0 (AC-OFFICE-COSTS-002.8)", got)
|
||||
}
|
||||
@@ -215,6 +224,9 @@ func TestUpdateBudgetPolicy_DiscardsClaims(t *testing.T) {
|
||||
if updated.LimitSubcents != 2000 {
|
||||
t.Fatalf("limit_subcents = %d, want 2000", updated.LimitSubcents)
|
||||
}
|
||||
if updated.Revision != 2 {
|
||||
t.Fatalf("stored revision = %d, want 2", updated.Revision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListBudgetPolicies_OrdersByCreatedAtThenID(t *testing.T) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/kandev/kandev/internal/db"
|
||||
"github.com/kandev/kandev/internal/persistence"
|
||||
)
|
||||
|
||||
@@ -37,7 +38,7 @@ func (r *Repository) activateLoopLiveness() {
|
||||
{"agent_wakeup_requests", causationIDColumn},
|
||||
{"runs", causationIDColumn},
|
||||
} {
|
||||
exists, err := columnExists(r.db, probe.table, probe.column)
|
||||
exists, err := db.ColumnExists(r.db, probe.table, probe.column)
|
||||
if err != nil {
|
||||
r.logActivationWarn("loop liveness schema probe failed", err)
|
||||
return
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/kandev/kandev/internal/db/dialect"
|
||||
"github.com/kandev/kandev/internal/db"
|
||||
"github.com/kandev/kandev/internal/persistence"
|
||||
)
|
||||
|
||||
@@ -35,7 +33,7 @@ func (r *Repository) activateRunOutcome() {
|
||||
r.logActivationWarn("ensure kandev_meta table failed", err)
|
||||
return
|
||||
}
|
||||
exists, err := columnExists(r.db, "runs", "outcome")
|
||||
exists, err := db.ColumnExists(r.db, "runs", "outcome")
|
||||
if err != nil {
|
||||
r.logActivationWarn("runs.outcome schema probe failed", err)
|
||||
return
|
||||
@@ -55,44 +53,3 @@ func (r *Repository) logActivationWarn(msg string, err error) {
|
||||
r.log.Warn(msg, zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// columnExists reports whether table declares column, on either dialect.
|
||||
// SQLite has no information_schema, so it falls back to PRAGMA table_info;
|
||||
// Postgres is queried through the standard catalog view scoped to the
|
||||
// current schema so callers never see another schema's same-named table.
|
||||
func columnExists(db interface {
|
||||
DriverName() string
|
||||
Query(string, ...interface{}) (*sql.Rows, error)
|
||||
QueryRow(string, ...interface{}) *sql.Row
|
||||
Rebind(string) string
|
||||
}, table, column string) (bool, error) {
|
||||
if dialect.IsPostgres(db.DriverName()) {
|
||||
var exists bool
|
||||
err := db.QueryRow(db.Rebind(`
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = ?
|
||||
AND column_name = ?
|
||||
)
|
||||
`), table, column).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
for rows.Next() {
|
||||
var cid, notnull, pk int
|
||||
var name, ctype string
|
||||
var dflt sql.NullString
|
||||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if name == column {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, rows.Err()
|
||||
}
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@
|
||||
{
|
||||
"engine": "sqlite3",
|
||||
"file": "sqlite.sql",
|
||||
"sha256": "e1b490607af347314fce11bb3352bf91599eab4c651a1521a60142e73a3b2d5d",
|
||||
"sha256": "b7c763f6c3f1f6000e8a8f90a123ee3b114582bdd3a0aabf162eedae18cf57a3",
|
||||
"sentinels": {
|
||||
"fixture.sentinel": "v0.93.0-sqlite-sentinel"
|
||||
},
|
||||
@@ -37,7 +37,7 @@
|
||||
{
|
||||
"engine": "pgx",
|
||||
"file": "postgres.sql",
|
||||
"sha256": "f4f26d8d67610ef94a2d5cc308392be7a353efdd3e36e8469fb5ed11d774d165",
|
||||
"sha256": "8dd108b73d1daaf9cd35ee2255f06f2800f90a7101ba3337c9ee4b0951a51de6",
|
||||
"sentinels": {
|
||||
"fixture.sentinel": "v0.93.0-postgres-sentinel"
|
||||
},
|
||||
|
||||
+12
-2
@@ -455,8 +455,6 @@ CREATE TABLE office_budget_policies (
|
||||
updated_at timestamp without time zone NOT NULL
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE office_channels (
|
||||
id text NOT NULL,
|
||||
workspace_id text NOT NULL,
|
||||
@@ -2010,6 +2008,18 @@ ALTER TABLE ONLY office_budget_policies
|
||||
|
||||
|
||||
|
||||
CREATE TABLE office_budget_claims (
|
||||
policy_id text NOT NULL,
|
||||
period_key text NOT NULL,
|
||||
level text NOT NULL,
|
||||
claimed_at timestamp without time zone NOT NULL,
|
||||
CONSTRAINT office_budget_claims_pkey PRIMARY KEY (policy_id, period_key, level),
|
||||
CONSTRAINT office_budget_claims_policy_id_fkey FOREIGN KEY (policy_id)
|
||||
REFERENCES office_budget_policies(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
|
||||
ALTER TABLE ONLY office_channels
|
||||
ADD CONSTRAINT office_channels_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
+8
@@ -367,6 +367,14 @@ CREATE TABLE office_budget_policies (
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
);
|
||||
CREATE TABLE office_budget_claims (
|
||||
policy_id TEXT NOT NULL,
|
||||
period_key TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
claimed_at TIMESTAMP NOT NULL,
|
||||
PRIMARY KEY (policy_id, period_key, level),
|
||||
FOREIGN KEY (policy_id) REFERENCES office_budget_policies(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE office_channels (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL,
|
||||
|
||||
Reference in New Issue
Block a user