Skip to content

fix(purchases): atomic ramp-step increment via SELECT FOR UPDATE - #1109

Merged
cristim merged 2 commits into
feat/multicloud-web-frontendfrom
fix/1071-atomic-plan-progress
Jun 8, 2026
Merged

cristim merged 2 commits into
feat/multicloud-web-frontendfrom
fix/1071-atomic-plan-progress

Conversation

@cristim

@cristim cristim commented Jun 7, 2026 •

Copy link
Copy Markdown
Member

Summary

Closes #1071

updatePlanProgress performed a non-atomic read-modify-write on PurchasePlan.CurrentStep: two overlapping Lambda invocations could both read the same step value, both increment it in memory, and both write CurrentStep+1, skipping a step or landing the plan at the wrong index.

  • Added IncrementPlanCurrentStep(ctx, planID string) error to StoreInterface.
  • Implemented it in store_postgres.go using SELECT ... FOR UPDATE inside WithTx. The lock prevents concurrent callers from reading stale step state; only the winner of the lock reads and writes the row.
  • Extracted scanPurchasePlanRow(pgx.Row) as a shared helper so GetPurchasePlan and IncrementPlanCurrentStep use the same scan logic.
  • Simplified updatePlanProgress in execution.go to a single delegate call; all step-advance logic now lives in the store method where the lock is held.
  • Updated all mock implementations across api, analytics, purchase, scheduler, and server test packages.

Test plan

  • go test ./internal/config/... - passes (store-level logic)
  • go test ./internal/purchase/... - all TestManager_UpdatePlanProgress* and integration tests pass
  • go test ./internal/api/... ./internal/analytics/... ./internal/scheduler/... ./internal/server/scheduledauth/... - pass
  • go test ./... - no new failures (two pre-existing failures in internal/auth and internal/server are unrelated)

Generated by Claude Code

Summary by CodeRabbit

  • Tests

    • Updated test infrastructure across multiple modules to reflect improved plan progression handling
  • Refactor

    • Refactored plan progression logic to use atomic database operations, ensuring purchase plans advance reliably during concurrent operations

updatePlanProgress performed a read-modify-write on PurchasePlan.CurrentStep
without locking: concurrent Lambda invocations could both read the same step,
both increment it in memory, and both write CurrentStep+1 -- skipping a step or
converging on the wrong index.

Fix by adding IncrementPlanCurrentStep to StoreInterface and implementing it
with a SELECT FOR UPDATE inside WithTx. The store locks the row before reading
so only one caller at a time can advance the step counter. updatePlanProgress
is simplified to a single delegate call; the step-advance logic now lives
entirely inside the store method.

All mock implementations updated across the api, analytics, purchase,
scheduler, and server test packages.

Closes #1071
@coderabbitai

coderabbitai Bot commented Jun 7, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@cristim, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 23 minutes and 37 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9ce58441-de77-4d1e-bf2c-0462e6b9bdfb

📥 Commits

Reviewing files that changed from the base of the PR and between 276404a and 79d43d8.

📒 Files selected for processing (15)
  • internal/analytics/collector_test.go
  • internal/api/mocks_test.go
  • internal/config/interfaces.go
  • internal/config/store_postgres.go
  • internal/config/store_postgres_increment_step_test.go
  • internal/mocks/stores.go
  • internal/purchase/approvals_test.go
  • internal/purchase/coverage_extra_test.go
  • internal/purchase/execution.go
  • internal/purchase/execution_test.go
  • internal/purchase/manager_test.go
  • internal/purchase/mocks_test.go
  • internal/purchase/money_path_regression_test.go
  • internal/scheduler/scheduler_test.go
  • internal/server/test_helpers_test.go
📝 Walkthrough

Walkthrough

This PR fixes a non-atomic read-modify-write race in plan progress tracking (issue #1071) by introducing a new IncrementPlanCurrentStep store method that uses FOR UPDATE locking. The manager's updatePlanProgress is simplified to delegate to this atomic operation, and all test mocks and expectations are updated to reflect the new store responsibility.

Changes

Plan Progress Atomicity via Locked Increment

Layer / File(s) Summary
Store interface and PostgreSQL implementation
internal/config/interfaces.go, internal/config/store_postgres.go
StoreInterface gains IncrementPlanCurrentStep(ctx, planID) error to atomically advance a plan's ramp step using FOR UPDATE row locking. Refactored scanPurchasePlanRow helper centralizes JSON unmarshalling and nullable timestamp handling; GetPurchasePlan and new IncrementPlanCurrentStep both use the shared helper. Step progression, NextExecutionDate/LastExecutionDate updates, and plan persistence now live in the store.
Manager simplification to delegate progress updates
internal/purchase/execution.go
updatePlanProgress now short-circuits on direct-execute (planID == "") and delegates all ramp advancement to m.config.IncrementPlanCurrentStep(ctx, planID), removing prior fetch/mutate/persist logic.
Manager unit tests for plan progress
internal/purchase/execution_test.go
TestManager_UpdatePlanProgress* test cases updated to mock and assert IncrementPlanCurrentStep calls instead of constructing ramp schedules and mocking GetPurchasePlan/UpdatePurchasePlan.
Integration tests for scheduled and recovered purchases
internal/purchase/manager_test.go
ProcessScheduledPurchases and RecoverStrandedApprovals integration tests updated to expect single GetPurchasePlan calls and IncrementPlanCurrentStep for ramp persistence instead of prior UpdatePurchasePlan expectations.
Test mock implementations of IncrementPlanCurrentStep
internal/analytics/collector_test.go, internal/api/mocks_test.go, internal/mocks/stores.go, internal/purchase/mocks_test.go, internal/scheduler/scheduler_test.go, internal/server/test_helpers_test.go
All test doubles now implement IncrementPlanCurrentStep using consistent patterns: testify-based mocks delegate to m.Called(ctx, planID) and return the configured error; simple test doubles return nil.
Approval and execution-specific test paths
internal/purchase/approvals_test.go, internal/purchase/coverage_extra_test.go, internal/purchase/money_path_regression_test.go
Approval flow and execution tests updated to mock IncrementPlanCurrentStep instead of UpdatePurchasePlan for their plan progress side effects.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Possibly related issues

  • LeanerCloud/CUDly#1013: The refactoring moves ramp-step advancement into the new atomic store method, directly addressing the non-atomic plan-progress update and missing row-claiming concerns raised in the linked issue.

Possibly related PRs

  • LeanerCloud/CUDly#635: Both PRs affect the internal/purchase scheduling and recovery flow; this PR updates integration test expectations for ProcessScheduledPurchases and RecoverStrandedApprovals to use the new atomic increment, while that PR adds the RecoverStrandedApprovals sweep itself.

Suggested labels

priority/p1, severity/high, impact/many, effort/m

Poem

🐰 A race condition lurked in steps untold,
Where reads and writes danced, brave and bold,
Until the lock came—FOR UPDATE strong,
Now plans advance atomic, safe and long! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding atomic ramp-step increment using SELECT FOR UPDATE to fix the non-atomic read-modify-write issue.
Linked Issues check ✅ Passed The PR successfully implements the core fix from issue #1071 by adding IncrementPlanCurrentStep with SELECT FOR UPDATE locking to prevent concurrent race conditions on plan step advancement.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing atomic ramp-step increment and updating related test mocks; no unrelated refactoring or feature additions detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/1071-atomic-plan-progress

Comment @coderabbitai help to get the list of available commands and usage tips.

@cristim cristim added triaged Item has been triaged priority/p2 Backlog-worthy severity/medium Moderate harm urgency/this-sprint Within the current sprint impact/few Limited audience effort/s Hours type/bug Defect labels Jun 7, 2026 — with Claude

cristim commented Jun 7, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review


Generated by Claude Code

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Get PR details and diff
gh pr view 1109 --repo LeanerCloud/CUDly
echo "=== FILES CHANGED ==="
gh pr diff 1109 --repo LeanerCloud/CUDly

Length of output: 24714


🏁 Script executed:

#!/bin/bash
# Check WithTx implementation
rg -n "func.*WithTx" --type go -A 20

echo "=== RampSchedule IsComplete ==="
rg -n "IsComplete" --type go -A 5 -B 2

echo "=== UpdatePurchasePlanTx implementation ==="
rg -n "func.*UpdatePurchasePlanTx" --type go -A 20

echo "=== Store-level tests for IncrementPlanCurrentStep ==="
rg -rn "IncrementPlanCurrentStep" --type go

echo "=== pgxmock tests ==="
fd "store_postgres_test" --type f
fd "store.*test" --type f -e go | head -20

Length of output: 36277


🏁 Script executed:

#!/bin/bash
echo "=== pgxmock test file for IncrementPlanCurrentStep ==="
cat internal/config/store_postgres_pgxmock_test.go

echo "=== Check if any existing store test covers IncrementPlanCurrentStep ==="
grep -n "IncrementPlanCurrentStep\|CurrentStep\|increment\|ramp_step\|ramp.*step" internal/config/store_postgres_pgxmock_test.go 2>/dev/null || echo "NOT FOUND in pgxmock test"

grep -rn "IncrementPlanCurrentStep" internal/config/ 2>/dev/null

echo "=== purchasePlanSelectCols usage ==="
grep -n "purchasePlanSelectCols" internal/config/store_postgres.go

echo "=== scanPurchasePlanRow - check error context lost ==="
grep -n "scanPurchasePlanRow\|failed to get\|ErrNotFound" internal/config/store_postgres.go -A3 -B1

echo "=== current_step column check (is it separate or in JSON?) ==="
grep -rn "current_step" --type go | head -20

Length of output: 80503



@cristim — the concurrency fix is well-structured: SELECT FOR UPDATE inside WithTx correctly holds the row lock through the read-modify-write, UpdatePurchasePlanTx reuses the same transaction, and the Twice()→Once() fixes in manager_test.go correctly reflect the reduced call count. A few things worth looking at:


Bug: updated_at is not refreshed by IncrementPlanCurrentStep

UpdatePurchasePlanTx only sets UpdatedAt when it is zero:

// store_postgres.go
func (s *PostgresStore) UpdatePurchasePlanTx(...) error {
    if plan.UpdatedAt.IsZero() {
        plan.UpdatedAt = time.Now()
    }
    ...
}

IncrementPlanCurrentStep reads the plan from the DB (so plan.UpdatedAt is already set to its current DB value), then calls UpdatePurchasePlanTx without resetting it. The result is that the updated_at column is written back with the old timestamp—the row looks untouched even though current_step, next_execution_date, and last_execution_date all changed.

Fix:

now := time.Now()
plan.LastExecutionDate = &now
plan.UpdatedAt = now   // ← add this so updated_at reflects the step increment

return s.UpdatePurchasePlanTx(ctx, tx, plan)

Missing pgxmock store-level tests

execution_test.go has the comment:

"The step-advance and completion logic now live inside IncrementPlanCurrentStep in the store, tested at the store layer with pgxmock."

However, store_postgres_pgxmock_test.go contains no test for IncrementPlanCurrentStep. The scenarios that previously lived in TestManager_UpdatePlanProgress (step advance, ramp-complete path, NextExecutionDate nil-out) now live exclusively in the store method and are unexercised at the store level. Suggested cases to add:

Test What it covers
TestPGXMock_IncrementPlanCurrentStep_AdvancesStep SELECT FOR UPDATE + CurrentStep++ + commit
TestPGXMock_IncrementPlanCurrentStep_CompletedRamp CurrentStep already at TotalSteps, NextExecutionDate set to nil
TestPGXMock_IncrementPlanCurrentStep_PlanNotFound pgx.ErrNoRows → nil (not an error)
TestPGXMock_IncrementPlanCurrentStep_DBError query error → error propagated, rollback

Minor: planID absent from lock-failure error message

return fmt.Errorf("failed to lock purchase plan: %w", err)

Including planID here would make log triage faster:

return fmt.Errorf("failed to lock purchase plan %s: %w", planID, err)

🧠 Learnings used
Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-05-05T15:44:54.463Z
Learning: In the LeanerCloud/CUDly repository, when a CodeRabbit review flags something as "outside-diff" that was already implemented in an earlier commit of the same PR, it is likely a stale-snapshot artifact where the review diff was generated against a pre-fix view. The inline comment convention `(CR pass on PR #<number> Finding N)` is used by the maintainer (cristim) to mark lines that were changed in direct response to a prior CodeRabbit review pass.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-06T06:43:53.792Z
Learning: In LeanerCloud/CUDly PR `#995` (issue `#950` ownership gate), `POST /api/plans/{id}/purchases` (`createPurchaseExecutionsTx`) must stamp `created_by_user_id` via `resolveCreatorUserID(session)` at creation time. Without this, every scheduled purchase created via the Plans-page "Create planned purchases" action ships a NULL creator, which the ownership gate treats as a legacy row and denies management to all non-`update-any` users — including the creator themselves. The admin API key path must set `CreatedByUserID` to nil (not the key identity). Regressions: `TestHandler_createPlannedPurchases_StampsCreator` and `TestHandler_createPlannedPurchases_AdminAPIKeyCreatorIsNil`. Fixed in commit 9ef1b4352.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-06T06:43:53.792Z
Learning: In LeanerCloud/CUDly PR `#995` (issue `#950` ownership gate), the Dashboard "Upcoming purchases" widget (`dashboard.ts`) has two Cancel entry points (card-level and modal) that both call `api.deletePlannedPurchase`. The `UpcomingPurchase` response DTO must include `CreatedByUserID *string` (omitempty, mirroring `PlannedPurchase`) so the frontend can gate these buttons via a `canCancelUpcomingPurchase` helper that mirrors `canManageScheduledPurchase` on the Plans page. Without this, the backend correctly 403s non-owners but the UI has no gate and the UX is broken. Regressions: `frontend/src/__tests__/dashboard-ownership-950.test.ts` (6 tests) and `TestHandler_getUpcomingPurchases_PropagatesCreatedByUserID`. Fixed in commit 94326f6b9.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-07T17:45:38.069Z
Learning: In LeanerCloud/CUDly `providers/gcp/services/cloudsql/client.go`, `getSQLPricing` had the same per-hour/term-total unit mismatch as memorystore and cloudstorage: `commitmentPrice` (per-hour SKU rate) was passed directly to savings math that expected a term total, producing ~99.99% savings, and `HourlyRate` was set to `per-hour / hoursInTerm` (near-zero). Fixed in PR `#1047` commit 20590c6b4 (issue `#1078` folded in): `commitmentPriceTerm := commitmentPrice * hoursInTerm`; `HourlyRate = commitmentPrice` (raw per-hour). `convertGCPRecommendation` also hardcoded `rec.Term = "1yr"`, ignoring `params.Term`; fixed with same defaulting pattern as memorystore/cloudstorage. Regression tests: `TestGetSQLPricing_CommitmentPriceIsTermTotal` and `TestCloudSQLConvertGCPRecommendation_PropagatesTermFromParams`. PaymentOption was already correct (`if paymentOption == "" { paymentOption = "monthly" }`).

@cristim

cristim commented Jun 8, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jun 8, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.

cristim commented Jun 8, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review


Generated by Claude Code

@coderabbitai

coderabbitai Bot commented Jun 8, 2026 •

Copy link
Copy Markdown
Contributor

I'll kick off a fresh review of the PR right away.


🧠 Learnings used
Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-05-05T15:44:54.463Z
Learning: In the LeanerCloud/CUDly repository, when a CodeRabbit review flags something as "outside-diff" that was already implemented in an earlier commit of the same PR, it is likely a stale-snapshot artifact where the review diff was generated against a pre-fix view. The inline comment convention `(CR pass on PR #<number> Finding N)` is used by the maintainer (cristim) to mark lines that were changed in direct response to a prior CodeRabbit review pass.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-07T17:45:38.069Z
Learning: In LeanerCloud/CUDly `providers/gcp/services/cloudsql/client.go`, `getSQLPricing` had the same per-hour/term-total unit mismatch as memorystore and cloudstorage: `commitmentPrice` (per-hour SKU rate) was passed directly to savings math that expected a term total, producing ~99.99% savings, and `HourlyRate` was set to `per-hour / hoursInTerm` (near-zero). Fixed in PR `#1047` commit 20590c6b4 (issue `#1078` folded in): `commitmentPriceTerm := commitmentPrice * hoursInTerm`; `HourlyRate = commitmentPrice` (raw per-hour). `convertGCPRecommendation` also hardcoded `rec.Term = "1yr"`, ignoring `params.Term`; fixed with same defaulting pattern as memorystore/cloudstorage. Regression tests: `TestGetSQLPricing_CommitmentPriceIsTermTotal` and `TestCloudSQLConvertGCPRecommendation_PropagatesTermFromParams`. PaymentOption was already correct (`if paymentOption == "" { paymentOption = "monthly" }`).

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-06T06:43:53.792Z
Learning: In LeanerCloud/CUDly PR `#995` (issue `#950` ownership gate), `POST /api/plans/{id}/purchases` (`createPurchaseExecutionsTx`) must stamp `created_by_user_id` via `resolveCreatorUserID(session)` at creation time. Without this, every scheduled purchase created via the Plans-page "Create planned purchases" action ships a NULL creator, which the ownership gate treats as a legacy row and denies management to all non-`update-any` users — including the creator themselves. The admin API key path must set `CreatedByUserID` to nil (not the key identity). Regressions: `TestHandler_createPlannedPurchases_StampsCreator` and `TestHandler_createPlannedPurchases_AdminAPIKeyCreatorIsNil`. Fixed in commit 9ef1b4352.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-06T06:43:53.792Z
Learning: In LeanerCloud/CUDly PR `#995` (issue `#950` ownership gate), the Dashboard "Upcoming purchases" widget (`dashboard.ts`) has two Cancel entry points (card-level and modal) that both call `api.deletePlannedPurchase`. The `UpcomingPurchase` response DTO must include `CreatedByUserID *string` (omitempty, mirroring `PlannedPurchase`) so the frontend can gate these buttons via a `canCancelUpcomingPurchase` helper that mirrors `canManageScheduledPurchase` on the Plans page. Without this, the backend correctly 403s non-owners but the UI has no gate and the UX is broken. Regressions: `frontend/src/__tests__/dashboard-ownership-950.test.ts` (6 tests) and `TestHandler_getUpcomingPurchases_PropagatesCreatedByUserID`. Fixed in commit 94326f6b9.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-07T17:38:19.433Z
Learning: In LeanerCloud/CUDly GCP service converters (`convertGCPRecommendation` in memorystore/client.go and cloudstorage/client.go), `rec.Term` must be derived from `params.Term` with a `"1yr"` default — not hardcoded to `"1yr"`. Without this, 3-year callers always emit 1-year commitments even though the downstream `termYears` derivation from `rec.Term` is correct. Fixed in PR `#1047` commit c6280c390 (F2 for memorystore, F4 for cloudstorage). Regression tests: `TestConvertGCPRecommendation_PropagatesTermFromParams` and `TestCloudStorageConvertGCPRecommendation_PropagatesTermFromParams`.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-07T17:38:19.433Z
Learning: In LeanerCloud/CUDly `providers/gcp/services/computeengine/client.go`, `convertGCPRecommendation` must force `paymentOption = "monthly"` unconditionally (logging a WARN when a non-monthly value such as "upfront", "all-upfront", or "partial-upfront" is supplied) because GCP CUDs are monthly-only and any non-monthly value passed through would be a silent misconfiguration. Fixed in PR `#1047` commit c6280c390 (F1). Regression test: `TestConvertGCPRecommendation_NonMonthlyPaymentOptionForcedToMonthly`.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-06T06:41:06.841Z
Learning: In LeanerCloud/CUDly PR `#804`, `CancelScheduledExecutionAtomic` (`WHERE status = 'scheduled'`) is distinct from `CancelExecutionAtomic` (`WHERE status IN ('pending','notified')`). The pre-fire-delay revoke path uses `CancelScheduledExecutionAtomic`; the 410 "window closed" / 409 "not pending" race outcomes must remain disambiguated. Using `CancelExecutionAtomic` for scheduled-execution revocation is a critical bug (the execution fires and the user is charged while the UI shows "cancelled").

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-07T18:13:37.445Z
Learning: In LeanerCloud/CUDly `providers/gcp/services/computeengine/client.go`, `convertGCPRecommendation` no longer hardcodes `Term = "1yr"` (fixed in PR `#1047` commit 8f9a787, H-3 audit finding). It propagates `params.Term` with a `"1yr"` default, validates via `termPlan`, and returns nil on unknown term. This is stricter than memorystore/cloudstorage/cloudsql (which default and continue): computeengine returns nil on unknown term because it is on the purchase path. Regression tests: `TestConvertGCPRecommendation_PropagatesParamsTerm`, `TestConvertGCPRecommendation_RejectsUnknownTerm`.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-07T18:28:28.139Z
Learning: In LeanerCloud/CUDly GCP service clients (computeengine, cloudsql, memorystore, cloudstorage), `GetRecommendations` filters out non-ACTIVE recommendations using the typed `recommenderpb.RecommendationStateInfo_ACTIVE` enum (CLAIMED/SUCCEEDED/FAILED/DISMISSED are skipped). State is accessed nil-safely via the proto getter. Fixed in PR `#1047` commit 95a390f (H-1 finding). Regression tests: `TestGetRecommendations_FiltersNonActiveStates` (5-rec mock, only ACTIVE survives) and `TestGetRecommendations_ActiveRecIncluded`. Existing mock tests in all four services had StateInfo added to satisfy the new filter.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-06T06:41:06.841Z
Learning: In LeanerCloud/CUDly PR `#804`, `canRevokeCompletedRow` in `frontend/src/history.ts` must check `canAccess` (not just `getCurrentUser()` truthiness) to align with peer predicates `canCancelPendingRow`, `canApprovePendingRow`, and `canRetryFailedRow`. `revoke-own` and `revoke-any` were added to the closed `Action` union to make future UX-vs-RBAC drift a compile error.
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
internal/config/store_postgres.go (1)

514-514: 💤 Low value

Consider including planID in the lock-failure error message.

Including the plan ID in the error (e.g., "failed to lock purchase plan %s: %w", planID, err) aids triage when multiple plans are processed concurrently.

🔍 Proposed change
-			return fmt.Errorf("failed to lock purchase plan: %w", err)
+			return fmt.Errorf("failed to lock purchase plan %s: %w", planID, err)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/config/store_postgres.go` at line 514, The error returned from the
lock operation currently uses fmt.Errorf("failed to lock purchase plan: %w",
err) without the plan identifier; update the fmt.Errorf call to include the
planID (e.g., fmt.Errorf("failed to lock purchase plan %s: %w", planID, err) or
use %v if planID is not a string) so logs show which plan failed — change the
fmt.Errorf expression at the lock-failure return site (the line that currently
returns fmt.Errorf("failed to lock purchase plan: %w", err)) to include planID
while preserving the %w error wrapping.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/config/store_postgres.go`:
- Around line 528-531: The plan's UpdatedAt must be refreshed before persisting:
after setting plan.LastExecutionDate (the block that sets now := time.Now() and
plan.LastExecutionDate = &now), also set plan.UpdatedAt = &now (or otherwise
assign time.Now()) so UpdatePurchasePlanTx receives a plan with a current
UpdatedAt; UpdatePurchasePlanTx only assigns UpdatedAt when it's zero, so
updating plan.UpdatedAt here (using the same now value) ensures the DB row is
written with a fresh updated_at timestamp.

---

Nitpick comments:
In `@internal/config/store_postgres.go`:
- Line 514: The error returned from the lock operation currently uses
fmt.Errorf("failed to lock purchase plan: %w", err) without the plan identifier;
update the fmt.Errorf call to include the planID (e.g., fmt.Errorf("failed to
lock purchase plan %s: %w", planID, err) or use %v if planID is not a string) so
logs show which plan failed — change the fmt.Errorf expression at the
lock-failure return site (the line that currently returns fmt.Errorf("failed to
lock purchase plan: %w", err)) to include planID while preserving the %w error
wrapping.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5b62ffff-fa74-4c84-bf83-0ac2e72613b9

📥 Commits

Reviewing files that changed from the base of the PR and between 276404a and 959b4ac.

📒 Files selected for processing (14)
  • internal/analytics/collector_test.go
  • internal/api/mocks_test.go
  • internal/config/interfaces.go
  • internal/config/store_postgres.go
  • internal/mocks/stores.go
  • internal/purchase/approvals_test.go
  • internal/purchase/coverage_extra_test.go
  • internal/purchase/execution.go
  • internal/purchase/execution_test.go
  • internal/purchase/manager_test.go
  • internal/purchase/mocks_test.go
  • internal/purchase/money_path_regression_test.go
  • internal/scheduler/scheduler_test.go
  • internal/server/test_helpers_test.go

Comment thread internal/config/store_postgres.go
…c ramp step

Address CodeRabbit findings on the atomic ramp-step increment (#1071):

- Refresh plan.UpdatedAt inside IncrementPlanCurrentStep before persisting.
  The plan is read from the DB with its prior UpdatedAt, so UpdatePurchasePlanTx's
  zero-value guard would otherwise write a stale updated_at on every increment.
- Include planID in the lock-failure error to aid triage under concurrency.
- Add pgxmock regression tests covering the SELECT FOR UPDATE lock path,
  step advance + persisted updated_at, completed-ramp next_execution_date
  clearing, plan-deleted-mid-race tolerance, and lock-error rollback. The
  FOR UPDATE assertion fails if the row lock is dropped; the updated_at
  assertion fails without the refresh above.
@cristim

cristim commented Jun 8, 2026

Copy link
Copy Markdown
Member Author

CR round 1 addressed

Both findings from the full review were addressed in 79d43d818:

  1. store_postgres.go 528-531 (inline) — stale updated_at on increment. Valid. The plan is read from the DB with its prior UpdatedAt, and UpdatePurchasePlanTx only stamps UpdatedAt when zero, so every increment would persist a stale updated_at. Fixed by setting plan.UpdatedAt = now before the UpdatePurchasePlanTx call (value, not pointer — the field is time.Time).

  2. store_postgres.go 514 (nitpick) — lock-failure error lacks planID. Applied: failed to lock purchase plan %s: %w, preserving %w wrapping.

Regression coverage added

The atomic SELECT FOR UPDATE path had no store-layer test. Added internal/config/store_postgres_increment_step_test.go with four pgxmock cases:

  • LocksAndAdvances — asserts the SELECT carries FOR UPDATE, CurrentStep advances 1 -> 2 in the persisted row, and updated_at is refreshed past a seeded stale value.
  • CompletedRampClearsNextDate — completed ramp keeps the step and clears next_execution_date.
  • PlanDeletedMidRace — a plan deleted between execution and progress update returns nil (no spurious error).
  • LockErrorRollsBack — lock error rolls back the tx.

Both fixes were verified load-bearing: removing FOR UPDATE or the updated_at refresh from the store makes the relevant tests fail; both pass with the fix in place.

Verification: go build ./..., go vet ./internal/config/ ./internal/purchase/, go test ./internal/config/ ./internal/purchase/ all pass (761 tests). DB paths covered with pgxmock (no live Postgres).

@cristim

cristim commented Jun 8, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jun 8, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.

@cristim
cristim merged commit ebba640 into feat/multicloud-web-frontend Jun 8, 2026
4 checks passed
@cristim
cristim deleted the fix/1071-atomic-plan-progress branch June 8, 2026 03:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/few Limited audience priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/bug Defect urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants