main
11
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9f103c13ef |
Record a first lifecycle request under one lock and one read
RecordFirstLifecycleRequest took the instance lock and read the latest
generation, then called RecordLifecycleRequest, which took the lock and
read the latest generation again. It now checks its address, locks and
reads once, refuses a later generation of an instance that already
holds a request, reads the content, and passes the latest generation to
decide, the per-instance decision RecordLifecycleRequest also calls
(recordNew above the latest, answerAddress otherwise). decide takes a
*Queries and opens nothing. The person is still parsed after the
first-request check, so a malformed person on a used instance under a
later generation still answers a conflict.
A new create, an identical repeat and a create above the ceiling now run
two statements fewer: the second LockLifecycleInstance and
LatestLifecycleGeneration. The three rows of the lifecycle answers
golden ("first: a new request", "first: an identical repeat", "first:
above the ceiling on an empty instance") lose that pair; they were
edited by hand before the run and the test passed without -update, so
every other row is byte-equal. Answers, rows, errors and writes do not
change unless an instance's latest generation moves between the
parent's two reads. Inserts happen only under the instance lock, and
status and acknowledgement updates leave the generation alone, so the
one writer that can move it is DeleteUnstartedLifecycleRequest, which
takes no instance lock. If it commits between the parent's two reads, a
double submit's repeat is recorded as a new create there and answered a
conflict here, from the one read. That ordering needs a commit between
two statements of one transaction and is not probed.
docs/database-locks.md, "Lifecycle intake", said the second lock was
harmless; it now describes the single lock and read, and says the delete
of an unstarted request runs outside the instance lock. The comment
"taking it again ... is harmless" is deleted with the second lock.
How it was shown to change no output: the packages
internal/integration and internal/integrations/fedwiki/... gave the same
302 test and subtest names with the same results at the parent and here
(301 pass, 1 skip, the replay recorder, skipped on both sides); the
Temporal extraction (activities, workflows, workflow bodies, payload
types) is byte-equal; FedWiki's recorded histories replay; the sites
walkthrough, which creates a site with a double submit, passes on both
sides; the project lint prints the same on both sides; the multiset of
lifecycle.go's string literals is equal (20 formats, no literal added or
removed); the lock-key query, the single address read in heldAt, the
unchanged sameLifecycleContent and the two spec and answer types are
unchanged. 9 break records, 8 per side (one break's code moved into
decide, so it has an old and a new record), each caught by the old test
at the parent and the new test here. Cognitive
complexity (pinned golangci-lint): RecordLifecycleRequest 8 to 6,
RecordFirstLifecycleRequest 7 to 9, decide 1.
Unprobed: the race with an unstarted request's delete, for the reason
above; every database error after the first statement, because no test
makes one fail and nothing else; the *sql.Conn path, which inOwnTx
treats as a transaction as before and no caller uses.
|
||
|
|
0abbfac7ae |
Reorder a ladder's tiers through entitlements.ReorderTiers
The reorder's renumbering and the enactment of a new rank 0 on the
organizations lived in the HTTP handler. They are now
entitlements.ReorderTiers, which takes a ReorderTiersInput: the ladder, the
order, and, when the rank 0 changed on a ladder some organization type
defaults to, an Enact value naming the types, the disposition, the operator
and the readers it needs.
ReorderTiers runs the same steps in the same order: it opens BeginRuleChange
(the exclusive rendezvous, no pool lock), renumbers the ladder and commits.
With Enact set it then reads the ladder's name for the reason (the id stands
in when the read fails), composes "plan-ladder tier reorder (<ladder>)", and
for each type lists its organizations and enacts each through EnactDefault
against the new rank 0. A failed listing returns the result so far and a
PlanActError that now also carries the organization type.
ReorderPlanLadderTiers keeps the session, the form parse, the full preview,
the stale check and the disposition gate. tierReorderAnswer returns the
stale sentence, the rank-0 sentence, the toast naming the types' display
names and the failed organizations' answer; tierReorderFailure returns and
logs the sentences for a failed begin, renumbering, commit and listing. The
failures of each type's organizations are logged after the enactment instead
of after each type; the lines and their order are the same.
One departure, as for the other moved tier acts: a failed renumbering ("This
page was out of date; showing the current tier order. Try again.") and the
other failures before the commit are now answered after the act's rollback,
which releases the rendezvous before the page is read, where the handler
rendered them before its deferred rollback. Only reads move: no write, no
sentence and no lock changes, and the lock is held for less time. Under
concurrency a request queued on the rendezvous can now commit before the
page is read. The stale-candidate case is unchanged: the enactment still
classifies against the rank 0 the reorder saved.
Docs correction that rides along: the reorder entry in docs/database-locks.md
names entitlements.ReorderTiers.
ReorderPlanLadderTiers had a cognitive complexity of 30, the limit, with no
exception to delete. Measured: 14 at this commit; ReorderTiers 4,
enactReorder 6 (the largest new helper), tierReorderAnswer 7,
tierReorderFailure 2.
No output changes. Names and results of ./internal/server,
./internal/entitlements and ./internal/testkit/... are equal at the parent
and at this commit after a database reset on each side: 1779 results on
each side, 1778 passing and the one existing skip. No test, golden or
testdata file is edited and none ran with -update, so every golden is
byte-equal. The seeded operator walkthroughs (TestPlanLaddersWalkthrough and
TestOrgTypesWalkthrough among them) give equal names and results on both
sides, and the screens of operator-plan-ladders, plan-ladder-detail (and its
open and invalid states) and org-types, desktop and mobile, are
byte-identical at the parent and at this commit. Every string literal of the
old operator_plan_ladders.go and operator_org_types.go is still found in a
non-test file of internal/server or internal/entitlements.
15 breaks (24 records), each caught by the old code and by the new code
where its text exists on both sides: a stale submission says so, a new rank
0 on no type's default says so, the reorder's reason names the ladder,
organizations are enacted against the new rank 0, a failed organization's
answer, a new rank 0 on a default ladder is enacted, the gate counts the
organizations, a failed begin is logged, a failed listing after the reorder,
the toast names the types by their display names, the reorder renumbers the
ladder, the reorder takes the exclusive rendezvous, a reorder in contention
fails generically, a failed read of the types reads as no type's default,
and a cosmetic reorder says the order was updated. None was dropped.
Not probed: faults inside the reorder's transaction (the renumbering and the
commit; no querier makes them fail), so the page-out-of-date sentence and
its log line are read but not seen, and the move of its answer after the
rollback is named, not shown; the label's fallback to the ladder id when the
ladder read fails, which no querier refuses; a listing failure on the second
of two types, since the listing fault refuses every listing and the first
type fails; and the browser end-to-end plan-management tests, which were not
run here.
|
||
|
|
19ca28745f |
Change an organization type's default through entitlements.ChangeOrgTypeDefault and EnactDefault
The org-type default change and each organization's enactment lived in the HTTP handlers. They are now entitlements.ChangeOrgTypeDefault and entitlements.EnactDefault, which the reorder handler also calls for each organization. ChangeOrgTypeDefault runs the same steps in the same order: it saves the default (autocommit, outside any lock beyond the row update), composes the actor with the reason "org-type default change (<type>)", lists the type's organizations, and enacts each one. EnactDefault is the old enactOrgDisposition: it finds the default pool, opens BeginMaterializing, locks the pool, reads and classifies its positions, and then initiates the default, counts the pool, or applies the disposition (grandfather through ConferGrantTx per incumbent, migrate through core.end_conferral, ReapplyDefaultsIfVacant and the materialization), each organization in its own transaction and a failure leaving the others alone. The statements, the locks, the rendezvous kind and the legacy grant's description and transition reason are unchanged. Failures come back as EnactFailure values naming the step (eight new steps, from saving the default to the grandfather conferral's outcome); internal/entitlements now imports internal/organization, which imports no console package. CommitOrgTypeDefaultChange keeps the reads, the reserved-type check, the form parse, the candidate's resolution and the gate. renderDefaultChangeFailure gives the two sentences for a failed save and a failed listing, enactFailureReason gives each organization's reason with the driver's text unchanged, orgTypeChangeResult converts the counts for the summary, and logEnactFailures writes the line each failing step wrote before: a warning for a missing pool, errors for the initiation, the grandfather and the migrate's end and restore. The reorder handler passes the new rank 0 as the candidate and logs each type's failures after that type's organizations, as the lines came before. No answer of the default change moves after a rollback: each organization's transaction ended inside the enactment before the card rendered, and still does. Docs: docs/database-locks.md renames enactOrgDisposition to EnactDefault in the pool-row line and the reorder entry, names the default change by entitlements.ChangeOrgTypeDefault and EnactDefault, and gains a correction that rides along: the migrate branch's list now includes ReapplyDefaultsIfVacant, which the migration runs before it materializes. The two model docs point at internal/entitlements/default_change.go, and the call-sites list in the package comment of internal/entitlements now names that file for the default change and the reorder's enactment, in place of the handler. The gocognit exception for enactOrgDisposition (pinned at 38) is deleted from .golangci.yml, with the step heading that only it carried. Measured: CommitOrgTypeDefaultChange 18 at the parent and 16 at this commit; enactOutgoingDefault, the largest new helper, 9; enactDefault 5, grandfatherIncumbents 5, initiateDefault 4, ChangeOrgTypeDefault 3, EnactDefault 1. No output changes. Names and results of ./internal/server, ./internal/entitlements and ./internal/testkit/... are equal at the parent and at this commit after a database reset on each side: 1779 results on each side, 1778 passing and the one existing skip. No test, golden or testdata file is edited and no test ran with -update, so every golden is byte-equal, the two pool-queue tests among them. The seeded operator walkthroughs, which drive the default change through TestOrgTypesWalkthrough, give equal names and results on both sides, and the screens of operator-plan-ladders, plan-ladder-detail (and its open and invalid states) and org-types, desktop and mobile, are byte-identical at the parent and at this commit except the masked operator-integration-stripe shots. Three stored texts leave the handler file: ": grandfathered as legacy", "Grandfathered on org-type default change: " and "org-type default change (%s)" are each found in internal/entitlements; every other literal of the plan-ladders and org-types handler files is still found in a non-test file of internal/server. 21 breaks (38 records), each caught by the old code and by the new code where its text exists on both sides: a grandfathered and an initiated pool are counted, the legacy grant's description and transition reason, a failed restoration's reason, a failed organization turns the toast into an error, an organization with no default pool fails and is logged as a warning, the grandfather conferral must supersede, a failed begin fails the organization, a failed initiation's reason and its log line with the pool, a failed save's sentence, a failed listing after the save, the reason names the type, the new default is saved, a migration to None materializes the pool, each organization's enactment waits on its pool, an organization in contention says a change is in progress, organizations are enacted against the new rank 0, and grandfather reissues the incumbents. None was dropped. Not probed: faults inside each organization's transaction (the position read, the endings, the materialization and the commit; no querier makes them fail), so their reasons and log lines are read but not seen; the undecided refusal, which needs an outgoing-default position that appears between the gate and the enactment; the enactment order among organizations with the same name; and the browser end-to-end plan-management tests, which were not run here. |
||
|
|
b0f7aa26d3 |
Add and delete a tier through entitlements.AddTier and DeleteTier
The tier add's transaction and the tier delete's transaction lived in the HTTP handlers. They are now entitlements.AddTier and entitlements.DeleteTier. AddTier takes the ladder, the product and the operator; it opens BeginRuleChange and runs the same statements in the same order: the tier insert, the product's live provisions in creation order, for each one core.align_conferral_shape as the operator with the reason "tier added to ladder <id>" and then the pool's materialization, and the commit. The pools lock in provision order, as before. DeleteTier opens BeginRuleChange, runs the tier delete, the list of the remaining tiers and the renumbering that RemoveTier already shares, and commits. Each failure comes back as a PlanActError naming the step (two new steps, creating the tier and reading the provisions); a held rung's ErrConferralShapeCollision comes back inside the alignment step's error, where errors.Is finds it. CreatePlanLadderTier keeps the session, the form parse, the product read and the published check. refuseTierAdd maps the insert's two constraint messages and the held rung to the product field's error, and otherwise logs the line the failed step logged. DeletePlanLadderTier keeps the held-tier count and the last-tier guard before the transaction; logTierDeleteFailure logs the line each step logged. Every sentence is unchanged. The one departure from today: every refusal or failure answer the add and the delete give from inside their transactions is now rendered after the act's rollback, where the handler used to render it before its deferred rollback. Only reads move: no write, Stripe call or sentence changes, and the rendezvous and the pool locks are held for less time. Under concurrency, a request queued on them can commit before the page is read. The one golden line this moves is the rollback in the statements_with_reads view of plan_acts/add-collides-with-a-held-rung.json, which now sits directly before the refusal page's reads instead of after them; it was moved by hand before the run and the golden test then passed without -update, so no other line moved. Docs: the two handler names in the tier add and tier delete lines of docs/database-locks.md become AddTier and DeleteTier. Riding along as a correction: the call-sites list in the package comment of internal/entitlements names internal/entitlements/ladder_acts.go, not the handler, for align_conferral_shape on tier add and tier removal. No gocognit exception is deleted: neither handler had one. Measured scores: CreatePlanLadderTier 21 to 6, DeletePlanLadderTier 16 to 11, AddTier 9, DeleteTier under 4, refuseTierAdd 9. No output changes beyond that departure. Names and results of ./internal/server, ./internal/entitlements and ./internal/testkit/... are equal at the parent and at this commit after a database reset on each side: 1779 results on each side, 1778 passing and the one existing skip. Apart from the one hand-moved golden line, no test, golden or testdata file is edited and no test ran with -update. Screens of operator-plan-ladders, plan-ladder-detail (and its open and invalid states) and org-types, desktop and mobile, are byte-identical at the parent and at this commit; only the masked operator-integration-stripe shots and the 2x28 pixel mask of the invalid desktop shot differ. Every string literal of the plan-ladders and org-types handlers is still found in a non-test file of internal/server. 16 breaks (28 records), each caught by the old and the new code where its text exists on both sides: every live provision is aligned, the reason names the ladder, each pool is materialized, a held rung refuses the add under the product, the operator aligns, a failed begin of the add is logged, the add's toast, an add in contention fails generically, the tiers left are renumbered, the delete's toast, the last tier of a default ladder is refused, a held tier is refused, a failed begin of the delete is logged, a delete in contention fails generically, and the add and the delete take the exclusive rendezvous. None was dropped. Not probed: faults inside the transactions (no querier makes one fail), so none of the failure answers that now render after the rollback is seen moving, nor the add's two constraint messages; the held rung's golden is the only file that shows the move. The delete guard's read of the organization types, which skips the guard when it fails, is unchanged and stays in the handler. |
||
|
|
d9f73ea45c |
Remove a tier with holders through entitlements.RemoveTier
The tier removal's transaction, its pool locks and its per-pool reconciliation lived in the HTTP handler. They are now entitlements.RemoveTier, which takes the removal's ladder, product, the two names its reason records, the disposition and the operator, and returns the positions it ended and the organizations it migrated. The same transaction opened with BeginRuleChange, the same statements in the same order: read the holders, lock their pools in ascending order, read them again, the gate on default-sourced holders without a disposition, the tier delete, the list of the remaining tiers and the renumbering, then for each pool the incumbents' ending, the alignments, the restoration as the operator and the materialization, and the commit. Each failure comes back as a PlanActError naming the step and, where there is one, the pool or the provision; a refused gate comes back as a DispositionRequiredError. CommitPlanLadderTierRemoval keeps the session, the form parse and the reads of the two names, calls RemoveTier, and answers: the toast on success, refuseUndecidedTierRemoval (today's preview, field error and 422) for the gate, and tierRemovalFailure for any other error. tierRemovalFailure is one switch on the step that writes the log line each step wrote and returns the sentence it returned; the pool locks, the second read of the holders and the list of the remaining tiers log nothing, as before. The one departure from today: every failure or refusal answer the removal gives from inside its transaction is now rendered after the act's rollback, where the handler used to render it before its deferred rollback. Only reads move: no write, Stripe call or sentence changes, and the rendezvous and the holders' pool locks are held for less time. Under concurrency, a request queued on those locks can commit before the page is read, so the gate's preview can list a different set of holders than the count in its field error. The renumbering no longer guards on a non-empty list of remaining tiers; RenumberTiers over no tiers runs no statement. The gocognit exception for CommitPlanLadderTierRemoval (57) is deleted. Measured scores: CommitPlanLadderTierRemoval 11, RemoveTier 11, and the largest helper, removeFromPool, 13 (lockTierHolders 10, deleteTier 4); in the handler file, tierRemovalFailure 2 and refuseUndecidedTierRemoval 1. Docs: the handler names in the pool-row lock table and in the tier-removal paragraph of docs/database-locks.md become RemoveTier. Riding along as corrections: docs/models/plan-ladders-transitions.md drops the structural validation page from the operator ladder management row and the health strip from the plan topology row, and invariant 14 no longer says they report drift; both were retired and the code no longer has them. The ladder management row also names internal/entitlements/ladder_acts.go. No output changes beyond that departure. Names and results of ./internal/server, ./internal/entitlements and ./internal/testkit/... are equal at the parent and at this commit after a database reset on each side: 1779 results on each side, 1778 passing and the one existing skip. No test, golden or testdata file is edited and no test ran with -update, so every plan-act golden is byte-identical, the removal files included. Screens of operator-plan-ladders, plan-ladder-detail (and its open and invalid states) and org-types, desktop and mobile, are byte-identical at the parent and at this commit; only the masked operator-integration-stripe shots differ. The string literal "tier removal (%s from %s)" leaves the handler file and is found in internal/entitlements; every other literal of the plan-ladders and org-types handlers is still found in a non-test file of internal/server. 20 breaks, each caught by the old and the new code where its text exists on both sides (36 records, the removal's toast, the product-name fallback, the contention sentence and the did-not-commit sentence as one record each because their text did not move): the gate counts the default-sourced holders, keep aligns a default-sourced position, migrate counts the organizations, the removal counts the positions it ended, the reason names the product and the ladder, the operator restores the default, the toast, the pools reconcile in ascending order, a migrated pool held elsewhere is materialized, a failed restoration's sentence and its log line with the pool, a failed begin is logged, a removal that did not commit says nothing was changed, the gate's refusal counts the holders, the ladder is renumbered, other-source positions align, the removal locks its holders' pools, an unread product name falls back to its id, a removal in contention says a change is in progress, and the removal takes the exclusive rendezvous. None was dropped. Not probed: faults inside the transaction (no querier makes one fail), so none of the failure answers that now render after the rollback is seen moving, nor the restoration failures beyond the two the goldens pin; the removal files record no reads, so they cannot show the move. The second read of the holders under the pool locks: since the removal holds the exclusive rendezvous from its first statement no console transaction can change a holder between the two reads, and no test tells them apart. The dropped guard on remaining tiers. A failed read of the preview at the gate, which answers "Failed to remove the tier; nothing was changed." because no querier refuses the preview's reads. |
||
|
|
750b5db13d |
Move the Stripe catalog sync's act into the Stripe tree
SyncProductToStripe held the whole sync: the price read, the
dead-letter probe and reset, the product lock, the re-reads under the
lock, the pending mappings and the outbox enqueues, in one 46-point body
that a lint exception pinned. The act now lives in the new package
internal/integrations/stripe/catalogsync, which cannot sit in
internal/integrations/stripe because that package imports
internal/server. Sync returns an outcome and a closer; FailedSync is the
dead-letter probe, now also called by the product readiness panel.
The handler keeps the configured check, the product and price reads, the
price choice (syncPrice, the same rule as the loop it replaces), and
every sentence and log text, now in two tables keyed by outcome and by
stage. The transaction is ended by the handler after the page is
rendered, as before: Sync returns with its transaction open on the paths
where the handler rendered before its deferred rollback (a failed lock,
mapping write or enqueue, a re-check under the lock that finds the price
synced or in flight, the product's create in flight), and the handler
defers the closer on the next line. Only an enqueue commits. So the
product lock is held until the page is read, the rollback sits where it
did, and no golden changes.
The gocognit exception for SyncProductToStripe (pinned at 46) is
deleted. Measured scores: SyncProductToStripe 6 (was 46), enqueueLocked
19 (the largest new function), Sync 7, enqueue 6, syncPrice 4,
renderSyncFailure 2, FailedSync 1.
Six string literals leave operator_billing.go and move to catalogsync.go
with the same number of occurrences as at the parent:
"create_stripe_product", "unit_amount", the product lock SELECT
pg_advisory_xact_lock(hashtext($1)::bigint), the dead-letter probe and
the outbox reset once each, and "create_stripe_price" twice. The two SQL
texts are copied byte for byte, indentation included. Every other
literal of operator_billing.go at the parent is found in a non-test file
of internal/server at this commit.
Docs correction riding along: docs/database-locks.md named
SyncProductToStripe as the holder of the product-sync lock and said
nothing of when the lock ends; the table row and the section now name
catalogsync.Sync and say that a sync that commits nothing is rolled back
by SyncProductToStripe after it has rendered its answer, so the lock is
held until then. docs/building-an-integration.md and
docs/models/provider-integration.md said the catalog sync stays
core-side; they now say the act lives in the Stripe tree and the
operator's action stays. A test comment naming the moved probe is
renamed.
No output changes. Names and results of ./internal/server,
./internal/integrations/stripe/... and ./internal/testkit/... are equal
at the parent and at this commit after a database reset on each side:
1793 results, all passing. Every operator_sync_stripe golden is
unchanged, product_create_in_flight included, and no test ran with
-update. TestProductsWalkthrough passes on both sides with equal names,
results and skip messages; TestPlanLaddersWalkthrough failed once at this
commit with its known toast wait ("still false after 20s") and passed
three of three alone right after. The operator-products and
operator-product-detail screens are byte-identical at the parent and at
this commit; the only differing shots are the masked
operator-integration-stripe ones. The lint snapshot differs from the
parent's by no added finding and no removed one.
27 breaks, each caught by the old and the new test: an unconfigured
deployment refusing, the default price syncing when none is requested, a
price of another product refused, a synced price answering before the
lock and again under it, a dead-lettered create retried and set back to
pending, a pending price answering before the lock and again under it,
the sync waiting for the product lock, a product create in flight
holding back another price, both mappings written pending, a reachable
product enqueuing the price only, the price and product payloads
carrying their fields, a transaction that cannot begin, the six answers,
the readiness panel reading and naming a dead letter, an unreachable
product created again, and the no-op under the lock rolling back after
the page. Two records were reworded on one side each, because the first
wording left a variable or an import unused and failed to compile
instead of failing the test.
Not probed, as when the sync action was first characterized (all moved
unchanged):
- "Failed to load the product.", which renders only when the page's own
read of the product succeeds after the handler's read failed;
- a failed read of the price mapping before the lock, which reads as no
mapping and is not told apart (that read now goes through the
database handle rather than the Stripe query handle, which are one
handle in production and in every test that drives this action);
- the stale and other-environment mappings, which only
TestSyncProductToStripeCreatesAgainWhenUnreachable pins;
- the rollback after the page on a failure after the transaction began
(a failed lock, mapping write, enqueue or commit), because no row
reaches one, so the place of the rollback is shown on the no-op path
only;
- the log lines, whose texts now sit in the stage table.
|
||
|
|
44ee0cc65c |
Release a spent Stripe schedule after the reconcile commits
The subscription reconcile released a spent Stripe schedule inside its transaction, so for as long as Stripe took to answer it held the shared materialization lock, the subscription lock and any pool rows it had locked. Another reconcile of the subscription waited on it, and an operator's tier change, with every materializing transaction queued behind that, waited on the lock too. The release was the one network call left inside a materializing transaction (#80 lists it among the Stripe calls in fulfillment). The reconcile now commits everything that does not depend on the release, then releases the schedule with no lock held. Only when Stripe accepts the release does it supersede the period-end downgrade rows the schedule held, in a second short transaction that takes the subscription's advisory lock first and no rendezvous, since it writes only scheduled-change rows. A refused release logs the same warning and leaves the row scheduled, as before. The rows are the ones the first transaction listed, by id, so a downgrade scheduled after the commit is left alone, and an update still skips a row that is no longer scheduled. The OpenSpec change release-schedule-after-commit restates "Scheduled moves converge with Stripe" and is archived here; its spec delta is synced and openspec validate --specs --strict passes. Cost and the failure path that changes: one more transaction (begin, advisory lock, one update per held row, commit) when a released schedule held a downgrade row that is still scheduled, and none otherwise; a landed downgrade's release opens none, since its row is already applied. A failure of the second transaction, or a cancelled context after the release, now returns an error after the first transaction has committed, where the same failure rolled everything back; the row stays scheduled with no schedule at Stripe, and the next reconcile, finding none attached, supersedes it. The reconcile's other writes (the new tier, the items) now stay committed in that case. A failure of the first transaction sends no release, where a commit failure used to come after one. The refused release's warning is logged after the commit. Every caller treats an error as before and a retry converges. Two new error formats: "reconcile: begin tx after releasing the schedule" and "reconcile: advisory lock after releasing the schedule"; the supersede format is shared by both transactions and keeps %w. Behaviour changed on purpose, and only here: four golden files. In downgrade_landed, later_upgrade and release_refused the release line moves after commit and the lock probe reads "free" where it read "held"; later_upgrade also gains the second transaction (begin, the advisory lock, SupersedeScheduledChange, commit) with rows unchanged. The sweep's period_end_landed moves its release line after commit. The other five scheduled-change files, the other eleven sweep files and the 21 reconcile files are byte-equal: they have no spent schedule. How no other output changed: the four goldens were rewritten with -update and the diff read line by line against the code (nothing else differed; no other file changed), then the tests ran without -update three times in a row after a database reset. At the parent, with this commit's test file and goldens copied over, TestScheduledChangeTrace fails in downgrade_landed, later_upgrade and release_refused on the probe "held", TestSweepTrace fails in period_end_landed, TestNextReconcileSupersedesADowngradeLeftAfterItsRelease fails (the top tier is not attached: the cancelled context rolled back the transaction the release ran in) and TestSupersedeAfterAReleaseTakesTheSubscriptionLock fails with "the subscription lock is held while Stripe releases the schedule"; TestRefusedReleaseKeepsTheDowngrade, TestReleaseLeavesATermBoundarySwitchScheduled and TestReconcileWaitsForTheSubscriptionLock pass there. Over ./internal/fulfillment, ./internal/integrations/stripe/workflows and ./internal/server the names and results at the parent and at the commit are equal apart from the five new tests (1794 passing names before, 1799 after, none failing on either side). New tests: a refused release keeps the downgrade scheduled while the upgrade commits, and the next reconcile releases again and supersedes it; a failure between the release and the write after it leaves the row scheduled and the next reconcile supersedes it without a second release; a term-boundary switch stays scheduled when the schedule is released; the reconcile's first transaction waits for the subscription lock before it writes; the second transaction takes that lock, and the release runs with it free. docs/database-locks.md describes the second transaction and drops the entry that listed the release as a call made inside the transaction. 18 breaks on the new side, each caught: seven by the new tests, the other eleven by the scheduled-change and sweep traces. The earlier break "the release runs under the subscription's lock" is dropped: the release runs after the commit, so its probe reads "free" whatever the first transaction's key is, and the break "the reconcile's transaction takes the subscription's lock" pins that key through the new TestReconcileWaitsForTheSubscriptionLock instead. The three breaks that put the release back before the commit run only on cases that open no second transaction or whose second transaction fails at its begin; on a case with held rows the second transaction would wait for the lock its own caller holds. Scores, measured with gocognit: ReconcileSubscription 17 (16 at the parent), supersedeGoneMoves 10 (11), findSpentSchedule 7, supersedeReleasedDowngrades 5, convergeScheduledChanges 3, releaseSpentSchedule 3, supersede 2. No gocognit exception is deleted or added. Unprobed: - A process that dies between the release and the second write. The test fails the second transaction's begin with a cancelled context, which leaves the rows a crash leaves; a crash itself is not run. - The held rows' plan_switch clause. No console path or Stripe state puts a period-end cancellation row beside a spent console schedule: a period-end cancel releases an attached schedule first, and Stripe refuses cancel_at_period_end while a schedule is attached. - Two reconciles that both find the schedule spent. The second one's release reaches a released schedule; Stripe should refuse it, the warning is logged, and that reconcile leaves the row to the first one's second transaction. Read from the code and Stripe's API, not run. - A downgrade scheduled between a reconcile's fetch and its release. The downgrade reuses an attached schedule and rewrites its metadata, so a reconcile that fetched before it releases the reused schedule, Stripe drops the new downgrade, and the next reconcile supersedes its row. The window is the one the reconcile has today; it is not new and not fixed here. - The second transaction has no lock timeout, like the other plain advisory-lock transactions; no test holds the subscription lock past 5 s. - Screens, walkthroughs and test/e2e: no template, handler or sentence changes and none reaches the reconcile; not run. |
||
|
|
2eb996512f |
Keep the scheduled-change ledger true to Stripe in named rules
convergeScheduledChanges ran four rules in one body, and its 36-point cognitive complexity was pinned by a lint exception. It now lists the subscription's active rows and calls one function per rule, in the order the spec gives: currentPrices reads the console price ids the subscription carries, applyLandedDowngrades applies the row of a downgrade that landed and returns the rows it closed, releaseSpentSchedule releases a schedule the console attached once its source price is gone and reports whether a schedule is still attached, and supersedeGoneMoves supersedes the period-end moves Stripe no longer holds. The release stays inside the reconcile's transaction, where it runs today, and keeps its warn line on a refusal. currentPrices keeps its own price-mapping reads. The statements, the Stripe call, their order, the error texts and the log lines are the same. The gocognit exception for convergeScheduledChanges (pinned at 36) is deleted. Measured scores: convergeScheduledChanges 2 (was 36), supersedeGoneMoves 11 (the largest new function), applyLandedDowngrades 7, currentPrices 6, releaseSpentSchedule 4. Docs correction riding along: docs/database-locks.md says convergeScheduledChanges calls the Stripe API to release the schedule; it now says the call is made in releaseSpentSchedule. No output changes. The names and results of internal/fulfillment, internal/integrations/stripe/workflows and internal/server are equal at the parent and at this commit after a database reset on each side: 1794 results, none failing, none skipped. The scheduled-change, reconcile and sweep trace goldens are unchanged and ran without -update. The 49 fmt.Errorf formats and the logger and unreachable-mapping texts of reconcile.go, and the 9 formats and the logger texts of sweep.go, are equal as sorted lists at both commits, every wrap keeping %w. The lint snapshot differs from the parent's by no added finding and no removed one. No test changed, so there is no assertion map. 13 breaks, each caught by the old and the new test: a landed downgrade's row, the closing of an applied row, a carried price counted as current, a refused release, its log line, a term-boundary row, a gone schedule, the subscription lock around the release, a cleared cancellation, a spent schedule of the console's, a schedule without the console's metadata, an attached schedule, and a released schedule. Not probed: a missing row in rule 1's apply and in the supersede (no row closes between the list and the write, since the transaction holds the subscription lock); an unreadable price mapping in the current prices (the same reads already succeeded in the item pass); a period-end plan switch without a target price (no console path writes one). |
||
|
|
a304124215 |
Order an operator's tier changes against every conferral
A conferral reads a ladder's ranks and a product's conferral shape in several statements at READ COMMITTED, and the reorder, the tier delete, the tier removal and the tier add took the rendezvous in shared mode, so one of them could commit between those reads. A grandfathered organization that keeps its product was then recorded as upgraded or downgraded between two ranks of the same product (#213), and TestTierReorder_CommitRacesDefaultChangeCommit failed intermittently on it. The four handlers now open their transactions with BeginRuleChange, which takes the rendezvous in exclusive mode, so each finishes before a conferral starts or starts after it. The default change and the reorder's per-organization enactments keep the shared mode. The cost, accepted for #213: a tier change waits for every materializing transaction in flight, up to the 5 s lock timeout, and new ones wait behind it while it runs; the removal and the add hold the lock for their whole act. A wait past the timeout answers as a rule change in progress does today: the removal with its refusal sentence, the add, the delete and the reorder with their generic failure copy; #216 is the generic timeout answer this leaves. Behaviour changed on purpose, and only here: the rendezvous statement the four acts record, in 25 act golden files (26 lines; the held-rung file records it in two views), shared to exclusive, one line each in the same place; the renumbering-race files, which moved to tier-change-during-conferral/ and now record a transfer at one rank (a fourth file records the tier add); and stale-candidate.json, which loses the second reorder's answer. Every other golden, answer, status and row is byte-equal to the parent. How no other output changed: TestPlanActsGolden was rewritten with -update and the diff read line by line (25 files, 26 lines, each pg_advisory_xact_lock_shared to pg_advisory_xact_lock and nothing else), then run three times in a row without -update on a freshly reset database. At the parent, with this commit's test file and goldens copied over, TestTierChangesWaitForAConferral fails in all four cases and TestTierRemovalAgainstDefaultChange in its two queued cases, each on the 10 s wait for the rendezvous statement, and TestPlanActsGolden fails in the same 25 subtests, on their recorded statements; TestReorderEnactsAgainstTheRankZeroItSaved passes there. Over ./internal/server, ./internal/entitlements and ./internal/testkit/... the names and results at the parent and at the commit are equal, apart from TestRenumberingCommitsDuringAConferral and its three subtests, renamed TestTierChangesWaitForAConferral, and the new subtest "a tier add" (1746 names before, 1747 after; no failure on either side). Tests: TestTierChangesWaitForAConferral starts the tier change while the conferral is paused and waits for it to queue on the exclusive rendezvous before releasing, so the tier change commits after the conferral. The two queued cases of TestTierRemovalAgainstDefaultChange wait on the rendezvous for the second request instead of on the pool row. TestReorderEnactsAgainstTheRankZeroItSaved is rebuilt: a second renumbering would otherwise queue behind the first reorder's enactment, so the test holds a tier row, queues a renumbering on the rendezvous and releases, which still reaches the window between a reorder's renumbering and its enactment. Its stale-candidate.json keeps the first answer and the rows and loses the second answer, which makes that comparison NARROWER; the second reorder's answer is unprobed. The race test keeps its assertions and gets the comment that says why they now hold. 11 breaks, each caught by the new tests (each of the four openers reverted to the shared mode is caught by the interleaving test and by a golden, the removal's also by the two queued cases, and a reorder that enacts against the wrong rank 0 by the rebuilt stale-candidate test); the old side is not run because the new tests are what show the fix. The race test passed 500 of 500 idle and 500 of 500 under eleven busy loops on a 12-core machine (it failed 2 and 19 of 500 before), and a probe written for this race, which commits a renumbering inside a conferral, now ends in a transfer from rank 0 to rank 0 with the reorder waiting for the conferral. Unprobed: - The removal's second read of its holders under the pool locks: the exclusive lock is taken before the first read, so no console transaction can change a holder between them and no test tells the two reads apart. - The tier add's failures without the fix (the exclusion-constraint failure, a conferral committing without the new rung, the refusal about a held rung) were read from the code and not run; the before run shows only that the add did not queue on the rendezvous. - The 5 s timeout answers of the add, the delete and the reorder behind a long shared holder: the held-rule-change test already pins them, and a wait behind a shared holder takes the same path. - The time a removal of a tier with many holders, or an add of a widely held product, holds the lock: not measured. - A conferral that does not materialize would escape the order; none exists, and the database function does not assert the rendezvous. - The plan-management HTTP test, which adds tiers through the changed opener, was not run: the end-to-end packages are outside this commit's checks. The browser screens and the walkthroughs were not run: no template or sentence changed. - The demonstration seed adds its ladder's tiers on the plain handle, outside the rendezvous; the spec, the comments and the docs name the operator's four tier changes, and the seed is left as it is. The entitlements spec's requirement says which transactions take the exclusive lock and gains two scenarios ordering a tier change against a conferral; the change is archived here and the spec synced. docs/database-locks.md and docs/go-conventions.md name the four openers as exclusive. |
||
|
|
219f734e9a |
Document the test kit, the transaction runner and the surface constructors
docs/testing.md: the front-matter summary names the test kit; the TL;DR runs make test, and one package on the same footing after a reset. Database-gated tests open through dbtest: Open skips when TEST_DATABASE_URL is unset, never migrates and refuses a database whose core ledger is behind; OpenE2E and DSN; Scratch, UpTo and ApplyVersion for migration subjects; Q and AwaitLockWait. The self-migrating table and the hand-list warning are gone. The fixtures paragraph describes world (console functions, fails on any error, suffixed names, no cleanup, any *sql.DB) and states the exemption for a test of the schema itself. Type 4 names the server harness's set helpers (OperatorSet, MemberSet, Render), htmltest selectors by role and by aria-*, hx-* and data-* attributes, and authtest. Type 7 gives browsertest's rules: boolean waits only, ClickAndLand for a navigating answer, AwaitSwap for a swap, Recover in each walkthrough, AppDB never migrates and skips without its key, free functions taking t, and phases as named functions. A table lists the kit's packages and what each may import. A paragraph describes proving a converted assertion with a deliberate break and scripts/mutation-check. The complexity bullet says a closure passed to t.Run counts toward the enclosing function. The lint bullets say the lint parses each template in the set its surface builds in production and fails on a file no surface parses, and that forbidigo refuses rod's load and stable waits. The stale internal/workflows/stripe path becomes internal/integrations/stripe/workflows, and the notes that described TEST_DATABASE_URL as MC_DB_DSN, the shared-database fixture pollution and the source-order convention now describe the separate test databases and the reset. docs/go-conventions.md: a transaction opens through db.InTx or db.Begin with a kind (entitlements.Materializing or RuleChange for rendezvous transactions), its begin and commit errors unwrapped; a pool lock goes through LockPools or LockPoolOfGrant, and the order lives in database-locks.md; test helpers take testing.TB and fail on error; "Where the checks live" names the rod wait rule, the waits it refuses and those that pass it. docs/database-locks.md: the pool row's "Taken by" names LockPools and LockPoolOfGrant and their callers; Revoke, Manage, tier removal, default change and the rule change say LockPools; the intro of the order section names the two kinds as what sets lock_timeout, says a plain transaction sets none, and says which paths render the lock refusal; rule 3 says "through LockPools"; the #186 bullet keeps only the advisory-key half. test/AGENTS.md and test/reset-test-db.sh: the reset is the only migrator and dbtest.Open refuses a stale ledger; the walkthroughs run before and after a change from the demo snapshot. docs/building-an-integration.md, docs/design-system.md and docs/operator-ux-conventions.md: template sets come from web.MemberTemplates and web.OperatorTemplates with the integration's web.Mount; operator files are named <key>_operator*.html; no ParseUIPartials and no per-set surfaceRoot or routeURL registration; the helpIcon row names forms.Help and forms.HelpIcon. The DB-backed tests bullet of docs/building-an-integration.md says integration tests open through dbtest.Open, the reset migrates every registered stream, and a migration's own test runs on a dbtest.Scratch database; its UI-assets paragraph says the member set panics at boot through template.Must, an operator handler returns the constructor's error, and the lint fails on a file neither set parses. |
||
|
|
8dcda61e37 | Describe the console's database locks and the order paths take them |