Repository navigation
fix(marketplace): keep each RI Marketplace listing's settlement in its own row - #1581
Conversation
…s own row purchase_history describes only an RI's latest marketplace listing, and a relist (or the claim that starts one) moves the row to the new listing on purpose (LeanerCloud#1289). A listing that sold partially and was canceled before a poll saw the sale was then never polled again, and its proceeds were lost. - Migration 000153 adds marketplace_listing_settlements, one row per AWS listing id with the purchase_history row it was recorded on, the RI id, status, listed_at, proceeds, fee, currency and timestamps. It backfills one row per listing purchase_history records a settlement for. - Trigger purchase_history_keep_superseded_listing copies a listing into the table when the row stops describing it: its listing_id changes, or a list claim stores a token over it. ON CONFLICT DO NOTHING keeps a row the poller already wrote. - ListMarketplaceListingsToPoll also returns those kept listings, under the same rules as purchase_history rows, once per listing. - SettleMarketplaceListing writes, in one transaction, the purchase_history row while it still holds the listing (as before, so its columns stay the latest listing's settlement) and then the listing's own row, created or filled only while its amounts are unset. purchase_history is locked first, the same order a relist takes, so the two cannot deadlock. - GetMarketplaceListingProceedsTotals sums an RI's proceeds and fees over its listings from the table alone, so a sale is never counted twice. Closes LeanerCloud#1494 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ettlement table too The per-listing settlement write ignored LeanerCloud#1508's rule that a row mid-claim (pending with a list attempt's token) is left alone: a poll of the listing a claim had just kept was recorded in marketplace_listing_settlements while the claim was in flight, so SettleMarketplaceListing reported applied. - SettleMarketplaceListing share-locks the purchase_history rows that hold the listing after its own UPDATE and writes no settlement row while one of them is a claim in flight. The lock makes a claim that starts meanwhile wait, so nothing is written mid-claim. The listing is settled once the claim resolves (it is then polled from the kept row) or is released. - ListMarketplaceListingsToPoll leaves such a listing out of the settlement table branch as well. - The trigger also keeps the listing when a resumed claim (a released attempt whose token was kept, back in the listing's own state) makes the row pending again, and never copies a row that is already mid-claim. Refs LeanerCloud#1494 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keeps govet's shadow check and gocritic's sloppyReassign both satisfied. Refs LeanerCloud#1494 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Caution Review failedThe pull request is closed. Finishing Touches
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked: Read the migration, trigger WHEN clause, poll UNION, and the 3-step settle tx against #1494's acceptance criteria. Gap 1 and gap 3 were already handled by merged #1508, so Body-stale:
Reviewer: rN1. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Body-stale:
Reviewer: rN2. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
|
Local verification at |
|
Merged by IAmJSD's agent at IAmJSD's request (IAmJSD is acting owner while the maintainers are away), under the bar IAmJSD set: 2 approving independent review(s) on |
Problem (gap 2 of the issue)
purchase_historydescribes only an RI's latest marketplace listing. A relist, or the claim that starts one, moves the row on to the new listing on purpose (#1289):listing_idchanges and the previous listing'slisted_at/proceeds/fee are cleared. So if listing A sold partially and was canceled, and the RI was relisted as B before a poll saw A's sale, nothing named A any more. The poller never read A again and A's proceeds were lost. #1508 fixed gap 1 and documented gap 3. This PR fixes gap 2 as the acting owner decided (D24): each listing's settlement goes in its own row.Fix
Migration 000153 (
internal/database/postgres/migrations/000153_marketplace_listing_settlements.{up,down}.sql)marketplace_listing_settlements, primary keylisting_id(AWS listing ids are unique, and each belongs to one RI). It haspurchase_history_id(FK topurchase_history.id,ON DELETE CASCADE),purchase_id,status,listed_at,proceeds_received,fee_paid(NUMERIC(12,2)),currency(defaultUSD),settled_at,created_atandupdated_at. A CHECK makes proceeds, fee and settled_at all set or all NULL. There are indexes onpurchase_idandpurchase_history_id.purchase_historycurrently has a settlement for (listing_proceeds_received IS NOT NULL). Legacy duplicate rows of one RI hold the same listing, so the backfill uses the earliest row.purchase_history_keep_superseded_listing(AFTER UPDATE OFlisting_id,listing_state,listing_client_token) copies a listing into the table, as purchase_history last recorded it, at the moment the row stops describing it. That happens whenlisting_idchanges, or when a list claim makes the row pending with a token. The claimed row keeps the oldlisting_idbut its state becomespending, so the claim is the last moment the old listing's real state is known.ON CONFLICT DO NOTHINGkeeps any row the poller already wrote.purchase_historywith no table row) and says what the down migration restores.purchase_history's columns were never touched, so they still hold each RI's latest listing settlement. Settlements of superseded listings exist only in the table and are lost on rollback.Store (
internal/config/store_postgres_marketplace_settlement.go)ListMarketplaceListingsToPollreads the current listing frompurchase_history(unchanged rules) UNION listings kept in the table that are still unsettled. The same rules apply to both: active, pending, or closed/canceled withlisted_atset. Both leave out a listing that apurchase_historyrow holds as a claim in flight (pending with a token), as fix(marketplace): poll listings recorded as pending and document where the poll runs #1508 does. Account, region and cloud account come from the referencedpurchase_historyrow. Results are deduplicated per(purchase_id, listing_id), and thepurchase_historycopy wins when both sources have a listing.SettleMarketplaceListingnow runs one transaction:purchase_historyUPDATE, unchanged. It applies only while the row still holds the listing.SELECT … FOR SHAREon thepurchase_historyrows that hold the listing. If any of them is a claim in flight, nothing more is written. This is fix(marketplace): poll listings recorded as pending and document where the poll runs #1508's mid-claim rule, now applied to the per-listing row too. The share lock makes a claim that starts meanwhile wait for this transaction, so no settlement is ever written while a row is mid-claim.INSERT … ON CONFLICT (listing_id) DO UPDATE … WHERE proceeds_received IS NULL AND status IN (active, pending, same terminal state) AND same purchase_id). Otherwise, the same guarded UPDATE runs against the row the relist kept.It returns true if either write applied. Locks are taken on
purchase_historyfirst and then on the table, the same order a relist and its trigger use, so a poll and a relist cannot deadlock.purchase_historyagain. Either way it is settled once.GetMarketplaceListingProceedsTotals(ctx, purchaseIDs)returns, for each RI and currency, the sum of proceeds and fees over its settled listings, plus counts of settled and unsettled listings. It reads the table only. It is onStoreInterfaceand has a mock.What
purchase_history's settlement columns mean now: still the latest listing's settlement, not a total. The poller's own guards depend on that meaning (listing_proceeds_received IS NULLmeans "this listing is not settled yet", andRecordMarketplaceListingActiveand the relist paths clear the columns for a new listing). Turning the columns into a running total would break those guards. Anyone who added the total to the per-listing rows would also count a sale twice. Per-RI totals therefore come from the table only.Where proceeds totals are read today: nowhere. No API handler, report or frontend code reads
listing_proceeds_received/listing_fee_paid(the frontend'shistory.tscomputes its own pre-listing estimate). So the new read path has no caller yet. It is the method the API should use when proceeds are exposed (that is gated on D23, below).Money path: no double counting. Each listing has exactly one row (the primary key). Each write fills a row only while its amounts are NULL, and the row lock serializes concurrent pollers. Totals sum the table only.
Closes #1494
Verification
Commands, run from the worktree with
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=trueand Go 1.26.9:go build ./...,go vet ./...andgo vet -tags integration ./...all pass.go test -count=1passes for./internal/config/,./internal/mocks/,./internal/api/and./internal/database/postgres/migrations/(run one package at a time).golangci-lint run --allow-parallel-runners --build-tags integration ./internal/config/ ./internal/mocks/ ./internal/api/ ./internal/database/postgres/migrations/reports 0 issues.gocyclo -over 10 -ignore "_test\.go"on the changed non-test files reports nothing.go test -tags integration -count=1 -timeout 30m ./internal/config/passes (96s, at 568c6d6 with origin/main 6e2033c merged). This includes fix(marketplace): poll listings recorded as pending and document where the poll runs #1508'sTestMarketplacePendingListingPoll_Postgres/MidClaimIsLeftAlone, which failed on the first commit of this branch in the combined-tree run.go test -tags integration -count=1 -timeout 20m -run 'TestMarketplaceListingSettlements_Postgres' ./internal/config/passes, and also passes with-race. Subtests:RelistAfterPartialSaleKeepsBothListings: A is canceled after the poller saw it, the RI is relisted as B, A is still returned by the poll, both settle, the RI total is A+B (264/36), andpurchase_historyholds B's settlement.RepollIsIdempotent: three more polls of each listing with different amounts change nothing.UnpricedSupersededListingIsPolledUntilPricedReleasedClaimCountsListingOnce: while the claim is in flight, A is not polled and a settle writes nothing. A failed relist then releases the claim, so A is both on the row and in the table. It is polled once and counted once.SettleWaitingOnClaimLeavesListingForLater: deterministic held-lock race. The poll queues behind a committing claim and writes nothing (fix(marketplace): poll listings recorded as pending and document where the poll runs #1508). Once the claim resolves to B, A is polled from the kept row and recorded once.ResumedClaimKeepsListing: an attempt released with its token kept (written with triggers off, as if from before 000153). The retry's claim keeps A, so the relist does not lose it.ConcurrentPollsSettleSupersededOnce: 8 pollers × 5 rounds, exactly one applies.ConcurrentRelistAndPollKeepProceedsOnce: 20 rounds of relist + 2 polls. Even rounds start all three at once; odd rounds start the polls after the claim commits, so both ways the listing's row can be created are exercised (the claim created it in 10–12 of 20 rounds). In every round:TotalsOfUnknownPurchasesgo test -tags integration -count=1 -timeout 20m -run 'TestMigration153_MarketplaceListingSettlements' ./internal/database/postgres/migrations/passes. It migrates topreviousMigrationVersion(t, 153), seeds settled, legacy-duplicate, unsettled and unlisted rows, then migrates up. It checks the backfill (2 rows, values, earliest duplicate), that the trigger keeps a relisted listing with its recorded state andlisted_at, and that the amounts CHECK holds. It then migrates down (table, trigger and function gone,purchase_historyvalues kept, a relist still works) and re-applies.MidClaimIsLeftAlone,ReleasedClaimCountsListingOnceandSettleWaitingOnClaimLeavesListingForLaterfail.ResumedClaimKeepsListingfail.TotalsOfUnknownPurchases(count confirmed by both reviewers at 568c6d6). For example,"ril-mls-b" map … does not contain "ril-mls-a"(the superseded listing is not polled), andround 1: exactly one poll records the sale. The pass-after results are listed above.This is DB-level integration evidence on real Postgres. No AWS call is made; the poller's AWS read is not part of these tests.
Combined-tree verification. Tested on a local, never-pushed tree:
queue/platform@19e51df6= origin/main6e2033cdplus:Linux, Go 1.26.9,
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true; Docker Postgres via testcontainers.go build ./...,go vet ./...,go vet -tags integration ./...and the fullgo test -race -short -count=1 ./....--build-tags integration.gocyclo -over 10is silent on the changed non-test files.-tags integration -count=1, one package at a time):internal/api,internal/config,internal/database/postgres/migrations,internal/purchaseandinternal/serverpass.scripts/test-git-secrets-allowlist.shpasses.Deliberately out of scope / notes for review
listed_atNULL) is still not polled, either onpurchase_historyor in the table. The table keeps it (unsettled), so a later change could poll it.purchase_historyrow.purchase_historyrow, so a sale is never counted twice.purchase_historyrow a listing references deletes that listing's row. No code path deletespurchase_historyrows today.000153_contract_canceled_spelling) also uses 000153. Whichever PR merges second has to renumber.Frontend follow-up for the UI owner
No frontend change in this PR. Once proceeds are exposed in the API (after D23 and the real-listing check above), the History view's marketplace column should show each RI's total across listings, from
GetMarketplaceListingProceedsTotals, labeled as an estimate. A per-listing breakdown could be shown as well. It should not readpurchase_history's latest-listing columns as the RI's total.Maintainer: please mirror the issue's triage labels onto this PR:
priority/p2,type/bug. The issue has notriagedlabel, so do not add that one.🤖 Generated with Claude Code