Skip to content

fix(history): sort future pending ramp steps after due rows in the actionable fetch - #1038

Merged
cristim merged 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-680
Oct 17, 2026
Merged

cristim merged 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-680

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 16, 2026

Copy link
Copy Markdown
Collaborator

Root cause

GetExecutionsByStatuses (internal/config/store_postgres.go) ordered by scheduled_date DESC with the MaxListLimit (1000) clamp. Plan creation (createPurchaseExecutionsTx) writes every ramp step up front as a pending row dated in the future, up to 52 per plan. About 20 plans therefore fill the actionable fetch (#684) with steps months ahead, and a pending approval due today falls past the cap. truncated was set, but the row was lost.

Fix

Only the ORDER BY changes:

ORDER BY (status = 'pending' AND scheduled_date > now()) ASC,
         CASE WHEN status = 'pending' AND scheduled_date > now() THEN scheduled_date END ASC,
         scheduled_date DESC
  • Before: all rows scheduled_date DESC, so the farthest-future ramp steps come first and fill the cap.
  • After: every row that is not a future pending step comes first, still scheduled_date DESC (due and overdue pending rows, notified, scheduled, approved, running, paused, whatever their date). Future pending steps come after them, soonest first, so when the cap is hit it drops the farthest steps.
  • Terminal statuses never match the pending predicate, so the terminal class keeps its order (pinned by a test that includes a canceled step with a future date).
  • notified rows with a future date stay in the first group because their approval link is already out. Only not-yet-due pending steps go to the back.

MockConfigStore.GetExecutionsByStatuses (internal/mocks/stores.go) mirrors the new order so handler tests match the real store. I updated the interface and method docs.

Cap and truncated: unchanged. The actionable class is still clamped at MaxListLimit, and truncated still fires when the fetch returns 1000 rows. The handler has no cursor or paging today (#674), so there are no cursor semantics to keep. A future keyset cursor would have to use the same three-part sort key.

Migration / index

No migration. The tier expression can't be served by an index order, so Postgres sorts the actionable rows (status-filtered) with a top-N heapsort bounded by the LIMIT. That set is bounded by plans × ramp steps plus in-flight rows (thousands), not by terminal history volume. I didn't think an expression index was worth a migration for that.

UI impact

There is no visible change to ordering: the History handler re-sorts the merged list by timestamp. Only which rows survive the cap changes. A due approval now always shows. Once more than 1000 actionable rows exist, the dropped rows are the farthest-future ramp steps, where before they were the most recent due ones. The truncation banner behaves as before. No frontend change.

Closes #680

Verification (local, macOS; tests run with AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null)

  • Real Postgres (testcontainers), TestIntegration_GetExecutionsByStatuses_DueRowSurvivesFutureRampSteps: 20 real plans × 52 future pending steps (1040), plus a pending row due an hour ago, an overdue one, a future notified row and a near scheduled row. The result has 1000 rows (the cap is still hit, so truncated holds). The four actionable rows come first in scheduled_date DESC, then future pending steps ascending. It FAILS on main (the head was all far-future steps) and passes with the fix.
  • TestIntegration_GetExecutionsByStatuses_TerminalOrderUnchanged: failed, expired and a canceled step with a future date stay newest first. The existing RowClasses test passes unchanged.
  • Handler, TestHandler_getHistory_DueApprovalNotCrowdedOutByFutureRampSteps (through getHistory with the store-mirroring mock): the due row is returned, the soonest step is kept, the farthest is dropped, there are 1000 purchases, and truncated: true. It FAILS on main and passes now.
  • pgxmock: a new SQL-shape test, and the clean-completed test regex loosened on ORDER BY.
  • Mutation probes (each one makes the named test fail): dropping the tier term, sorting future steps DESC, adding notified to the back tier (integration test); adding canceled to the tier predicate (terminal test); turning off the tier in the mock (handler test).
  • go build ./..., go vet ./... and go vet -tags integration ./... are clean. golangci-lint v2.10.1 ./... reports 0 issues, with and without --build-tags integration. gocyclo -over 10 on the changed non-test files is clean.
  • Unit tests pass: internal/config, internal/api, internal/mocks, internal/analytics, internal/server, internal/purchase. Integration tests pass: the full internal/config package (-tags integration) and internal/purchase -run RampStep.
  • git-secrets: in a fresh temp repo with isolated HOME and --register-aws, the only hit is an existing line on main (store_postgres_pgxmock_test.go:1129, a comment with a placeholder account id, authored in aed37fa). This PR adds no hits. scripts/test-git-secrets-allowlist.sh reports 42 PASS.
  • Not verified: a real deployment or real accounts, or behavior under a non-UTC DB session (now() and timestamptz compare absolutely, so the timezone shouldn't matter).

Out of scope

🤖 Generated with Claude Code

…tionable fetch

GetExecutionsByStatuses ordered by scheduled_date DESC with a
MaxListLimit (1000) clamp. Plan creation writes up to 52 pending ramp
steps per plan, all dated in the future, so about 20 plans fill the cap
with steps months ahead and a pending approval due today never comes
back (truncated was set, but the row was lost).

Pending rows with a future scheduled_date now sort after every other
row, soonest first among themselves, so the cap drops the farthest
steps. Every other row keeps scheduled_date DESC; terminal statuses
never match the predicate, so the terminal class is unchanged. The
History handler re-sorts the merged list by timestamp, so only which
rows survive the cap changes. The shared mock mirrors the new order.

Closes LeanerCloud#680

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 6 billable files and costs up to $1.50.

Or wait 59 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: LeanerCloud/cloud-commitments-platform/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: f2b5b660-467d-42a0-9236-2010dd11a06b
📥 Commits

Reviewing files that changed from the base of the PR and between 70a6457 and 44d06e5.

📒 Files selected for processing: 6
  • internal/api/handler_history_actionable_starvation_test.go
  • internal/config/interfaces.go
  • internal/config/store_postgres.go
  • internal/config/store_postgres_executions_by_statuses_integration_test.go
  • internal/config/store_postgres_pgxmock_test.go
  • internal/mocks/stores.go
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@IAmJSD IAmJSD added priority/p2 Backlog-worthy severity/medium Moderate harm urgency/this-quarter Within the quarter impact/few Limited audience effort/s Hours type/bug Defect triaged Item has been triaged labels Oct 16, 2026
@cristim

cristim commented Oct 16, 2026

Copy link
Copy Markdown
Member

Independent review verdict: CLEAN at 44d06e5

Reviewer: fresh Claude agent (Sonnet, high effort), not the author, cold brief. The PR body was treated as a claim. Coordinator: claude-cc-reviewer.

Blocking findings: none.

New ORDER BY (internal/config/store_postgres.go:1519-1521):
(status='pending' AND scheduled_date > now()) ASC, CASE WHEN status='pending' AND scheduled_date > now() THEN scheduled_date END ASC, scheduled_date DESC, with LIMIT $2 (clamped to 1000).

  • scheduled_date is TIMESTAMPTZ NOT NULL (000001:79), so there is no NULL case. now() against timestamptz is absolute, so the DB timezone doesn't matter.
  • The only production caller is internal/api/handler_history.go:218 (fetchExecutionsByClass). The mock at internal/mocks/stores.go:1809 mirrors the order.
  • There is no cursor or offset, so pagination is unaffected.

Notes (non-blocking):

  1. There is no id tiebreak, so rows with equal scheduled_date come back in nondeterministic order. That is harmless today (no paging, and the handler re-sorts), but a future keyset cursor (feat(api): page the History view past the first limit rows (offset, has_more) #674) will need it.
  2. The CASE sort can't use an index. The status-filtered set is bounded and the top-N heapsort is fine, as the body says.
  3. The handler starvation test runs against the mock. The SQL itself is covered by the integration test.

What was attacked and how:

  • go vet ./internal/... is clean. go test -race -count=1 ./internal/config/... ./internal/api/... passes 4383.
  • -tags integration -v -run GetExecutionsByStatuses ./internal/config/ against real Postgres: 0 SKIP. RowClasses, DueRowSurvivesFutureRampSteps (1040 future pending rows plus due rows) and TerminalOrderUnchanged all pass.
  • Fail-before: with store_postgres.go reverted to main, the integration starvation test and the pgxmock sort-shape test fail.
  • Mutation probe: dropping the tier and CASE (leaving only scheduled_date DESC) fails the same two tests.

Gate:

@cristim cristim closed this Oct 16, 2026
@cristim cristim reopened this Oct 16, 2026
@cristim

cristim commented Oct 16, 2026

Copy link
Copy Markdown
Member

Gate record for 44d06e5 (origin/main CLAUDE.md "Review gate"):

Marking ready (owner-authorized) and merging at 44d06e5 with --match-head-commit.

@cristim
cristim marked this pull request as ready for review October 16, 2026 23:32
@cristim cristim closed this Oct 16, 2026
@cristim cristim reopened this Oct 16, 2026
@cristim

cristim commented Oct 17, 2026

Copy link
Copy Markdown
Member

Gate record for 44d06e5 (origin/main CLAUDE.md "Review gate"):

Merging at 44d06e5 with --match-head-commit.

@cristim
cristim merged commit af374cc into LeanerCloud:main Oct 17, 2026
69 checks passed
cristim pushed a commit that referenced this pull request Oct 20, 2026
POST /api/auth/forgot-password only had the per-email bucket, so one
client could walk any number of addresses, one request each, with no
total volume cap. Add a forgot_password_ip bucket (10 per 15 minutes per
IP, the same budget as reset_password) checked before the body is
parsed. It keys on the request's SourceIP like every other per-IP
bucket (rightmost, proxy-appended X-Forwarded-For entry on the HTTP
server) and fails open like the per-email bucket (02-M1).

The comment/config mismatch from the issue was already reconciled to
10 per 5 minutes in #1038; the handler comment now names both buckets.

Closes #13

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(history): future-dated ramp steps can still crowd a due pending approval out of the actionable fetch

2 participants