Reorder tiers on obligations, with the ladder page's lines and Retry

A tier reorder that changed a default ladder's rank 0 renumbered the
tiers and then moved every organization of the types defaulting to the
ladder inside the request, from a list read after the renumbering. At
size it ran out of time, and a failure part way left the rest unmoved
with no way to resume (#187). The org types defaulting to the ladder
were read outside the transaction, and a failed read counted as none.

ReorderTiers now runs on the runner. Its first transaction holds the
exclusive rendezvous and does four things in order:

- It reads the tiers and reconciles the submitted order against them
  inside the transaction. A submission that names none of the ladder's
  tiers writes nothing and re-renders the page.
- It renumbers the tiers.
- It records the act. When rank 0 changed, core.record_plan_act reads
  the org types defaulting to the ladder and records them as reached.
- With no disposition given, it rolls back when an organization holds
  the outgoing default.

The listing, the moves and the drain then run as they do for a default
change. A move reads the current rank 0, so an organization already on
it is settled as already at the default and gets no legacy grant. The
toast reads its counts by act id across every type the reorder reaches.

The ladder page shows, below the tiers table, the lines of each reorder
that owns a type with open work, newest first in the order the acts
reached their types. They are read in two statements in one read-only
repeatable-read transaction. The page has its own Retry route, which
checks that the act is a reorder of this ladder.

The old enactment is gone. In entitlements: EnactDefault, enactDefault,
enactReorder, Enactment, EnactResult, EnactFailure, ReorderEnactment,
OrgLister, DefaultPools, LadderNames, their helpers and the steps only
they used. In the server: orgTypeChangeResult, enactFailureReason,
dispositionFailure, logEnactFailures, the reorder's own disposition and
reconciliation helpers, and OrgTypeChangeResult.Failures with the
toast's "failed" part.

Along the way:

- orgTypesDefaultingToLadder returns its error. The tier card and the
  reorder preview, and the preview a refused commit rebuilds, show
  "Failed to read the org types that default to this ladder. Reference:
  {request id}." with no commit, instead of treating the failed read as
  no org type.
- "Failed to classify affected organizations." takes the reference
  form, logged through the request's logger.
- One helper logs a gate that found an outgoing default when the
  rebuilt preview finds none, for both the default change and the
  reorder, with the request id.
- The timing test times EnactObligation once, rather than EnactDefault
  at each holder count; task 1.1's text says so.

With counts unread and nothing settled, the reorder's toast is its
opening alone, "{Product} is now the default plan for new {types}
organizations.", approved with the default change's as copy 62.
docs/database-locks.md, docs/plan-management.md and
docs/models/plan-ladders-transitions.md follow the code.

Goldens:

- Behaviour changes in reorder-an-organization-fails,
  reorder-begin-fails, reorder-cosmetic, reorder-grandfather,
  reorder-migrate, reorder-needs-disposition, reorder-rank-0-no-default,
  reorder-redrop, reorder-stale, reorder-two-types, stale-candidate,
  tier-change-during-conferral/a-reorder and contention (the reorder's
  collision sentence; later placeholders renumber). routes.golden gains
  the ladder Retry route's probes.
- Retired: reorder-list-fails, because no listing runs outside the act
  any more, replaced by reorder-listing-fails; reorder-org-types-unread,
  because that read moved into core.record_plan_act, replaced by
  reorder-record-fails. New: reorder-retry.
- The 7 add-*, 7 delete-* and 13 remove-* goldens change only because
  every ladder page render now reads its lines. Each gains that read's
  transaction statements. The three begin-fails among them also gain
  "Pending plan changes could not be read. Reference: {request id}."
  and its log line, since their closed database fails that read too.

The full suite passes.
This commit is contained in:
2026-10-19 21:03:09 -04:00
parent b0df28647f
commit a86547a434
77 changed files with 2890 additions and 991 deletions
+17 -9
View File
@@ -41,7 +41,7 @@ lists (see the pool row below).
| Lock | Row | Mode | Taken by | Serializes |
|---|---|---|---|---|
| Pool row | `core.resource_pools` row of one pool | `FOR UPDATE`; `FOR KEY SHARE` in a plan act's listing batch | the functions `core.confer`, `core.end_conferral`, `core.align_conferral_shape`, `core.update_conferral_bounds` and `core.settle_obligation`; `LockPools` (through the `LockResourcePool` query), called by `RevokeGrantTx`, `ManageGrantTx`, `ResumeReplacedGrantsTx`, `settlePool`, `runPlanAct` (`AddTier` and `EnactObligation`, a default change's move), `RemoveTier` and `EnactDefault` (the reorder's move); `LockPoolOfGrant` (through the `LockGrantPool` query), called by `ExpireGrantTx` and `ExtendWaitingGrantTx`; and `core.write_plan_act_obligations`, which takes the pool rows of one listing batch `FOR KEY SHARE` | Everything that changes a pool's positions or entitlements: conferral, endings, grant acts, tier removal, a default change's move, rule-change settlement and grant resumption. A listing batch's key-share lock conflicts with `FOR UPDATE`, so a batch and a move or a conferral on one of its pools wait for each other, and two batches of one act share the rows. |
| Pool row | `core.resource_pools` row of one pool | `FOR UPDATE`; `FOR KEY SHARE` in a plan act's listing batch | the functions `core.confer`, `core.end_conferral`, `core.align_conferral_shape`, `core.update_conferral_bounds` and `core.settle_obligation`; `LockPools` (through the `LockResourcePool` query), called by `RevokeGrantTx`, `ManageGrantTx`, `ResumeReplacedGrantsTx`, `settlePool`, `runPlanAct` (`AddTier` and `EnactObligation`, a default change's move), `RemoveTier`, and `runPlanAct` for a reorder's move (`EnactObligation`); `LockPoolOfGrant` (through the `LockGrantPool` query), called by `ExpireGrantTx` and `ExtendWaitingGrantTx`; and `core.write_plan_act_obligations`, which takes the pool rows of one listing batch `FOR KEY SHARE` | Everything that changes a pool's positions or entitlements: conferral, endings, grant acts, tier removal, a default change's move, rule-change settlement and grant resumption. A listing batch's key-share lock conflicts with `FOR UPDATE`, so a batch and a move or a conferral on one of its pools wait for each other, and two batches of one act share the rows. |
| Entitlement set row and rule row | `core.entitlement_sets` row and `core.entitlement_set_rules` row | `FOR UPDATE` | `core.commit_rule_change` (the set row also by the `LockEntitlementSet` query, once per batch) | A rule change against a writer that changes the set or the rule without holding the rendezvous. |
| Change obligation row | `core.entitlement_set_change_obligations` row of one (change, pool) | `FOR UPDATE` | `core.settle_obligation` | Two passes settling one owed pool, so each effect is written once. |
| Plan act obligation row | `core.plan_act_obligations` row of one (act, pool) | `FOR UPDATE` by a move, a listing batch's supersession and Retry's requeue; `UPDATE` by the failure recorder | the `LockPlanActObligation` query (a move, after the pool row), `core.write_plan_act_obligations` (the open obligations of earlier acts on the batch's pools), `core.requeue_plan_act_obligations` (the retried act's failed obligations of the types it still owns) and `core.record_plan_act_obligation_failure` | Two passes settling one obligation, and a settlement against a supersession or a requeue. A batch and a Retry take several rows in ascending `pool_id` order and touch disjoint rows: a batch supersedes the open rows of earlier acts on the types its act owns, a Retry requeues the failed rows of the types its act still owns, and both hold the shared rendezvous, so no default change can move the ownership while either runs. |
@@ -170,10 +170,9 @@ ladder's ranks and a product's shape in separate statements, so no conferral
may run while one of them commits (#213). The default change's T0 takes the
exclusive rendezvous too: a move reads the type's default and its rank 0, and a
default saved between those reads would mix two catalogs. What follows T0 runs
in transactions that take the shared rendezvous: Retry's own, the drain's
listing, moves and last probe, and the reorder's enactment of each
organization come from `BeginMaterializing`; the listing, the moves and the
last probe inside the request that made the act come from
in transactions that take the shared rendezvous: Retry's own and the drain's
listing, moves and last probe come from `BeginMaterializing`; the listing, the
moves and the last probe inside the request that made the act come from
`MaterializingWithin`, which sets the request's shorter lock timeout and its
transaction timeout before the rendezvous statement.
@@ -220,6 +219,19 @@ transaction timeout before the rendezvous statement.
probes). It takes no pool row and no obligation row, so its hold does not
grow with the number of organizations. The request's deadline covers the
two waits it can make, the rendezvous and the type's row.
- **Reorder** (`entitlements.ReorderTiers`, through `runPlanAct`): T0 takes the
exclusive rendezvous and no pool or obligation row. Inside it: the ladder's
tiers are listed and the submitted order reconciled against them, the names
read, the ranks renumbered (`RenumberTiers`), the act recorded
(`core.record_plan_act`, which reads the org types that default to the ladder
and records them as the types the act reaches, only when rank 0 changed), and,
when no disposition was chosen and the act reaches a type, the gate
(`core.outgoing_default_exists`), which refuses the whole T0. Then the
listing batches and the moves run as for a default change: the shared
rendezvous, `core.write_plan_act_obligations`, and `EnactObligation` for each
organization, which reads the type's current default and its rank 0 when it
runs, not the rank 0 the reorder saved. A reorder that keeps rank 0 reaches no
type and writes no obligation.
- **The listing** (`ListPlanActBatch`, after T0): the shared rendezvous with
the lock timeout and transaction timeout the caller gives (the request's
shorter ones, or the drain's 5 s), then `core.write_plan_act_obligations`:
@@ -260,10 +272,6 @@ transaction timeout before the rendezvous statement.
touches any obligation. The two writers of several obligation rows, a
batch's supersession and a Retry's requeue, take them in ascending order and
touch disjoint rows.
- **Reorder** (`entitlements.ReorderTiers`): the exclusive rendezvous around
the renumbering, committed with no pool lock. When the rank-0 tier changed,
each affected organization then runs `EnactDefault` under the shared
rendezvous, as a default change does.
### Invoice reconcile
+2 -2
View File
@@ -46,7 +46,7 @@ Database objects sit in the `core` schema; the baseline DDL is `internal/db/migr
| Member plan moves | Switch, cancel, and the commitment gating layered above conferral | `internal/fulfillment/plan_change.go` |
| Member position | The organization's position on each ladder, read from its default pool in one query (`ListLadderPositionsByPool`, `internal/entitlements/queries/pool_provision_ladders.sql`); the Products page, Checkout, the switch and fulfillment's plan changes read it, and "held" in it means an active attachment | `internal/entitlements/queries/pool_provision_ladders.sql` |
| Member move offer | `plans.Offer` decides which move an organization is offered toward a price on a ladder, `plans.Across` combines a product's ladders, `plans.OffLadder` decides a product on none; the Products page draws from them and Checkout, the switch and its preview refuse anything else | `internal/plans/offer.go` |
| Operator ladder management | Ladder and tier CRUD, reorder and removal (with a preview of what happens to pools sitting on affected rank-0 tiers; removing the last tier of a ladder an org type defaults to is refused, because signups land on that ladder's entry tier) | `internal/server/operator_plan_ladders.go`, `internal/entitlements/ladder_acts.go` |
| Operator ladder management | Ladder and tier CRUD, reorder and removal (with a preview of what happens to pools sitting on affected rank-0 tiers; removing the last tier of a ladder an org type defaults to is refused, because signups land on that ladder's entry tier) | `internal/server/operator_plan_ladders.go`, `internal/entitlements/ladder_acts.go`, `internal/entitlements/tier_reorder.go` |
| Operator enrollment | Issue, revoke, and extend grants; revoke ends positions then restores the guarded default | `internal/server/operator_enrollment.go` |
| Org-type default changes | Changing a type's default ladder, with a per-pool preview offering two dispositions: **grandfather** (the pool keeps its old position, re-decreed as a legacy grant) or **migrate** (end the old position, then floor-guarded restore onto the new default) | `internal/server/operator_org_types.go`, `internal/entitlements/default_change.go` |
| Plan topology | The ladders-by-ranks overview grid | `internal/server/operator_topology.go` |
@@ -72,7 +72,7 @@ An invariant is a rule the model guarantees everywhere; code that would break on
14. **[app]** Tier ranks are contiguous from 0 only by authoring discipline: the schema enforces only per-ladder rank uniqueness; the operator UI appends and renumbers to keep 0..N-1.
15. **[db]** A ladder is identified by its `plan_ladder_id`, presented by its display name, and addressed declaratively by a nullable `key` (`00012_entity_keys.sql` renamed `ladder_key` to `key` and made it nullable; the rule is in `docs/identifiers.md` and the `ui-vocabulary` spec). The key is what a seed, a configuration file, a test, a script, or an API client names to reach the ladder without knowing its generated id; it is `TEXT NULL`, matches `^[a-z][a-z0-9_]*$` with at most 64 characters, and is unique across the table (`uq_plan_ladders_key`). Neither the create form nor the edit form collects it, so a ladder an operator creates carries none; the ladder's detail page renders it read-only when set and renders nothing when NULL. A key addresses whichever ladder holds it now, so a seed ensures the row exists and never overwrites the fields of the row it lands on.
16. **[db]** A ladder's display name is unique, compared without regard to case (`uq_plan_ladders_name_ci`). The name is what every catalog and composite surface leads with, so two ladders sharing one would be indistinguishable to an operator. Migration `00012` deduped colliding names before adding the index, and folded the existing ladder keys to the key grammar (lower-cased, separator runs to `_`, leading non-letters removed), clearing any value the grammar still rejected and any that collided after folding.
17. **[app]** Audit reasons that name a ladder (`plan-ladder tier reorder (…)`, `tier removal (… from …)`) are composed at the moment of the action and stored on the transition rows, so they are write-time snapshots: they record the name as it read then, and a later rename does not rewrite them. The ladder's UUID stands in when the row cannot be loaded.
17. **[app]** Audit reasons that name a ladder (`plan-ladder tier reorder (…)`, `tier removal (… from …)`) are composed at the moment of the action and stored on the transition rows, so they are write-time snapshots: they record the name as it read then, and a later rename does not rewrite them. An act that cannot read the ladder's row refuses at the read of names and records nothing.
18. **[app]** When a subscription's end, a grant's revocation or a grant's expiry ends at least one provision of that plan, every grant `core.resumable_grants(now())` names with that plan as holder re-confers from its own row, with the reason `grant-resumption:subscription:`, `grant-resumption:revocation:` or `grant-resumption:expiration:` followed by the ending plan's id. The chain rule: a marked grant waits on the nearest plan above it in its chain of replacements that has not ended, and resumes when that plan ends; a plan between them that ends first (it expires, is revoked, or is unmarked while it waits) is passed over. A grant and its extensions are one plan, named by the lineage's head. `core.grant_waits` is the one statement of the walk and the conditions (marked, active, valid at the instant, delivering nothing, its product on at least one ladder whose rungs no source but the holder holds, and first in resumption order among grants sharing a ladder), and the operator's delivery states and the member's held rung read it through the two functions. No other ending path resumes a grant: not a suspension, not a subscription dropping an item, not an extension, not an org-type migration and not a tier removal.
19. **[app]** On a ladder of the product it buys or switches to, a member's own move never takes a pool below a rung a grant delivers: from a grant-held rung the only move offered is up, through Checkout. Checkout never supersedes a position on another ladder of the product unless that ladder offers Checkout too: Checkout of a product refuses unless every ladder it is a tier of offers it. Two exceptions are open (#88), because the decision reads a position only on the ladders it is asked about. The plan switch: a switch to a product that is a tier of several ladders can supersede a position on another of them, a grant's included, because deciding it needs the subscription that holds each ladder. And a rung on a ladder the product is not a tier of: supersession is whole-bundle (invariant 7), so Checkout that supersedes a grant's position on a ladder of the product also ends that grant's rungs on ladders the product is not a tier of, and no default is restored there while the pool holds the product (invariant 11). `plans.Offer` and `plans.Across` state the rule; the Products page, Checkout and the switch read it.
+4
View File
@@ -88,6 +88,10 @@ Tiers are reordered by dragging rows on the ladder detail page — and the drop
- If the pending order keeps rank 0 (or the ladder is no org type's default), the preview says "display order only" and committing just renumbers ranks contiguously (`0..N-1`).
- If it changes rank 0 of a ladder that **is** some org type's default, the reorder is a default change wearing a drag gesture: the preview classifies the affected organizations exactly like the org-types card (see *Changing the Default* below) and the commit **requires a disposition** — grandfather or migrate — for organizations holding the outgoing rank-0 through the type default. Plan-less pools of those types are initiated onto the new rank 0.
The commit renumbers the ranks and records the reorder as one act in a single step; the organizations of the types it reaches are then moved in the same request as far as its time allows, and the rest are moved by the background drain, as for a default change. The toast says how many were moved and how many are not yet reached. Each organization is moved onto the rank 0 the type has when its move runs, so a reorder that races a default change moves nobody onto a stale rank 0, and an organization already on that rank 0 is settled without a legacy grant. Grandfathered positions are reissued as legacy grants described "Grandfathered: plan-ladder tier reorder (ladder name)".
An organization whose move fails five times is listed below the tiers table on the ladder page, under its reorder, with the sentence for the step that failed and the act's reference, beside **Retry**. Retry moves those organizations onto the types' current default without writing the ranks again, for the types the reorder still owns; a newer default change or reorder takes over the rest. The grants and plan changes a retried organization receives are recorded under the operator who pressed Retry.
Member positions bind by product, so reordering never moves an existing member between plans; only the display order and the rank-0 provisioning default change.
### Removing Tiers
@@ -11,11 +11,13 @@
page with the sentence for the step that failed, at most fifty, then the
act's reference. They are lines, as the entitlement set's failed-recompute
line is, not the page-anatomy readout part. When the lines could not be
read, that sentence stands in their place. */}}
read, that sentence stands in their place. The org type card and the
ladder page both include it; .Target and .Swap say what a Retry swaps. */}}
{{ if .Error }}
<p class="small text-danger mt-3 mb-0" role="alert">{{ .Error }}</p>
{{ end }}
{{ $target := .Target }}
{{ $swap := or .Swap "outerHTML" }}
{{ range .Acts }}
<div class="mt-3" id="plan-act-{{ .ActID }}">
{{ if .NotYetReached }}
@@ -26,7 +28,7 @@
<button type="button" class="btn btn-sm btn-outline-primary ms-2"
hx-post="{{ .RetryURL }}"
hx-target="{{ $target }}"
hx-swap="outerHTML"
hx-swap="{{ $swap }}"
hx-sync="this:replace">
Retry
</button>
@@ -18,7 +18,9 @@
whose set has no active rule to rank 0. The commit stays enabled. */}}
<p class="small mb-2">{{ . }}</p>
{{ end }}
{{ if not .RankZeroChanged }}
{{ if .OrgTypesError }}
<p class="small text-danger mb-2" role="alert">{{ .OrgTypesError }}</p>
{{ else if not .RankZeroChanged }}
<p class="small text-muted mb-2">Display order only; rank 0 is unchanged.</p>
{{ else if not .TypePreviews }}
<p class="small text-muted mb-2">Rank 0 changes from {{ .OldRankZeroName }} to {{ .NewRankZeroName }}, but this ladder is not any org type's default; display order only.</p>
@@ -12,7 +12,10 @@
operator commits, so the pending panels render the preview
declarations operator.plan-ladder.tier.reorder.commit and .remove.commit
(design D10's preview idiom; finding FA-13). Product names cross-link
to their products (chrome-conventions: cross-refs link identifiers). */}}
to their products (chrome-conventions: cross-refs link identifiers).
Below the table, the lines of the reorders that still owe work
(plan-acts "The place where an act was made lists the organizations it
did not move"). */}}
{{ if .Error }}
<div class="alert alert-danger" role="alert">{{ .Error }}</div>
{{ end }}
@@ -74,7 +77,7 @@
<span class="sortable-handle text-muted me-2" role="button" tabindex="-1" title="Drag to reorder" aria-label="Drag to reorder {{ .ProductName }}">&#x28FF;</span>
{{ end }}
<a href="{{ routeURL "/operator/products/{productID}" .ProductID }}">{{ .ProductName }}</a>
{{ if eq .Rank 0 }}{{ range $.DefaultBadges }} {{ template "statusBadge" . }}{{ end }}{{ end }}
{{ if eq .Rank 0 }}{{ if $.DefaultTypesError }} <span class="small text-danger">{{ $.DefaultTypesError }}</span>{{ else }}{{ range $.DefaultBadges }} {{ template "statusBadge" . }}{{ end }}{{ end }}{{ end }}
</th>
<td>{{ if $pending }}{{ template "statusBadge" .PendingRankBadge }}{{ else }}{{ template "statusBadge" .RankBadge }}{{ end }}</td>
<td>{{ if .HasActiveAttachments }}{{ .ActiveAttachmentCount }}{{ else }}<span class="text-muted">0</span>{{ end }}</td>
@@ -122,3 +125,5 @@
{{ template "form" .Form }}
{{ end }}
{{ end }}
{{ template "operator_plan_act_lines.html" .PlanActs }}
+54 -49
View File
@@ -16,7 +16,6 @@ import (
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/world"
@@ -984,78 +983,84 @@ func (p *planActProto) previewRows(t *testing.T) int {
}
// timePlanActToday times today's code, built with the test kit in a migrated
// scratch database, at each holder count: one organization's move under each
// disposition of EnactDefault (the median of ten organizations), a removal
// with migrate (RemoveTier) and an add (AddTier), each once per count.
// scratch database: one organization's move under each disposition
// (EnactObligation, the median of ten organizations), then at each holder count
// a removal with migrate (RemoveTier) and an add (AddTier), each once.
func timePlanActToday(t *testing.T) {
database := scratchDeliveryDB(t)
w := world.New(t, database)
owner := w.Person("Timing")
moves := timeObligationMoves(t, w, owner)
t.Logf("EnactObligation: grandfather %s, migrate %s, initiate %s", planActMS(moves[0]), planActMS(moves[1]), planActMS(moves[2]))
for _, holders := range planActHolderCounts {
moves := timeEnactDefault(t, w, owner, holders)
removal := timeRemoveTier(t, w, owner, holders)
add := timeAddTier(t, w, owner, holders)
t.Logf("%5d holders: EnactDefault grandfather %s, migrate %s, initiate %s; RemoveTier %s (%s per holder); AddTier %s (%s per holder)",
holders, planActMS(moves[0]), planActMS(moves[1]), planActMS(moves[2]),
planActMS(removal), planActMS(removal/time.Duration(holders)), planActMS(add), planActMS(add/time.Duration(holders)))
t.Logf("%5d holders: RemoveTier %s (%s per holder); AddTier %s (%s per holder)",
holders, planActMS(removal), planActMS(removal/time.Duration(holders)), planActMS(add), planActMS(add/time.Duration(holders)))
}
}
// planActFloored creates n organizations of orgType, each with its default pool
// floored onto the type's current default.
func planActFloored(w *world.World, owner world.Person, orgType, label string, n int) []world.Org {
orgs := make([]world.Org, n)
for i := range orgs {
orgs[i] = w.Org(fmt.Sprintf("%s %d", label, i), orgType, owner)
w.Floor(orgs[i])
func planActFloored(w *world.World, owner world.Person, orgType, label string, n int) {
for i := range n {
w.Floor(w.Org(fmt.Sprintf("%s %d", label, i), orgType, owner))
}
return orgs
}
// timeEnactDefault builds a type of holders organizations on one default
// ladder, switches its default to a second ladder, and times ten moves under
// each disposition: grandfather and migrate on holders of the outgoing
// default, and initiate on plan-less organizations.
func timeEnactDefault(t *testing.T, w *world.World, owner world.Person, holders int) [3]time.Duration {
// timeObligationMoves times the move of one organization under each
// disposition, the median of ten organizations: grandfather and migrate on
// holders of the outgoing default, and initiate on plan-less organizations.
// Each disposition has an organization type of its own, switched to a second
// ladder by a default change whose request moves one organization, so the
// others are pending obligations; EnactObligation is then timed on each of
// them, the move the default change and the reorder share.
func timeObligationMoves(t *testing.T, w *world.World, owner world.Person) [3]time.Duration {
t.Helper()
orgType := w.OrgType(fmt.Sprintf("m%d", holders))
from, to := w.Product(fmt.Sprintf("Move from %d", holders)), w.Product(fmt.Sprintf("Move to %d", holders))
fromLadder, toLadder := w.Ladder(fmt.Sprintf("Move from %d", holders), from), w.Ladder(fmt.Sprintf("Move to %d", holders), to)
w.DefaultLadder(orgType, fromLadder)
held := planActFloored(w, owner, orgType, "Move holder", holders)
w.DefaultLadder(orgType, toLadder)
bare := make([]world.Org, planActRuns)
for i := range bare {
bare[i] = w.Org(fmt.Sprintf("Move bare %d", i), orgType, owner)
}
var out [3]time.Duration
for i, step := range []struct {
disposition string
orgs []world.Org
want func(*entitlements.EnactResult) int
key, disposition string
want entitlements.ObligationOutcome
floored bool
}{
{"grandfather", held[:planActRuns], func(r *entitlements.EnactResult) int { return r.Grandfathered }},
{"migrate", held[planActRuns : 2*planActRuns], func(r *entitlements.EnactResult) int { return r.Migrated }},
{"", bare, func(r *entitlements.EnactResult) int { return r.Initiated }},
{"mg", "grandfather", entitlements.OutcomeGrandfathered, true},
{"mm", "migrate", entitlements.OutcomeMigrated, true},
{"mi", "", entitlements.OutcomeInitiated, false},
} {
e := entitlements.Enactment{
Disposition: step.disposition,
CandidateProductID: to.ID,
OperatorPersonID: owner.ID,
Actor: entitlements.Actor{
ActorType: "operator", ActorID: uuid.NullUUID{UUID: uuid.MustParse(owner.ID), Valid: true}, Reason: "timing",
},
Pools: entitlements.New(w.DB),
orgType := w.OrgType(step.key)
from, to := w.Product("Move from "+step.key), w.Product("Move to "+step.key)
fromLadder, toLadder := w.Ladder("Move from "+step.key, from), w.Ladder("Move to "+step.key, to)
w.DefaultLadder(orgType, fromLadder)
if step.floored {
planActFloored(w, owner, orgType, "Move holder "+step.key, planActRuns+1)
} else {
for j := 0; j <= planActRuns; j++ {
w.Org(fmt.Sprintf("Move bare %s %d", step.key, j), orgType, owner)
}
}
cfg := entitlements.PlanActConfig{Limits: entitlements.PlanActLimits{
LockTimeout: 5 * time.Second, Cap: 1, Budget: time.Hour, ListBatch: 500, Deadline: time.Hour,
}}
res, err := entitlements.ChangeOrgTypeDefault(w.Ctx, w.DB, entitlements.DefaultChangeInput{
OrgType: orgType, Ladder: uuid.NullUUID{UUID: uuid.MustParse(toLadder.ID), Valid: true},
Disposition: step.disposition, OperatorPersonID: owner.ID, Acts: cfg,
})
if err != nil {
t.Fatalf("the default change for %s moves: %v", step.key, err)
}
pending, err := entitlements.New(w.DB).ListPendingPlanActObligations(w.Ctx, entitlements.ListPendingPlanActObligationsParams{
ActID: res.ActID, AfterOrgID: uuid.Nil.String(), BatchSize: planActRuns,
})
if err != nil || len(pending) != planActRuns {
t.Fatalf("the default change for %s left %d pending obligations (%v), want %d", step.key, len(pending), err, planActRuns)
}
var runs []time.Duration
for _, o := range step.orgs {
var result entitlements.EnactResult
for _, row := range pending {
start := time.Now()
entitlements.EnactDefault(w.Ctx, w.DB, organization.Organization{OrgID: o.ID, Name: o.Name}, e, &result)
move, err := entitlements.EnactObligation(w.Ctx, w.DB, entitlements.PlanActConfig{}, entitlements.Materializing, res.ActID, row.PoolID)
runs = append(runs, time.Since(start))
if len(result.Failures) > 0 || step.want(&result) != 1 {
t.Fatalf("EnactDefault %q moved %+v", step.disposition, result)
if err != nil || move.Skipped || move.Outcome != step.want {
t.Fatalf("EnactObligation %q moved %+v: %v", step.disposition, move, err)
}
}
out[i] = planActMedian(runs)
+25 -244
View File
@@ -8,14 +8,11 @@ import (
"database/sql"
"errors"
"fmt"
"log/slog"
"time"
"github.com/google/uuid"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
"git.coopcloud.tech/wiki-cafe/member-console/internal/logging"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
)
@@ -76,76 +73,15 @@ func EndDefaultIncumbents(ctx context.Context, q *Queries, incumbents []DefaultI
return nil
}
// The steps of the organization type default change and of each
// organization's enactment that the plan-wide acts' list in ladder_acts.go
// does not name; the reorder also lists a type's organizations
// (StepListOrganizations) and enacts each one.
// The steps of the organization type default change and of the reorder that
// the plan-wide acts' list in ladder_acts.go does not name.
const (
StepLockOrgType PlanActStep = "lock the organization type"
StepReadRankZero PlanActStep = "read the candidate ladder's rank-0 tier"
StepSaveDefault PlanActStep = "save the organization type's default"
StepReadGate PlanActStep = "read whether an organization holds the outgoing default"
StepListOrganizations PlanActStep = "list the organization type's organizations"
StepFindPool PlanActStep = "find the organization's default pool"
StepReadPositions PlanActStep = "read the pool's plan positions"
StepInitiate PlanActStep = "initiate the default"
StepUndecided PlanActStep = "an outgoing-default position has no disposition"
StepGrandfather PlanActStep = "grandfather the outgoing default"
StepGrandfatherOutcome PlanActStep = "the grandfather conferral's outcome"
StepLockOrgType PlanActStep = "lock the organization type"
StepReadRankZero PlanActStep = "read the candidate ladder's rank-0 tier"
StepSaveDefault PlanActStep = "save the organization type's default"
StepReadGate PlanActStep = "read whether an organization holds the outgoing default"
)
// DefaultPools finds an organization's default pool. The enactment reads it
// before it opens the organization's transaction.
type DefaultPools interface {
GetDefaultPoolByOrgID(ctx context.Context, orgID string) (ResourcePool, error)
}
// OrgLister lists an organization type's organizations, outside any
// transaction.
type OrgLister interface {
ListOrganizationsByType(ctx context.Context, orgType string) ([]organization.Organization, error)
}
// Enactment is what an organization type default change, or a reorder that
// changes a default ladder's rank 0, applies to each organization of an
// affected type: the disposition chosen for positions held through the
// outgoing default ("grandfather", "migrate", or "" when the preview found
// none), the candidate default's rank-0 product ("" for None), the operator
// and the actor every transition records.
type Enactment struct {
Disposition string
CandidateProductID string
OperatorPersonID string
Actor Actor
Pools DefaultPools
}
// EnactResult counts the organizations an enactment moved, by what happened
// to each, and lists the ones it did not move.
type EnactResult struct {
Initiated int // plan-less pools initiated onto the new default
AlreadyAt int // pools already at the candidate's rank-0 (idempotent re-runs land here)
Grandfathered int // outgoing-default pools reissued as legacy grants
Migrated int // outgoing-default pools moved to the new default (or ended to baseline)
Left int // plan-less pools that stayed plan-less (None candidate: nothing to initiate onto)
Untouched int // pools held by another source, never touched
Failures []EnactFailure
}
// EnactFailure is one organization an enactment did not move: the step that
// failed, the pool it was acting on once the pool was found, and the error,
// or for StepGrandfatherOutcome the outcome the conferral reported instead.
// The organization's transaction is rolled back; the others are not
// affected.
type EnactFailure struct {
OrgID string
OrgName string
PoolID string
Step PlanActStep
Outcome string
Err error
}
// ErrOrgTypeReserved is a default change of an organization type the
// application manages, which is not an operator's to configure. The change
// wrote nothing.
@@ -186,14 +122,7 @@ type DefaultChangeResult struct {
ActID string
// TargetProductID is the new default's rank-0 product, "" for None.
TargetProductID string
// Pass is what the request listed and moved. A pass that is not Done
// leaves the rest to the act's drain, which the caller starts.
Pass PlanActPass
// Counts is what the act still owes, read after the pass. CountsRead is
// false when the read failed or the pass stopped on a failed read, and the
// caller leaves the counts out of its answer.
Counts PlanActCounts
CountsRead bool
PlanActShare
}
// ChangeOrgTypeDefault makes the input's ladder (invalid for None) the
@@ -227,22 +156,11 @@ func ChangeOrgTypeDefault(ctx context.Context, database *sql.DB, in DefaultChang
return nil, err
}
log := logging.FromContext(ctx).With(slog.String("act_id", t.actID))
pass, err := DrainPlanAct(actCtx, database, in.Acts, t.actID, DrainPlanActInput{Mode: DrainInRequest, Started: started})
share, err := requestShare(ctx, actCtx, database, in.Acts, t.actID, started)
if err != nil {
return nil, err
}
result := &DefaultChangeResult{ActID: t.actID, TargetProductID: t.target, Pass: pass}
if pass.ReadFailed {
return result, nil
}
result.Counts, err = ReadPlanActCounts(ctx, database, in.Acts, t.actID)
if err != nil {
log.Error("could not read what the default change still owes", slog.Any("error", err))
return result, nil
}
result.CountsRead = true
return result, nil
return &DefaultChangeResult{ActID: t.actID, TargetProductID: t.target, PlanActShare: share}, nil
}
// defaultChange is one default change's T0: what it read and wrote, for the
@@ -332,164 +250,27 @@ func (t *defaultChange) gate(ctx context.Context, q planActQueriers) error {
for i, r := range reached {
types[i] = r.OrgType
}
target, err := PlanTarget(t.target)
return refuseOutgoingDefault(ctx, q.Ent, types, t.target)
}
// refuseOutgoingDefault is the disposition gate both T0s run when the operator
// chose no disposition (design D4): it refuses with a *DispositionRequiredError
// naming the reached types and the target when some organization of them holds
// a live default-sourced position that does not deliver the target's rank-0
// product ("" for None), and with a *PlanActError when the gate cannot be read.
// It reads two index entries for each type, so its cost does not grow with the
// types.
func refuseOutgoingDefault(ctx context.Context, q Querier, types []string, target string) error {
planTarget, err := PlanTarget(target)
if err != nil {
return &PlanActError{Step: StepReadGate, OrgType: t.in.OrgType, Err: err}
return &PlanActError{Step: StepReadGate, Err: err}
}
outgoing, err := q.Ent.GetOutgoingDefaultExists(ctx, GetOutgoingDefaultExistsParams{OrgTypes: types, Target: target})
outgoing, err := q.GetOutgoingDefaultExists(ctx, GetOutgoingDefaultExistsParams{OrgTypes: types, Target: planTarget})
if err != nil {
return &PlanActError{Step: StepReadGate, OrgType: t.in.OrgType, Err: err}
return &PlanActError{Step: StepReadGate, Err: err}
}
if outgoing {
return &DispositionRequiredError{OrgTypes: types, TargetProductID: t.target}
}
return nil
}
// EnactDefault applies one organization's commit-time bucket in its own
// short transaction and folds the outcome into result: the pool is locked,
// its positions are classified again inside the transaction against the
// candidate, and a pool whose bucket changed since the preview is enacted per
// its commit-time bucket. A failure is recorded in result and never aborts
// the caller's run.
func EnactDefault(ctx context.Context, b db.Beginner, org organization.Organization, e Enactment, result *EnactResult) {
if f := enactDefault(ctx, b, org.OrgID, e, result); f != nil {
f.OrgID, f.OrgName = org.OrgID, org.Name
result.Failures = append(result.Failures, *f)
}
}
func enactDefault(ctx context.Context, b db.Beginner, orgID string, e Enactment, result *EnactResult) *EnactFailure {
pool, err := e.Pools.GetDefaultPoolByOrgID(ctx, orgID)
if err != nil {
return &EnactFailure{Step: StepFindPool, Err: err}
}
tx, err := BeginMaterializing(ctx, b)
if err != nil {
return &EnactFailure{Step: StepBegin, PoolID: pool.PoolID, Err: err}
}
defer tx.Rollback()
// Row-level lock: concurrent commits serialize per pool, and the bucket
// re-derivation below reads a stable snapshot of the pool's positions.
q := New(tx)
if _, err := q.LockPools(ctx, pool.PoolID); err != nil {
return &EnactFailure{Step: StepLockPools, PoolID: pool.PoolID, Err: err}
}
target, err := PlanTarget(e.CandidateProductID)
if err != nil {
return &EnactFailure{Step: StepReadPositions, PoolID: pool.PoolID, Err: err}
}
bucket, err := q.GetPlanPoolBucket(ctx, GetPlanPoolBucketParams{PoolID: pool.PoolID, Target: target})
if err != nil {
return &EnactFailure{Step: StepReadPositions, PoolID: pool.PoolID, Err: err}
}
switch DefaultBucket(bucket) {
case BucketAlreadyAtDefault:
result.AlreadyAt++ // already delivering the candidate's rank-0; rollback is a no-op
case BucketWouldInitiate:
return initiateDefault(ctx, tx, pool.PoolID, e.Actor, result)
case BucketOutgoingDefault:
positions, err := q.ListOutgoingPlanPositionsByPool(ctx, ListOutgoingPlanPositionsByPoolParams{PoolID: pool.PoolID, Target: target})
if err != nil {
return &EnactFailure{Step: StepReadPositions, PoolID: pool.PoolID, Err: err}
}
incumbents := make([]DefaultIncumbent, len(positions))
for i, pos := range positions {
incumbents[i] = DefaultIncumbent{ProvisionID: pos.ProvisionID, GrantID: pos.GrantID.UUID.String(), ProductID: pos.ProductID}
}
return enactOutgoingDefault(ctx, tx, q, orgID, pool.PoolID, incumbents, e, result)
case BucketOtherSource:
result.Untouched++ // never touched; rollback is a no-op
default:
return &EnactFailure{Step: StepReadPositions, PoolID: pool.PoolID, Err: fmt.Errorf("pool class %q", bucket)}
}
return nil
}
// initiateDefault confers the type's current default on a plan-less pool
// and commits. The floor guard doubles as the in-transaction re-derivation:
// a pool that acquired a plan between classification and this call noops,
// and so does a pool whose type has no default.
func initiateDefault(ctx context.Context, tx *sql.Tx, poolID string, actor Actor, result *EnactResult) *EnactFailure {
res, err := ReapplyDefaultsIfVacant(ctx, tx, poolID, actor)
if err != nil {
return &EnactFailure{Step: StepInitiate, PoolID: poolID, Err: err}
}
if err := tx.Commit(); err != nil {
return &EnactFailure{Step: StepCommit, PoolID: poolID, Err: err}
}
if res.Outcome == "created" {
result.Initiated++
} else {
result.Left++
}
return nil
}
// enactOutgoingDefault applies the disposition to a pool that holds the
// outgoing default. With no disposition it refuses: the bucket appeared
// between the caller's validation gate and this pool's transaction, and an
// undecided disposition is never enacted silently. Grandfather reissues each
// incumbent as a legacy grant that extends it; migrate ends each incumbent,
// then the floor-guarded restoration confers the new default only when the
// pool is left with no live plan (a None candidate, or union debris held by
// another source, restores nothing), and the pool is materialized.
func enactOutgoingDefault(ctx context.Context, tx *sql.Tx, q *Queries, orgID, poolID string, incumbents []DefaultIncumbent, e Enactment, result *EnactResult) *EnactFailure {
switch e.Disposition {
case "":
return &EnactFailure{Step: StepUndecided, PoolID: poolID}
case "grandfather":
if f := grandfatherIncumbents(ctx, tx, orgID, poolID, incumbents, e); f != nil {
return f
}
if err := tx.Commit(); err != nil {
return &EnactFailure{Step: StepCommit, PoolID: poolID, Err: err}
}
result.Grandfathered++
return nil
}
if err := EndDefaultIncumbents(ctx, q, incumbents, e.Actor); err != nil {
return &EnactFailure{Step: StepEndIncumbents, PoolID: poolID, Err: err}
}
if _, err := ReapplyDefaultsIfVacant(ctx, tx, poolID, e.Actor); err != nil {
return &EnactFailure{Step: StepRestore, PoolID: poolID, Err: err}
}
if err := MaterializePoolEntitlements(ctx, q, poolID); err != nil {
return &EnactFailure{Step: StepMaterialize, PoolID: poolID, Err: err}
}
if err := tx.Commit(); err != nil {
return &EnactFailure{Step: StepCommit, PoolID: poolID, Err: err}
}
result.Migrated++
return nil
}
// grandfatherIncumbents reissues each incumbent default position as a legacy
// grant granted by the operator that extends the incumbent's grant, so the
// position continues as a transfer on the same product. Each conferral must
// supersede the lineage ("created").
func grandfatherIncumbents(ctx context.Context, tx *sql.Tx, orgID, poolID string, incumbents []DefaultIncumbent, e Enactment) *EnactFailure {
for _, inc := range incumbents {
res, err := ConferGrantTx(ctx, tx, ConferGrantInput{
ProductID: inc.ProductID,
OrgID: orgID,
GrantedByPersonID: e.OperatorPersonID,
GrantReason: "legacy",
Description: "Grandfathered on org-type default change: " + e.Actor.Reason,
Quantity: 1,
ExtendsGrantID: uuid.NullUUID{UUID: uuid.MustParse(inc.GrantID), Valid: true},
ActorType: "operator",
ActorID: e.Actor.ActorID,
TransitionReason: GrandfatheredReason(e.Actor.Reason),
ActID: e.Actor.ActID,
})
if err != nil {
return &EnactFailure{Step: StepGrandfather, PoolID: poolID, Err: err}
}
if res.Outcome != "created" {
return &EnactFailure{Step: StepGrandfatherOutcome, PoolID: poolID, Outcome: res.Outcome}
}
return &DispositionRequiredError{OrgTypes: types, TargetProductID: target}
}
return nil
}
+1 -1
View File
@@ -38,7 +38,7 @@
// Call sites:
//
// - internal/server/operator_enrollment.go — operator issue/extend/revoke
// - internal/entitlements/default_change.go — default change and reorder enactment: initiate/grandfather/migrate
// - internal/entitlements/plan_act_move.go — EnactObligation, the move of a default change and a reorder: initiate/grandfather/migrate
// - internal/entitlements/ladder_acts.go — align_conferral_shape on tier add and tier removal
// - internal/fulfillment/reconcile.go — subscription diff: confer/end/bounds/sync
// - internal/workflows/entitlements/ — grant expiry: decree + end_conferral
-85
View File
@@ -622,88 +622,3 @@ func deleteTierAct(ctx context.Context, q planActQueriers, in DeleteTierInput) e
}
return deleteTier(ctx, q.Billing, in.LadderID, in.ProductID)
}
// LadderNames reads a ladder, for the name a reorder's reason records.
type LadderNames interface {
GetPlanLadderByID(ctx context.Context, planLadderID string) (billing.PlanLadder, error)
}
// ReorderTiersInput is a committed reorder: LadderID's tiers in Order, rank 0
// first, every tier of the ladder named once. Enact is set when the reorder
// changes the rank 0 of a ladder organization types default to.
type ReorderTiersInput struct {
LadderID string
Order []string
Enact *ReorderEnactment
}
// ReorderEnactment is the new rank 0 enacted on the organizations of the
// types that default to the reordered ladder, in the order the types are
// given: the disposition for positions held through the outgoing default,
// the operator, and where the ladder's name, the organizations and their
// pools are read.
type ReorderEnactment struct {
OrgTypes []string
Disposition string
OperatorPersonID string
Ladders LadderNames
Orgs OrgLister
Pools DefaultPools
}
// ReorderTiers renumbers the ladder to the order in one transaction, which
// takes the exclusive rendezvous and no pool lock; the commit is the moment
// the effective default changes. A tier's rank is display order plus the
// rank-0 provisioning default; member provisions bind by product, so
// reordering never re-plans a member. When Enact is set, each organization of
// each type is then enacted onto the new rank 0 in its own transaction, as a
// default change enacts it, and the result is returned; otherwise the result
// is nil. A failed listing of a type's organizations stops the enactment and
// comes back with the result so far.
func ReorderTiers(ctx context.Context, b db.Beginner, in ReorderTiersInput) (*EnactResult, error) {
tx, err := BeginRuleChange(ctx, b)
if err != nil {
return nil, &PlanActError{Step: StepBegin, Err: err}
}
defer tx.Rollback()
if err := RenumberTiers(ctx, billing.New(tx), in.LadderID, in.Order); err != nil {
return nil, &PlanActError{Step: StepRenumber, Err: err}
}
if err := tx.Commit(); err != nil {
return nil, &PlanActError{Step: StepCommit, Err: err}
}
if in.Enact == nil {
return nil, nil
}
return enactReorder(ctx, b, in.LadderID, in.Order[0], *in.Enact)
}
// enactReorder enacts the reordered ladder's new rank 0 on every
// organization of the types that default to it. The reason is a write-time
// snapshot of the ladder's name as it read after the renumbering committed,
// so a later rename does not rewrite history; the ladder id stands in when
// the ladder cannot be read.
func enactReorder(ctx context.Context, b db.Beginner, ladderID, rankZero string, re ReorderEnactment) (*EnactResult, error) {
ladderLabel := ladderID
if ladder, err := re.Ladders.GetPlanLadderByID(ctx, ladderID); err == nil {
ladderLabel = ladder.Name
}
e := Enactment{
Disposition: re.Disposition,
CandidateProductID: rankZero,
OperatorPersonID: re.OperatorPersonID,
Actor: operatorActor(re.OperatorPersonID, fmt.Sprintf("plan-ladder tier reorder (%s)", ladderLabel)),
Pools: re.Pools,
}
result := &EnactResult{}
for _, orgType := range re.OrgTypes {
orgs, err := re.Orgs.ListOrganizationsByType(ctx, orgType)
if err != nil {
return result, &PlanActError{Step: StepListOrganizations, OrgType: orgType, Err: err}
}
for _, org := range orgs {
EnactDefault(ctx, b, org, e, result)
}
}
return result, nil
}
+43 -21
View File
@@ -400,6 +400,43 @@ func ListPlanActBatch(ctx context.Context, database db.Beginner, cfg PlanActConf
return len(rows), nil
}
// PlanActShare is what a request did of its act's work after the act's T0
// committed, and what is still owed. A pass that is not Done leaves the rest to
// the act's drain, which the caller starts.
type PlanActShare struct {
// Pass is what the request listed and moved.
Pass PlanActPass
// Counts is what the act still owes, read after the pass. CountsRead is
// false when the read failed or the pass stopped on a failed read, and the
// caller leaves the counts out of its answer.
Counts PlanActCounts
CountsRead bool
}
// requestShare runs the request's share of an act whose T0 committed: one pass
// of DrainPlanAct under actCtx, the deadline derived for the act's own calls,
// and then the read of what the act still owes on ctx, the request's own, which
// the deadline does not end. A read that fails is logged and leaves the counts
// out; the error is a limit the configuration refuses.
func requestShare(ctx, actCtx context.Context, database *sql.DB, cfg PlanActConfig, actID string, started time.Time) (PlanActShare, error) {
pass, err := DrainPlanAct(actCtx, database, cfg, actID, DrainPlanActInput{Mode: DrainInRequest, Started: started})
if err != nil {
return PlanActShare{}, err
}
share := PlanActShare{Pass: pass}
if pass.ReadFailed {
return share, nil
}
share.Counts, err = ReadPlanActCounts(ctx, database, cfg, actID)
if err != nil {
logging.FromContext(ctx).Error("could not read what the plan act still owes",
slog.String("act_id", actID), slog.Any("error", err))
return share, nil
}
share.CountsRead = true
return share, nil
}
// PlanActCounts is what an act still owes: the organizations not yet reached
// (pending obligations, an organization whose move failed and is not yet failed
// included, and organizations the listing has not reached) and the
@@ -410,33 +447,18 @@ type PlanActCounts struct {
}
// ReadPlanActCounts reads what the act still owes on the types it owns, for the
// toast of the request that made it.
// toast of the request that made it. It reads by the act's id, so a reorder
// that reaches several types counts every one of them, as a default change
// counts its one.
func ReadPlanActCounts(ctx context.Context, database *sql.DB, cfg PlanActConfig, actID string) (PlanActCounts, error) {
q := cfg.bind(database).Ent
reached, err := q.ListPlanActReachedOrgTypes(ctx, actID)
owed, err := q.GetPlanActOwed(ctx, actID)
if err != nil {
return PlanActCounts{}, fmt.Errorf("read the org types of plan act %s: %w", actID, err)
}
var counts PlanActCounts
if len(reached) > 0 {
types := make([]string, len(reached))
for i, r := range reached {
types[i] = r.OrgType
}
open, err := q.ListOpenPlanActsByOrgTypes(ctx, types)
if err != nil {
return PlanActCounts{}, fmt.Errorf("read the open work of plan act %s: %w", actID, err)
}
for _, o := range open {
if o.ActID == actID {
counts.NotYetReached += int(o.Pending + o.Unlisted)
}
}
return PlanActCounts{}, fmt.Errorf("read the work plan act %s still owes: %w", actID, err)
}
without, err := q.CountPlanActOrgsWithoutPool(ctx, actID)
if err != nil {
return PlanActCounts{}, fmt.Errorf("count the organizations of plan act %s without a default pool: %w", actID, err)
}
counts.WithoutPool = int(without)
return counts, nil
return PlanActCounts{NotYetReached: int(owed.Pending + owed.Unlisted), WithoutPool: int(without)}, nil
}
+45 -9
View File
@@ -22,7 +22,8 @@ type UnmovedOrganization struct {
// OpenPlanAct is one act's open work as the place where it was made shows it
// (design D22): the act that owns an org type, with the counts of its pending
// and failed obligations on the type and of the type's organizations its
// listing has not reached, and the first organizations it did not move.
// listing has not reached, and the first organizations it did not move. A
// reorder's counts are summed over the types it owns, and its OrgType is empty.
type OpenPlanAct struct {
ActID string
OrgType string
@@ -50,19 +51,54 @@ func ReadOpenPlanActsByOrgTypes(ctx context.Context, database db.Beginner, cfg P
if len(orgTypes) == 0 {
return nil, nil
}
return readOpenPlanActs(ctx, database, cfg, perActLimit, func(q Querier) ([]OpenPlanAct, error) {
open, err := q.ListOpenPlanActsByOrgTypes(ctx, orgTypes)
if err != nil {
return nil, fmt.Errorf("read the open plan acts of %d org types: %w", len(orgTypes), err)
}
acts := make([]OpenPlanAct, len(open))
for i, o := range open {
acts[i] = OpenPlanAct{ActID: o.ActID, OrgType: o.OrgType, Pending: int(o.Pending), Failed: int(o.Failed), Unlisted: int(o.Unlisted)}
}
return acts, nil
})
}
// ReadOpenPlanActsByLadder reads the reorders of the ladder that own an org
// type while they have open work, newest first, with their unmoved
// organizations, at most perActLimit for each act, in the two statements and
// the one read-only REPEATABLE READ transaction of ReadOpenPlanActsByOrgTypes.
// A reorder that owns several types is one act, its counts summed over them
// (OrgType is empty).
func ReadOpenPlanActsByLadder(ctx context.Context, database db.Beginner, cfg PlanActConfig, ladderID string, perActLimit int) ([]OpenPlanAct, error) {
return readOpenPlanActs(ctx, database, cfg, perActLimit, func(q Querier) ([]OpenPlanAct, error) {
open, err := q.ListOpenPlanActsByLadder(ctx, ladderID)
if err != nil {
return nil, fmt.Errorf("read the open plan acts of ladder %s: %w", ladderID, err)
}
acts := make([]OpenPlanAct, len(open))
for i, o := range open {
acts[i] = OpenPlanAct{ActID: o.ActID, Pending: int(o.Pending), Failed: int(o.Failed), Unlisted: int(o.Unlisted)}
}
return acts, nil
})
}
// readOpenPlanActs is the two reads of a place in one read-only REPEATABLE READ
// transaction: the open acts, which open reads, then the unmoved organizations
// of the ones with failed obligations.
func readOpenPlanActs(ctx context.Context, database db.Beginner, cfg PlanActConfig, perActLimit int, open func(Querier) ([]OpenPlanAct, error)) ([]OpenPlanAct, error) {
var acts []OpenPlanAct
err := db.InTx(ctx, database, db.ReadOnlyRepeatableRead, func(tx *sql.Tx) error {
q := cfg.bind(tx).Ent
open, err := q.ListOpenPlanActsByOrgTypes(ctx, orgTypes)
if err != nil {
return fmt.Errorf("read the open plan acts of %d org types: %w", len(orgTypes), err)
var err error
if acts, err = open(q); err != nil {
return err
}
acts = make([]OpenPlanAct, len(open))
var failing []string
for i, o := range open {
acts[i] = OpenPlanAct{ActID: o.ActID, OrgType: o.OrgType, Pending: int(o.Pending), Failed: int(o.Failed), Unlisted: int(o.Unlisted)}
if o.Failed > 0 {
failing = append(failing, o.ActID)
for _, a := range acts {
if a.Failed > 0 {
failing = append(failing, a.ActID)
}
}
if len(failing) == 0 {
@@ -504,7 +504,8 @@ func TestPlanActOwnerRuleIsStatedOnce(t *testing.T) {
// organizations without a pool through ownership.
for _, name := range []string{
"LockPlanActObligation", "ListPendingPlanActObligations", "ListPlanActsOwing",
"ListPlanActReachedOrgTypes", "CountPlanActOrgsWithoutPool", "ListOpenPlanActsByOrgTypes", "ListUnmovedOrganizations",
"ListPlanActReachedOrgTypes", "CountPlanActOrgsWithoutPool", "ListOpenPlanActsByOrgTypes", "ListOpenPlanActsByLadder",
"GetPlanActOwed", "ListUnmovedOrganizations",
} {
text, ok := texts[name]
if !ok {
@@ -551,7 +552,7 @@ const frontierRule = "core.plan_act_listing_frontier("
// it had when it stated the rule itself.
func TestPlanActListingFrontierIsStatedOnce(t *testing.T) {
texts := queryTexts(t)
for _, name := range []string{"GetPlanActListingDone", "ListPlanActsOwing", "ListOpenPlanActsByOrgTypes"} {
for _, name := range []string{"GetPlanActListingDone", "ListPlanActsOwing", "ListOpenPlanActsByOrgTypes", "ListOpenPlanActsByLadder", "GetPlanActOwed"} {
text, ok := texts[name]
if !ok {
t.Errorf("%s is not among the package's queries", name)
@@ -594,6 +595,8 @@ func TestPlanActListingFrontierIsStatedOnce(t *testing.T) {
"GetPlanActListingDone": `'` + uuid.NewString() + `'::uuid`,
"ListPlanActsOwing": "",
"ListOpenPlanActsByOrgTypes": `ARRAY['` + uuid.NewString() + `']::varchar[]`,
"ListOpenPlanActsByLadder": `'` + uuid.NewString() + `'::uuid`,
"GetPlanActOwed": `'` + uuid.NewString() + `'::uuid`,
} {
stmt := "frontier_" + strings.ToLower(name)
q.Exec(`PREPARE ` + stmt + ` AS ` + placeholder.ReplaceAllLiteralString(comments.ReplaceAllString(texts[name], ""), "$1"))
@@ -733,6 +736,13 @@ func TestPlanActListingFrontierAgreesAcrossItsReaders(t *testing.T) {
if got := f.unlisted(act); !maps.Equal(got, step.unlisted) {
t.Errorf("step %d (%s): %v not yet listed, want %v", i, step.name, got, step.unlisted)
}
var wantOwed int64
for _, n := range step.unlisted {
wantOwed += n
}
if owed, err := f.e.GetPlanActOwed(f.t.Context(), act); err != nil || owed.Unlisted != wantOwed {
t.Errorf("step %d (%s): the act owes %d not yet listed (%v), want %d", i, step.name, owed.Unlisted, err, wantOwed)
}
}
}
+109 -7
View File
@@ -92,24 +92,59 @@ func (q *Queries) GetPlanActMotion(ctx context.Context, arg GetPlanActMotionPara
return i, err
}
const getPlanActOwed = `-- name: GetPlanActOwed :one
SELECT COALESCE(sum(w.pending), 0)::bigint AS pending,
COALESCE(sum(w.unlisted), 0)::bigint AS unlisted
FROM (
SELECT (SELECT count(*) FROM core.plan_act_obligations ob
WHERE ob.act_id = r.act_id AND ob.org_type = r.org_type AND ob.status = 'pending') AS pending,
(SELECT count(*)
FROM core.plan_act_listing_frontier(r.act_id) AS f(org_type, after_org_id, last_org_id)
JOIN core.organizations g ON g.org_type = f.org_type::varchar
AND g.org_id > f.after_org_id::uuid AND g.org_id <= f.last_org_id::uuid
WHERE f.org_type::varchar = r.org_type) AS unlisted
FROM core.plan_act_reached_org_types r
WHERE r.act_id = $1::uuid
AND core.plan_act_owns_org_type(r.act_id, r.org_type)) w
`
type GetPlanActOwedRow struct {
Pending int64 `json:"pending"`
Unlisted int64 `json:"unlisted"`
}
// What the act still owes on the types it owns, whatever its cause: its
// pending obligations and the organizations its listing has not reached,
// summed over the types. The toast of the request that made the act reads it.
func (q *Queries) GetPlanActOwed(ctx context.Context, actID string) (GetPlanActOwedRow, error) {
row := q.db.QueryRowContext(ctx, getPlanActOwed, actID)
var i GetPlanActOwedRow
err := row.Scan(&i.Pending, &i.Unlisted)
return i, err
}
const getPlanActPlace = `-- name: GetPlanActPlace :one
SELECT d.cause, d.org_type
SELECT d.cause, d.org_type, d.plan_ladder_id
FROM core.plan_act_details d
WHERE d.act_id = $1::uuid
`
type GetPlanActPlaceRow struct {
Cause string `json:"cause"`
OrgType sql.NullString `json:"org_type"`
Cause string `json:"cause"`
OrgType sql.NullString `json:"org_type"`
PlanLadderID uuid.NullUUID `json:"plan_ladder_id"`
}
// Where an act was made: its cause and, for a default change, the org type it
// changed (NULL for any other cause). A Retry checks it before it records
// anything, so an act is retried only from the place that shows it.
// Where an act was made: its cause, the org type a default change changed
// (NULL for any other cause) and the ladder the act records: the one a reorder
// renumbered, a tier add or removal changed, or a default change's new default
// (which is not its place). A Retry checks it
// before it records anything, so an act is retried only from the place that
// shows it.
func (q *Queries) GetPlanActPlace(ctx context.Context, actID string) (GetPlanActPlaceRow, error) {
row := q.db.QueryRowContext(ctx, getPlanActPlace, actID)
var i GetPlanActPlaceRow
err := row.Scan(&i.Cause, &i.OrgType)
err := row.Scan(&i.Cause, &i.OrgType, &i.PlanLadderID)
return i, err
}
@@ -173,6 +208,73 @@ func (q *Queries) HasPlanActSettledOutgoing(ctx context.Context, actID string) (
return settled, err
}
const listOpenPlanActsByLadder = `-- name: ListOpenPlanActsByLadder :many
SELECT w.act_id,
sum(w.pending)::bigint AS pending,
sum(w.failed)::bigint AS failed,
sum(w.unlisted)::bigint AS unlisted
FROM (
SELECT r.act_id, r.org_type, r.reached_seq,
(SELECT count(*) FROM core.plan_act_obligations ob
WHERE ob.act_id = r.act_id AND ob.org_type = r.org_type AND ob.status = 'pending') AS pending,
(SELECT count(*) FROM core.plan_act_obligations ob
WHERE ob.act_id = r.act_id AND ob.org_type = r.org_type AND ob.status = 'failed') AS failed,
(SELECT count(*)
FROM core.plan_act_listing_frontier(r.act_id) AS f(org_type, after_org_id, last_org_id)
JOIN core.organizations g ON g.org_type = f.org_type::varchar
AND g.org_id > f.after_org_id::uuid AND g.org_id <= f.last_org_id::uuid
WHERE f.org_type::varchar = r.org_type) AS unlisted
FROM core.plan_act_details d
JOIN core.plan_act_reached_org_types r ON r.act_id = d.act_id
WHERE d.cause = 'tiers_reordered'
AND d.plan_ladder_id = $1::uuid
AND core.plan_act_owns_org_type(r.act_id, r.org_type)) w
GROUP BY w.act_id
HAVING sum(w.pending + w.failed + w.unlisted) > 0
ORDER BY max(w.reached_seq) DESC
`
type ListOpenPlanActsByLadderRow struct {
ActID string `json:"act_id"`
Pending int64 `json:"pending"`
Failed int64 `json:"failed"`
Unlisted int64 `json:"unlisted"`
}
// For the given ladder, each reorder that owns an org type while it has open
// work, newest first by the order the acts reached their types (reached_seq,
// which does not follow the wall clock), with the counts of its pending and failed obligations
// and of the organizations its listing has not reached, summed over the types
// it owns. A reorder reaches every org type that defaults to its ladder, so
// one act can own several.
func (q *Queries) ListOpenPlanActsByLadder(ctx context.Context, planLadderID string) ([]ListOpenPlanActsByLadderRow, error) {
rows, err := q.db.QueryContext(ctx, listOpenPlanActsByLadder, planLadderID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListOpenPlanActsByLadderRow{}
for rows.Next() {
var i ListOpenPlanActsByLadderRow
if err := rows.Scan(
&i.ActID,
&i.Pending,
&i.Failed,
&i.Unlisted,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listOpenPlanActsByOrgTypes = `-- name: ListOpenPlanActsByOrgTypes :many
SELECT w.org_type, w.act_id, w.pending, w.failed, w.unlisted
FROM (
+17 -3
View File
@@ -172,9 +172,16 @@ type Querier interface {
// text and disposition: the person of the motion act (the act, or the Retry
// that requeued the obligation) and the detail of the obligation's own act.
GetPlanActMotion(ctx context.Context, arg GetPlanActMotionParams) (GetPlanActMotionRow, error)
// Where an act was made: its cause and, for a default change, the org type it
// changed (NULL for any other cause). A Retry checks it before it records
// anything, so an act is retried only from the place that shows it.
// What the act still owes on the types it owns, whatever its cause: its
// pending obligations and the organizations its listing has not reached,
// summed over the types. The toast of the request that made the act reads it.
GetPlanActOwed(ctx context.Context, actID string) (GetPlanActOwedRow, error)
// Where an act was made: its cause, the org type a default change changed
// (NULL for any other cause) and the ladder the act records: the one a reorder
// renumbered, a tier add or removal changed, or a default change's new default
// (which is not its place). A Retry checks it
// before it records anything, so an act is retried only from the place that
// shows it.
GetPlanActPlace(ctx context.Context, actID string) (GetPlanActPlaceRow, error)
// The product the act's detail names: the default change's target, the
// reorder's new rank 0. NULL for a default change to None.
@@ -420,6 +427,13 @@ type Querier interface {
// The pools of one change whose last attempt recorded an error at or after a
// moment, for the drain activity's per-pool error log.
ListObligationFailuresSince(ctx context.Context, arg ListObligationFailuresSinceParams) ([]ListObligationFailuresSinceRow, error)
// For the given ladder, each reorder that owns an org type while it has open
// work, newest first by the order the acts reached their types (reached_seq,
// which does not follow the wall clock), with the counts of its pending and failed obligations
// and of the organizations its listing has not reached, summed over the types
// it owns. A reorder reaches every org type that defaults to its ladder, so
// one act can own several.
ListOpenPlanActsByLadder(ctx context.Context, planLadderID string) ([]ListOpenPlanActsByLadderRow, error)
// For each given org type, the org type default change that owns it while it
// has open work, with the counts of its pending and failed obligations on the
// type and of the type's organizations its listing has not reached.
+56 -4
View File
@@ -190,6 +190,55 @@ FROM (
WHERE w.pending + w.failed + w.unlisted > 0
ORDER BY w.org_type, w.act_id;
-- name: ListOpenPlanActsByLadder :many
-- For the given ladder, each reorder that owns an org type while it has open
-- work, newest first by the order the acts reached their types (reached_seq,
-- which does not follow the wall clock), with the counts of its pending and failed obligations
-- and of the organizations its listing has not reached, summed over the types
-- it owns. A reorder reaches every org type that defaults to its ladder, so
-- one act can own several.
SELECT w.act_id,
sum(w.pending)::bigint AS pending,
sum(w.failed)::bigint AS failed,
sum(w.unlisted)::bigint AS unlisted
FROM (
SELECT r.act_id, r.org_type, r.reached_seq,
(SELECT count(*) FROM core.plan_act_obligations ob
WHERE ob.act_id = r.act_id AND ob.org_type = r.org_type AND ob.status = 'pending') AS pending,
(SELECT count(*) FROM core.plan_act_obligations ob
WHERE ob.act_id = r.act_id AND ob.org_type = r.org_type AND ob.status = 'failed') AS failed,
(SELECT count(*)
FROM core.plan_act_listing_frontier(r.act_id) AS f(org_type, after_org_id, last_org_id)
JOIN core.organizations g ON g.org_type = f.org_type::varchar
AND g.org_id > f.after_org_id::uuid AND g.org_id <= f.last_org_id::uuid
WHERE f.org_type::varchar = r.org_type) AS unlisted
FROM core.plan_act_details d
JOIN core.plan_act_reached_org_types r ON r.act_id = d.act_id
WHERE d.cause = 'tiers_reordered'
AND d.plan_ladder_id = sqlc.arg(plan_ladder_id)::uuid
AND core.plan_act_owns_org_type(r.act_id, r.org_type)) w
GROUP BY w.act_id
HAVING sum(w.pending + w.failed + w.unlisted) > 0
ORDER BY max(w.reached_seq) DESC;
-- name: GetPlanActOwed :one
-- What the act still owes on the types it owns, whatever its cause: its
-- pending obligations and the organizations its listing has not reached,
-- summed over the types. The toast of the request that made the act reads it.
SELECT COALESCE(sum(w.pending), 0)::bigint AS pending,
COALESCE(sum(w.unlisted), 0)::bigint AS unlisted
FROM (
SELECT (SELECT count(*) FROM core.plan_act_obligations ob
WHERE ob.act_id = r.act_id AND ob.org_type = r.org_type AND ob.status = 'pending') AS pending,
(SELECT count(*)
FROM core.plan_act_listing_frontier(r.act_id) AS f(org_type, after_org_id, last_org_id)
JOIN core.organizations g ON g.org_type = f.org_type::varchar
AND g.org_id > f.after_org_id::uuid AND g.org_id <= f.last_org_id::uuid
WHERE f.org_type::varchar = r.org_type) AS unlisted
FROM core.plan_act_reached_org_types r
WHERE r.act_id = sqlc.arg(act_id)::uuid
AND core.plan_act_owns_org_type(r.act_id, r.org_type)) w;
-- name: ListUnmovedOrganizations :many
-- Each given act's failed obligations on the types it owns, with the
-- organization's current name and the step that failed, by organization name
@@ -225,9 +274,12 @@ SELECT EXISTS (
AND core.plan_act_owns_org_type(ob.act_id, ob.org_type))::boolean AS settled;
-- name: GetPlanActPlace :one
-- Where an act was made: its cause and, for a default change, the org type it
-- changed (NULL for any other cause). A Retry checks it before it records
-- anything, so an act is retried only from the place that shows it.
SELECT d.cause, d.org_type
-- Where an act was made: its cause, the org type a default change changed
-- (NULL for any other cause) and the ladder the act records: the one a reorder
-- renumbered, a tier add or removal changed, or a default change's new default
-- (which is not its place). A Retry checks it
-- before it records anything, so an act is retried only from the place that
-- shows it.
SELECT d.cause, d.org_type, d.plan_ladder_id
FROM core.plan_act_details d
WHERE d.act_id = sqlc.arg(act_id)::uuid;
+221
View File
@@ -0,0 +1,221 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package entitlements
import (
"context"
"database/sql"
"errors"
"fmt"
"slices"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
)
// StepReadReach is a reorder's read of the org types it reached.
const StepReadReach PlanActStep = "read the org types the act reached"
// ErrNoTiersToReorder is a reorder of a ladder that has no tier, or whose
// submission names none of its tiers. It wrote nothing.
var ErrNoTiersToReorder = errors.New("the ladder has no tier to reorder")
// ReconcileTierOrder reconciles a submitted product order against the ladder's
// current tiers: a submitted product that is no tier of the ladder, and a
// repeat, are dropped, and a tier the submission lacks is appended in its
// current relative order. It returns the final order, rank 0 first, the product
// at rank 0 now, and whether the submission had to change (a page that was out
// of date).
func ReconcileTierOrder(tiers []billing.PlanLadderTier, submitted []string) (final []string, rankZero string, stale bool) {
valid := make(map[string]bool, len(tiers))
for _, t := range tiers {
valid[t.ProductID] = true
if t.Rank == 0 {
rankZero = t.ProductID
}
}
taken := make(map[string]bool, len(submitted))
final = make([]string, 0, len(tiers))
for _, pid := range submitted {
if valid[pid] && !taken[pid] {
taken[pid] = true
final = append(final, pid)
}
}
stale = len(final) != len(submitted) // a submitted product was not a valid tier
for _, t := range tiers {
if !taken[t.ProductID] {
final = append(final, t.ProductID)
stale = true
}
}
return final, rankZero, stale
}
// ReorderTiersInput is a committed reorder: LadderID's tiers in the order the
// operator dropped them, rank 0 first, and the disposition for positions held
// through the outgoing default ("grandfather" or "migrate", "" when none was
// asked for). RequestID is the request's identifier, which the act stores for
// correlating it with log lines; Acts says where the act builds its queriers
// and what the request spends.
type ReorderTiersInput struct {
LadderID string
Submitted []string
Disposition string
OperatorPersonID string
RequestID string
Acts PlanActConfig
}
// ReorderTiersResult is what a committed reorder did before its request
// answered.
type ReorderTiersResult struct {
ActID string
// Stale is true when the submitted order was reconciled against a ladder
// that had changed.
Stale bool
// RankZeroChanged is true when the reorder put another product at rank 0,
// and NewRankZero names it.
RankZeroChanged bool
NewRankZero string
// ReachedOrgTypes are the display names of the org types the reorder
// reached, by key; none when rank 0 did not change or no type defaults to
// the ladder.
ReachedOrgTypes []string
// PlanActShare is what the request moved and what is still owed; it is the
// zero value for a reorder that reached no type.
PlanActShare
}
// ReorderTiers renumbers the ladder to the order and, when that changes the
// rank 0 of a ladder one or more org types default to, makes the new rank 0 what
// their organizations move to. T0 runs on the runner under the exclusive
// rendezvous and takes no pool lock: it reads the ladder's tiers and reconciles
// the submitted order against them, renumbers, records the act (whose function
// reads the org types that default to the ladder and records them as the types
// the act reaches) and, when no disposition was chosen and the act reaches a
// type, runs the disposition gate, refusing with a *DispositionRequiredError if
// an organization of the reached types holds the outgoing default. Then a
// listing batch and as many moves as the request's budget allows run in the
// request, and the rest is owed to the act's drain, as for a default change
// (design D1, D14). A rank is display order plus the rank-0 provisioning
// default; member provisions bind by product, so a reorder never re-plans a
// member.
//
// T0 and the drain run under a deadline derived from ctx for the act's own
// calls only. A T0 the deadline ends comes back as ErrPlanActDeadline, and a
// lock wait or a deadlock as the error db.IsLockContention reports.
func ReorderTiers(ctx context.Context, database *sql.DB, in ReorderTiersInput) (*ReorderTiersResult, error) {
started := time.Now()
limits, err := in.Acts.limits()
if err != nil {
return nil, err
}
actCtx, cancel := context.WithTimeout(ctx, limits.Deadline)
defer cancel()
t := &tierReorder{in: in}
if err := runPlanAct(actCtx, database, planAct{Kind: RuleChange, Queriers: in.Acts, Act: t.act}); err != nil {
if actCtx.Err() != nil && ctx.Err() == nil {
return nil, fmt.Errorf("%w: %w", ErrPlanActDeadline, err)
}
return nil, err
}
result := &ReorderTiersResult{
ActID: t.actID, Stale: t.stale, RankZeroChanged: t.rankZeroChanged, NewRankZero: t.newRankZero,
}
for _, r := range t.reached {
result.ReachedOrgTypes = append(result.ReachedOrgTypes, r.OrgTypeName)
}
if len(t.reached) == 0 {
return result, nil
}
if result.PlanActShare, err = requestShare(ctx, actCtx, database, in.Acts, t.actID, started); err != nil {
return nil, err
}
return result, nil
}
// tierReorder is one reorder's T0: what it read and wrote, for the caller to
// continue from.
type tierReorder struct {
in ReorderTiersInput
actID string
stale bool
rankZeroChanged bool
newRankZero string
reached []ListPlanActReachedOrgTypesRow
}
// act is T0's body, inside the runner's exclusive transaction.
func (t *tierReorder) act(ctx context.Context, q planActQueriers) error {
in := t.in
tiers, err := q.Billing.ListTiersByLadder(ctx, in.LadderID)
if err != nil {
return &PlanActError{Step: StepReadTiers, Err: err}
}
final, previousRankZero, stale := ReconcileTierOrder(tiers, in.Submitted)
if len(final) == 0 || !slices.ContainsFunc(tiers, func(t billing.PlanLadderTier) bool {
return slices.Contains(in.Submitted, t.ProductID)
}) {
// No tier, or a submission with no product that is one: a page that
// shows nothing of the ladder as it stands, so nothing is written.
return ErrNoTiersToReorder
}
t.stale = stale
t.rankZeroChanged = final[0] != previousRankZero
record := planActRecord{
Cause: ActTiersReordered,
ActorPersonID: in.OperatorPersonID,
RequestID: in.RequestID,
EffectiveAt: time.Now(),
PlanLadderID: in.LadderID,
}
ladder, err := q.Billing.GetPlanLadderByID(ctx, in.LadderID)
if err != nil {
return &PlanActError{Step: StepReadNames, Err: err}
}
record.PlanLadderName = ladder.Name
if t.rankZeroChanged {
next, err := q.Billing.GetProductByID(ctx, final[0])
if err != nil {
return &PlanActError{Step: StepReadNames, Err: err}
}
previous, err := q.Billing.GetProductByID(ctx, previousRankZero)
if err != nil {
return &PlanActError{Step: StepReadNames, Err: err}
}
record.ProductID, record.ProductName = final[0], next.Name
record.PreviousProductID, record.PreviousProductName = previousRankZero, previous.Name
record.Disposition = in.Disposition
t.newRankZero = next.Name
}
// The act is recorded after the renumbering: core.record_plan_act reads the
// ladder's rank 0 to check the detail, and the org types that default to the
// ladder to record the types the act reaches.
if err := RenumberTiers(ctx, q.Billing, in.LadderID, final); err != nil {
return &PlanActError{Step: StepRenumber, Err: err}
}
t.actID, err = writePlanAct(ctx, q.Ent, record)
if err != nil {
return &PlanActError{Step: StepRecordAct, Err: err}
}
if !t.rankZeroChanged {
return nil
}
t.reached, err = q.Ent.ListPlanActReachedOrgTypes(ctx, t.actID)
if err != nil {
return &PlanActError{Step: StepReadReach, Err: err}
}
if in.Disposition != "" || len(t.reached) == 0 {
return nil
}
types := make([]string, len(t.reached))
for i, r := range t.reached {
types[i] = r.OrgType
}
return refuseOutgoingDefault(ctx, q.Ent, types, final[0])
}
@@ -0,0 +1,385 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package entitlements_test
// A reorder on the runner (openspec change plan-act-obligations, tasks 7.1 to
// 7.3): T0 and its refusals, what the act records, the counts a reorder that
// reaches several org types owes, and the ownership of a type a default change
// takes from it. Each test builds its own ladder and org types, so the tests
// do not see each other's rows.
import (
"errors"
"fmt"
"slices"
"testing"
"time"
"github.com/google/uuid"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/world"
)
// reorderFixture is a ladder Main (Basic at rank 0, then Plus) that each of
// its org types defaults to, with perType organizations in each holding Basic
// through the default.
type reorderFixture struct {
t *testing.T
w *world.World
q *dbtest.Q
operator world.Person
basic world.Product
plus world.Product
main world.Ladder
types []string
orgs map[string][]world.Org
}
func newReorderFixture(t *testing.T, types, perType int) *reorderFixture {
t.Helper()
w := world.New(t, dbtest.Open(t))
f := &reorderFixture{t: t, w: w, q: dbtest.On(t, w.DB), operator: w.Person("Operator"), orgs: map[string][]world.Org{}}
f.basic, f.plus = w.Product("Basic"), w.Product("Plus")
f.main = w.Ladder("Main", f.basic, f.plus)
for i := range types {
key := w.OrgType(fmt.Sprintf("rt%d", i))
w.DefaultLadder(key, f.main)
f.types = append(f.types, key)
for j := range perType {
o := w.Org(fmt.Sprintf("Holder %d.%d", i, j), key, f.operator)
w.Floor(o)
f.orgs[key] = append(f.orgs[key], o)
}
}
return f
}
// reorder commits the order with the disposition under cfg.
func (f *reorderFixture) reorder(disposition string, cfg entitlements.PlanActConfig, order ...world.Product) (*entitlements.ReorderTiersResult, error) {
f.t.Helper()
ids := make([]string, len(order))
for i, p := range order {
ids[i] = p.ID
}
return entitlements.ReorderTiers(f.w.Ctx, f.w.DB, entitlements.ReorderTiersInput{
LadderID: f.main.ID,
Submitted: ids,
Disposition: disposition,
OperatorPersonID: f.operator.ID,
RequestID: "request-" + f.main.ID,
Acts: cfg,
})
}
// ranks is the ladder's products by rank.
func (f *reorderFixture) ranks() []string {
f.t.Helper()
return f.q.Strings(`SELECT product_id::text FROM core.plan_ladder_tiers WHERE plan_ladder_id = $1 ORDER BY rank`, f.main.ID)
}
// acts counts the reorders recorded for the ladder.
func (f *reorderFixture) acts() int64 {
f.t.Helper()
return f.q.Int(`SELECT count(*) FROM core.plan_act_details WHERE plan_ladder_id = $1 AND cause = 'tiers_reordered'`, f.main.ID)
}
// A reorder that commits with room to spare renumbers the ladder, records its
// act and the type it reaches, and lists and moves every organization in its
// own request: the grandfathered grants carry the act and the legacy
// description, and nothing is left owing.
func TestReorderTiersMovesWhatTheRequestReaches(t *testing.T) {
f := newReorderFixture(t, 1, 2)
res, err := f.reorder("grandfather", entitlements.PlanActConfig{}, f.plus, f.basic)
if err != nil {
t.Fatalf("ReorderTiers: %v", err)
}
if !res.RankZeroChanged || res.NewRankZero != f.plus.Name || res.Stale {
t.Errorf("the result is %+v, want a rank 0 changed to %s on a current page", res, f.plus.Name)
}
if want := f.q.String(`SELECT display_name FROM core.org_types WHERE org_type = $1`, f.types[0]); !slices.Equal(res.ReachedOrgTypes, []string{want}) {
t.Errorf("the reorder reached %q, want %q", res.ReachedOrgTypes, want)
}
if !res.Pass.Done || res.Pass.Moves.Grandfathered != 2 || res.Pass.Moves.Total() != 2 || res.Pass.Listed != 2 {
t.Errorf("the request's pass is %+v, want 2 listed and grandfathered and the act done", res.Pass)
}
if !res.CountsRead || res.Counts != (entitlements.PlanActCounts{}) {
t.Errorf("the counts are %+v (read %v), want none owed", res.Counts, res.CountsRead)
}
if got := f.ranks(); !slices.Equal(got, []string{f.plus.ID, f.basic.ID}) {
t.Errorf("the ladder reads %v, want Plus then Basic", got)
}
want := "Grandfathered: plan-ladder tier reorder (" + f.main.Name + ")"
if n := f.q.Int(`SELECT count(*) FROM core.grants WHERE act_id = $1 AND grant_reason = 'legacy' AND description = $2`, res.ActID, want); n != 2 {
t.Errorf("%d legacy grants carry the act and the description %q, want 2", n, want)
}
if got := f.q.String(`SELECT request_id FROM core.acts WHERE act_id = $1`, res.ActID); got != "request-"+f.main.ID {
t.Errorf("the act stores request id %q", got)
}
var disposition, previous string
f.q.Scan([]any{&disposition, &previous}, `SELECT disposition, previous_product_id::text FROM core.plan_act_details WHERE act_id = $1`, res.ActID)
if disposition != "grandfather" || previous != f.basic.ID {
t.Errorf("the detail records disposition %q and previous rank 0 %s, want grandfather and Basic %s", disposition, previous, f.basic.ID)
}
}
// T0 refuses without writing anything: an organization on the outgoing default
// with no disposition, a ladder with no tier, and limits the request refuses.
func TestReorderTiersRefusesWithoutWriting(t *testing.T) {
f := newReorderFixture(t, 1, 1)
t.Run("an organization on the outgoing default and no disposition", func(t *testing.T) {
_, err := f.reorder("", entitlements.PlanActConfig{}, f.plus, f.basic)
var needs *entitlements.DispositionRequiredError
if !errors.As(err, &needs) {
t.Fatalf("ReorderTiers returned %T %v, want a DispositionRequiredError", err, err)
}
if !slices.Equal(needs.OrgTypes, f.types) || needs.TargetProductID != f.plus.ID {
t.Errorf("the refusal names %v and %q, want %v and Plus %s", needs.OrgTypes, needs.TargetProductID, f.types, f.plus.ID)
}
if got := f.ranks(); !slices.Equal(got, []string{f.basic.ID, f.plus.ID}) {
t.Errorf("the refusal left the ladder as %v, want Basic then Plus", got)
}
if n := f.acts(); n != 0 {
t.Errorf("the refusal recorded %d acts", n)
}
})
t.Run("a ladder with no tier", func(t *testing.T) {
empty := f.w.Ladder("Empty")
_, err := entitlements.ReorderTiers(f.w.Ctx, f.w.DB, entitlements.ReorderTiersInput{
LadderID: empty.ID, Submitted: []string{f.plus.ID}, OperatorPersonID: f.operator.ID,
})
if !errors.Is(err, entitlements.ErrNoTiersToReorder) {
t.Errorf("ReorderTiers of an empty ladder returned %T %v, want ErrNoTiersToReorder", err, err)
}
})
t.Run("a submission that names none of the ladder's tiers", func(t *testing.T) {
_, err := f.reorder("", entitlements.PlanActConfig{}, f.w.Product("Elsewhere"), f.w.Product("Else"))
if !errors.Is(err, entitlements.ErrNoTiersToReorder) {
t.Fatalf("ReorderTiers returned %T %v, want ErrNoTiersToReorder", err, err)
}
if got := f.ranks(); !slices.Equal(got, []string{f.basic.ID, f.plus.ID}) {
t.Errorf("the refusal left the ladder as %v, want Basic then Plus", got)
}
if n := f.acts(); n != 0 {
t.Errorf("the refusal recorded %d acts", n)
}
})
t.Run("limits the request refuses", func(t *testing.T) {
cfg := entitlements.PlanActConfig{Limits: entitlements.PlanActLimits{LockTimeout: time.Second}}
if _, err := f.reorder("grandfather", cfg, f.plus, f.basic); err == nil {
t.Error("ReorderTiers accepted limits with no cap, budget, batch or deadline")
}
if got := f.ranks(); !slices.Equal(got, []string{f.basic.ID, f.plus.ID}) {
t.Errorf("the refusal left the ladder as %v, want Basic then Plus", got)
}
})
}
// A reorder that keeps rank 0 records its act and reaches no type, whatever
// disposition came with it: a disposition is the answer to a question the gate
// asks only of a change of rank 0.
func TestReorderTiersThatKeepsRankZeroReachesNoTypeAndRecordsNoDisposition(t *testing.T) {
f := newReorderFixture(t, 1, 1)
pro := f.w.Product("Pro")
f.w.AddTier(f.main, pro)
res, err := f.reorder("grandfather", entitlements.PlanActConfig{}, f.basic, pro, f.plus)
if err != nil {
t.Fatalf("ReorderTiers: %v", err)
}
if res.RankZeroChanged || len(res.ReachedOrgTypes) != 0 || res.Pass.Progress() {
t.Errorf("the result is %+v, want rank 0 kept, no type reached and no work done", res)
}
if got := f.ranks(); !slices.Equal(got, []string{f.basic.ID, pro.ID, f.plus.ID}) {
t.Errorf("the ladder reads %v, want Basic, Pro, Plus", got)
}
var disposition *string
f.q.Scan([]any{&disposition}, `SELECT disposition FROM core.plan_act_details WHERE act_id = $1`, res.ActID)
if disposition != nil {
t.Errorf("the detail records disposition %q for a reorder that kept rank 0", *disposition)
}
if n := f.q.Int(`SELECT count(*) FROM core.plan_act_reached_org_types WHERE act_id = $1`, res.ActID); n != 0 {
t.Errorf("the act reached %d types, want none", n)
}
if n := f.q.Int(`SELECT count(*) FROM core.plan_act_obligations WHERE act_id = $1`, res.ActID); n != 0 {
t.Errorf("the act owes %d obligations, want none", n)
}
}
// The toast's counts are read by the act's id, so a reorder that reaches two
// types counts both of them. The read by org type finds default changes only,
// which is why a reorder's request cannot take its counts from it.
func TestReorderTiersCountsEveryTypeItReaches(t *testing.T) {
f := newReorderFixture(t, 2, 2)
one := entitlements.PlanActConfig{Limits: entitlements.PlanActLimits{
LockTimeout: 5 * time.Second, Cap: 1, Budget: time.Minute, ListBatch: 500, Deadline: time.Minute,
}}
res, err := f.reorder("grandfather", one, f.plus, f.basic)
if err != nil {
t.Fatalf("ReorderTiers: %v", err)
}
if res.Pass.Done || res.Pass.Moves.Total() != 1 {
t.Errorf("the request's pass is %+v, want one move and the act not done", res.Pass)
}
if !res.CountsRead || res.Counts.NotYetReached != 3 || res.Counts.WithoutPool != 0 {
t.Errorf("the counts are %+v (read %v), want 3 not yet reached across both types", res.Counts, res.CountsRead)
}
if len(res.ReachedOrgTypes) != 2 {
t.Errorf("the reorder reached %q, want both types", res.ReachedOrgTypes)
}
open, err := entitlements.New(f.w.DB).ListOpenPlanActsByOrgTypes(f.w.Ctx, f.types)
if err != nil || len(open) != 0 {
t.Errorf("the read by org type returned %v (%v), want no row for a reorder", open, err)
}
}
// A reorder that reaches two types, followed by a default change of one of
// them, keeps the other: the default change owns its type from the moment it
// commits, and the reorder's lines, counts and listing stop at the type it
// still owns.
func TestAReorderKeepsTheTypeADefaultChangeDidNotTake(t *testing.T) {
f := newReorderFixture(t, 2, 2)
spent := entitlements.PlanActConfig{Limits: entitlements.PlanActLimits{
LockTimeout: 5 * time.Second, Cap: 1, Budget: time.Nanosecond, ListBatch: 500, Deadline: time.Minute,
}}
reorder, err := f.reorder("grandfather", spent, f.plus, f.basic)
if err != nil {
t.Fatalf("ReorderTiers: %v", err)
}
gold := f.w.Product("Gold")
next := f.w.Ladder("Next", gold)
taken := f.types[0]
change, err := entitlements.ChangeOrgTypeDefault(f.w.Ctx, f.w.DB, entitlements.DefaultChangeInput{
OrgType: taken, Ladder: uuid.NullUUID{UUID: uuid.MustParse(next.ID), Valid: true}, Disposition: "migrate",
OperatorPersonID: f.operator.ID, Acts: spent,
})
if err != nil {
t.Fatalf("ChangeOrgTypeDefault: %v", err)
}
q := entitlements.New(f.w.DB)
reached, err := q.ListPlanActReachedOrgTypes(f.w.Ctx, reorder.ActID)
if err != nil || len(reached) != 1 || reached[0].OrgType != f.types[1] {
t.Errorf("the reorder still owns %v (%v), want only %s", reached, err, f.types[1])
}
if owed, err := q.GetPlanActOwed(f.w.Ctx, reorder.ActID); err != nil || owed.Unlisted != 2 || owed.Pending != 0 {
t.Errorf("the reorder owes %+v (%v), want the 2 unlisted organizations of the type it kept", owed, err)
}
if owed, err := q.GetPlanActOwed(f.w.Ctx, change.ActID); err != nil || owed.Unlisted != 2 {
t.Errorf("the default change owes %+v (%v), want the 2 unlisted organizations of its type", owed, err)
}
// The reorder's listing lists the type it kept and nothing of the other.
listed, err := entitlements.ListPlanActBatch(f.w.Ctx, f.w.DB, entitlements.PlanActConfig{}, reorder.ActID, entitlements.Materializing, 500)
if err != nil || listed != 2 {
t.Fatalf("the reorder's batch listed %d (%v), want the 2 organizations of the type it kept", listed, err)
}
if n := f.q.Int(`SELECT count(*) FROM core.plan_act_obligations WHERE act_id = $1 AND org_type = $2`, reorder.ActID, taken); n != 0 {
t.Errorf("the reorder listed %d organizations of the type the default change took", n)
}
open, err := entitlements.ReadOpenPlanActsByLadder(f.w.Ctx, f.w.DB, entitlements.PlanActConfig{}, f.main.ID, 50)
if err != nil || len(open) != 1 || open[0].ActID != reorder.ActID || open[0].Pending != 2 || open[0].Unlisted != 0 {
t.Errorf("the ladder shows %+v (%v), want the reorder with the 2 organizations it kept pending", open, err)
}
}
// The ladder's open acts come newest first, each with its counts summed over
// the types it owns and the organizations it did not move, and a reorder that
// lost every type to a newer one is not shown.
func TestReadOpenPlanActsByLadderListsEachOwningReorderNewestFirst(t *testing.T) {
f := newReorderFixture(t, 2, 1)
spent := entitlements.PlanActConfig{Limits: entitlements.PlanActLimits{
LockTimeout: 5 * time.Second, Cap: 1, Budget: time.Nanosecond, ListBatch: 500, Deadline: time.Minute,
}}
first, err := f.reorder("grandfather", spent, f.plus, f.basic)
if err != nil {
t.Fatalf("the first reorder: %v", err)
}
// The first type leaves the ladder as its default, so the second reorder
// reaches the other type only and the first keeps the first type.
other := f.w.Ladder("Other", f.w.Product("Spare"))
f.w.DefaultLadder(f.types[0], other)
second, err := f.reorder("grandfather", spent, f.basic, f.plus)
if err != nil {
t.Fatalf("the second reorder: %v", err)
}
for _, act := range []string{first.ActID, second.ActID} {
if _, err := entitlements.ListPlanActBatch(f.w.Ctx, f.w.DB, entitlements.PlanActConfig{}, act, entitlements.Materializing, 500); err != nil {
t.Fatalf("list %s: %v", act, err)
}
}
// The first reorder's obligation on its type fails its last attempt.
failed := f.orgs[f.types[0]][0]
for range entitlements.PlanActMaxAttempts {
if _, err := entitlements.New(f.w.DB).RecordPlanActObligationFailure(f.w.Ctx, entitlements.RecordPlanActObligationFailureParams{
ActID: first.ActID, PoolID: failed.PoolID, Step: "begin", Error: "boom"}); err != nil {
t.Fatalf("fail the obligation: %v", err)
}
}
open, err := entitlements.ReadOpenPlanActsByLadder(f.w.Ctx, f.w.DB, entitlements.PlanActConfig{}, f.main.ID, 50)
if err != nil {
t.Fatalf("ReadOpenPlanActsByLadder: %v", err)
}
if len(open) != 2 || open[0].ActID != second.ActID || open[1].ActID != first.ActID {
t.Fatalf("the ladder shows %+v, want the second reorder then the first", open)
}
if open[0].Pending != 1 || open[0].Failed != 0 || open[0].OrgType != "" {
t.Errorf("the second reorder shows %+v, want its one pending organization", open[0])
}
if open[1].Failed != 1 || open[1].Pending != 0 || len(open[1].Unmoved) != 1 || open[1].Unmoved[0].OrgID != failed.ID || open[1].Unmoved[0].Step != entitlements.ObligationBegin {
t.Errorf("the first reorder shows %+v, want its failed organization named with its step", open[1])
}
// A default change of the second type takes it over: the second reorder
// owns nothing and drops off the page.
if _, err := entitlements.ChangeOrgTypeDefault(f.w.Ctx, f.w.DB, entitlements.DefaultChangeInput{
OrgType: f.types[1], Ladder: uuid.NullUUID{UUID: uuid.MustParse(other.ID), Valid: true}, Disposition: "migrate",
OperatorPersonID: f.operator.ID, Acts: spent,
}); err != nil {
t.Fatalf("ChangeOrgTypeDefault: %v", err)
}
open, err = entitlements.ReadOpenPlanActsByLadder(f.w.Ctx, f.w.DB, entitlements.PlanActConfig{}, f.main.ID, 50)
if err != nil || len(open) != 1 || open[0].ActID != first.ActID {
t.Errorf("after the default change the ladder shows %+v (%v), want only the first reorder", open, err)
}
}
// ReconcileTierOrder drops a product that is no tier and a repeat, appends the
// tiers the submission lacks in their current order, and says when it had to.
func TestReconcileTierOrder(t *testing.T) {
tiers := []billing.PlanLadderTier{
{ProductID: "a", Rank: 0}, {ProductID: "b", Rank: 1}, {ProductID: "c", Rank: 2},
}
for _, tc := range []struct {
name string
submitted []string
want []string
rankZero string
stale bool
}{
{"the current order", []string{"a", "b", "c"}, []string{"a", "b", "c"}, "a", false},
{"a new order", []string{"c", "a", "b"}, []string{"c", "a", "b"}, "a", false},
{"a tier missing", []string{"c", "a"}, []string{"c", "a", "b"}, "a", true},
{"a product that is no tier", []string{"x", "b", "a", "c"}, []string{"b", "a", "c"}, "a", true},
{"a repeat", []string{"b", "b", "a", "c"}, []string{"b", "a", "c"}, "a", true},
{"nothing submitted", nil, []string{"a", "b", "c"}, "a", true},
} {
t.Run(tc.name, func(t *testing.T) {
got, rankZero, stale := entitlements.ReconcileTierOrder(tiers, tc.submitted)
if !slices.Equal(got, tc.want) || rankZero != tc.rankZero || stale != tc.stale {
t.Errorf("ReconcileTierOrder(%v) = %v, %q, %v, want %v, %q, %v", tc.submitted, got, rankZero, stale, tc.want, tc.rankZero, tc.stale)
}
})
}
if got, _, _ := entitlements.ReconcileTierOrder(nil, []string{"a"}); len(got) != 0 {
t.Errorf("a ladder with no tier reconciled to %v, want nothing", got)
}
}
+2
View File
@@ -77,6 +77,8 @@ func init() {
{Label: "Preview removal", Method: "POST", Path: "/partials/operator/plan-ladders/{ladderID}/tiers/{productID}/remove/preview"},
{Label: "Remove tier", Method: "DELETE", Path: "/partials/operator/plan-ladders/{ladderID}/tiers/{productID}", Modal: true},
{Label: "Delete ladder", Method: "DELETE", Path: "/partials/operator/plan-ladders/{ladderID}", Modal: true},
{Label: "Retry", Method: "POST", Path: "/partials/operator/plan-ladders/{ladderID}/plan-acts/{actID}/retry",
Note: "fired beside the ladder page's organizations not moved; returns that reorder's failed obligations to pending and redispatches the drain"},
// Operator: the org-type default's live preview, fired by the
// select and the two disposition radios on change. It reads; the
@@ -0,0 +1,145 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
// The ladder page's lines (openspec change plan-act-obligations, task 7.2 and
// 7.3): the open work of the reorders that own an org type, in each state it
// can be in. They are the lines of the org type card, so these tests pin what
// the ladder page adds: where each Retry posts and what it swaps.
import (
"fmt"
"strings"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/htmltest"
)
// ladderWithLines renders the ladder page with the lines of the reorders given.
func ladderWithLines(t *testing.T, acts ...entitlements.OpenPlanAct) *htmltest.Node {
t.Helper()
view := PlanActLinesView{Target: "#operator-body", Swap: "innerHTML"}
for _, a := range acts {
view.Acts = append(view.Acts, planActLineView(a, "/partials/operator/plan-ladders/l-1/plan-acts/"+a.ActID+"/retry"))
}
return renderLadderWith(t, view)
}
func renderLadderWith(t *testing.T, view PlanActLinesView) *htmltest.Node {
t.Helper()
return htmltest.Parse(t, Render(t, OperatorSet(t), "operator_plan_ladder_tiers.html", PlanLadderTiersData{
Ladder: PlanLadderViewModel{PlanLadderID: "l-1", Name: "Hosting"},
PlanActs: view,
}))
}
func TestLadderPageLinesPendingOnly(t *testing.T) {
page := ladderWithLines(t, entitlements.OpenPlanAct{ActID: "act-1", Pending: 3})
block := page.One(t, "#plan-act-act-1")
if got := block.Text(); got != "3 organizations not yet reached." {
t.Errorf("the lines read %q, want only the not-yet-reached line", got)
}
block.None(t, "button")
}
func TestLadderPageLinesFailedOnlyRetryReplacesThePage(t *testing.T) {
page := ladderWithLines(t, entitlements.OpenPlanAct{ActID: "act-1", Failed: 1,
Unmoved: []entitlements.UnmovedOrganization{{OrgID: "o1", OrgName: "Acme", Step: entitlements.ObligationInitiate}}})
block := page.One(t, "#plan-act-act-1")
button := block.One(t, "button")
for attr, want := range map[string]string{
"hx-post": "/partials/operator/plan-ladders/l-1/plan-acts/act-1/retry",
"hx-target": "#operator-body",
"hx-swap": "innerHTML",
} {
if got, _ := button.Attr(attr); got != want {
t.Errorf("Retry %s = %q, want %q", attr, got, want)
}
}
if got := block.One(t, "p.text-danger").Text(); !strings.HasPrefix(got, "1 organization not moved.") {
t.Errorf("the danger line reads %q", got)
}
if got := block.One(t, "li").Text(); !strings.HasPrefix(got, "Acme: ") {
t.Errorf("the organization's line reads %q", got)
}
}
func TestLadderPageLinesBoth(t *testing.T) {
page := ladderWithLines(t, entitlements.OpenPlanAct{ActID: "act-1", Pending: 2, Failed: 1,
Unmoved: []entitlements.UnmovedOrganization{{OrgID: "o1", OrgName: "Acme", Step: entitlements.ObligationBegin}}})
block := page.One(t, "#plan-act-act-1")
htmltest.InOrder(t, block.One(t, "p:first-child"), block.One(t, "p.text-danger"))
if got := block.One(t, "p:first-child").Text(); got != "2 organizations not yet reached." {
t.Errorf("the first line reads %q", got)
}
}
func TestLadderPageLinesNameFiftyAndCountTheRest(t *testing.T) {
var unmoved []entitlements.UnmovedOrganization
for i := range planActUnmovedLimit {
unmoved = append(unmoved, entitlements.UnmovedOrganization{OrgID: fmt.Sprintf("o%d", i), OrgName: fmt.Sprintf("Org %02d", i), Step: entitlements.ObligationCommit})
}
page := ladderWithLines(t, entitlements.OpenPlanAct{ActID: "act-1", Failed: 70, Unmoved: unmoved})
block := page.One(t, "#plan-act-act-1")
if n := block.Count("li a"); n != 50 {
t.Errorf("%d organizations named, want 50", n)
}
items := block.All("li")
if got := items[len(items)-1].Text(); got != "And 20 more." {
t.Errorf("the list ends %q, want And 20 more.", got)
}
}
func TestLadderPageLinesCountOrganizationsNotYetListed(t *testing.T) {
page := ladderWithLines(t, entitlements.OpenPlanAct{ActID: "act-1", Pending: 450, Unlisted: 9500})
if got := page.One(t, "#plan-act-act-1").Text(); got != "9,950 organizations not yet reached." {
t.Errorf("the lines read %q", got)
}
}
// Two reorders read as two blocks in the order given, each with its own Retry
// and its own reference.
func TestLadderPageLinesKeepTwoActsApart(t *testing.T) {
failed := func(id string) entitlements.OpenPlanAct {
return entitlements.OpenPlanAct{ActID: id, Failed: 1,
Unmoved: []entitlements.UnmovedOrganization{{OrgID: "o-" + id, OrgName: "Org " + id, Step: entitlements.ObligationCommit}}}
}
page := ladderWithLines(t, failed("act-2"), failed("act-1"))
htmltest.InOrder(t, page.One(t, "#plan-act-act-2"), page.One(t, "#plan-act-act-1"))
for _, id := range []string{"act-1", "act-2"} {
block := page.One(t, "#plan-act-"+id)
if n := block.Count("button"); n != 1 {
t.Errorf("%s has %d Retry buttons, want 1", id, n)
}
if got, _ := block.One(t, "button").Attr("hx-post"); !strings.Contains(got, "/"+id+"/retry") {
t.Errorf("%s Retry posts to %q", id, got)
}
if !strings.Contains(block.One(t, "p.text-muted").Text(), id) {
t.Errorf("%s reference does not name the act", id)
}
}
}
func TestLadderPageShowsTheReadFailureInPlaceOfItsLinesRendered(t *testing.T) {
page := renderLadderWith(t, PlanActLinesView{Error: "Pending plan changes could not be read. Reference: req-7."})
if got := page.One(t, `p[role="alert"]`).Text(); got != "Pending plan changes could not be read. Reference: req-7." {
t.Errorf("the failure reads %q", got)
}
page.None(t, `[id^="plan-act-"]`)
}
func TestLadderPageWithNoOpenReorderRendersNoLinesRendered(t *testing.T) {
page := renderLadderWith(t, PlanActLinesView{})
page.None(t, `[id^="plan-act-"]`)
page.None(t, `p[role="alert"]`)
}
@@ -0,0 +1,500 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server_test
// The ladder page's lines and its Retry route (openspec change
// plan-act-obligations, tasks 7.2 and 7.3): the lines the page reads from the
// database for the reorders that own an org type, the sentence that stands in
// their place when they cannot be read, and what each refusal and failure of
// Retry answers. The story of a failed organization moved by Retry is
// TestTierReorder_GrandfatherCommitAndRerun.
import (
"context"
"database/sql"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/google/uuid"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/htmltest"
)
// failedReorder commits a reorder of ladder A that makes a fresh product its
// rank 0, whose one stuck organization's move fails until its obligation is
// failed, and returns the act, the stuck pool and the new rank-0 product. A
// second organization is moved normally.
func failedReorder(t *testing.T, database *sql.DB, f otcFixture) (actID, stuckPool, rankZero string, acts entitlements.PlanActConfig) {
t.Helper()
rankZero = trAddTier(t, database, f.ladderA, "fr-c-"+uuid.NewString()[:8])
pools := otcHolders(t, database, f, 2, false)
stuckPool = pools[0]
acts = refusingActs(planActLimits(50, time.Minute), refusingEntitlementsQ{brokenPool: stuckPool})
h := newOtcHarnessActs(t, database, f.operatorID, acts)
if rec := reorderTo(h, f, rankZero, "migrate"); rec.Code != http.StatusOK {
t.Fatalf("commit status=%d body=%s", rec.Code, rec.Body)
}
if err := database.QueryRowContext(context.Background(),
`SELECT act_id::text FROM core.plan_act_details WHERE plan_ladder_id = $1 AND cause = 'tiers_reordered'`, f.ladderA).Scan(&actID); err != nil {
t.Fatalf("read the act: %v", err)
}
drainUntilFailed(t, database, acts, actID, stuckPool)
return actID, stuckPool, rankZero, acts
}
// reorderTo commits the order that puts rankZero before ladder A's first
// product, with the disposition when one is given.
func reorderTo(h *otcHarness, f otcFixture, rankZero, disposition string) *httptest.ResponseRecorder {
form := url.Values{"order": {rankZero + "," + f.prodA}}
if disposition != "" {
form.Set("needs_disposition", "true")
form.Set("bucket2_disposition", disposition)
}
return h.postLadderRecorded(h.handler.ReorderPlanLadderTiers, f.ladderA, form)
}
// ladderRetry presses Retry of the act on ladder.
func ladderRetry(h *otcHarness, ladderID, actID string) (int, string) {
rec := h.postPath(h.handler.RetryLadderPlanAct, map[string]string{"ladderID": ladderID, "actID": actID}, url.Values{})
return rec.Code, rec.Body.String()
}
// ladderPage renders the ladder page the way a drop on it does: the composite
// with its lines.
func ladderPage(h *otcHarness, ladderID string, order ...string) string {
_, body, _ := h.postLadder(h.handler.PreviewPlanLadderTiersReorder, ladderID, url.Values{"product": order})
return body
}
func ladderRetryActs(t *testing.T, database *sql.DB, actID string) int {
t.Helper()
return otcScalar(t, database, `SELECT count(*) FROM core.plan_act_details WHERE retried_act_id = $1`, actID)
}
// The page reads the lines of the reorder that owns an org type from the
// database: the organization that was not moved with its sentence, the act's
// reference and a Retry that swaps the whole page.
func TestLadderPageReadsItsLinesFromTheAct(t *testing.T) {
database := dbtest.Open(t)
f := newOtcFixture(t, database)
actID, _, rankZero, acts := failedReorder(t, database, f)
h := newOtcHarnessActs(t, database, f.operatorID, acts)
body := ladderPage(h, f.ladderA, rankZero, f.prodA)
lines := htmltest.Parse(t, body).One(t, "#plan-act-"+actID)
text := lines.Text()
for _, want := range []string{"1 organization not moved.", "OTC Holder A: The move could not be completed.", "Reference: " + actID} {
if !strings.Contains(text, want) {
t.Errorf("the lines read %q, want %q", text, want)
}
}
if strings.Contains(text, "not yet reached") {
t.Errorf("the lines read %q: the settled organization is not owed", text)
}
retry := lines.One(t, "button")
if hx, _ := retry.Attr("hx-post"); hx != "/partials/operator/plan-ladders/"+f.ladderA+"/plan-acts/"+actID+"/retry" {
t.Errorf("Retry posts to %q", hx)
}
if target, _ := retry.Attr("hx-target"); target != "#operator-body" {
t.Errorf("Retry swaps %q, want the page's body", target)
}
if swap, _ := retry.Attr("hx-swap"); swap != "innerHTML" {
t.Errorf("Retry swaps by %q, want innerHTML as every act on the page does", swap)
}
if strings.Contains(htmltest.Parse(t, body).Text(), "Pending plan changes could not be read") {
t.Errorf("the page says its lines could not be read")
}
}
// A ladder no reorder owes work on renders no lines.
func TestLadderPageWithNoOpenActRendersNoLines(t *testing.T) {
database := dbtest.Open(t)
f := newOtcFixture(t, database)
h := newOtcHarness(t, database, f.operatorID)
page := htmltest.Parse(t, ladderPage(h, f.ladderA, f.prodA))
page.None(t, `[id^="plan-act-"]`)
if strings.Contains(page.Text(), "Pending plan changes could not be read") {
t.Errorf("the page says its lines could not be read")
}
}
// Lines that cannot be read give the page the sentence naming the request in
// their place, and the error is logged through the request's logger with the
// same id.
func TestLadderPageShowsTheReadFailureWithTheRequestID(t *testing.T) {
database := dbtest.Open(t)
f := newOtcFixture(t, database)
acts := refusingActs(planActLimits(50, time.Minute), refusingEntitlementsQ{listOpenLadder: true})
h := newOtcHarnessActs(t, database, f.operatorID, acts)
rec := h.postLadderRecorded(h.handler.PreviewPlanLadderTiersReorder, f.ladderA, url.Values{"product": {f.prodA}})
body := rec.Body.String()
id := referenceIn(t, body)
if want := "Pending plan changes could not be read. Reference: " + id + "."; !strings.Contains(body, want) {
t.Errorf("the page lacks %q: %.600s", want, body)
}
if strings.Contains(body, `id="plan-act-`) {
t.Errorf("the page renders lines beside the sentence that says they could not be read")
}
if logs := h.logs.String(); !strings.Contains(logs, "request_id="+id) || !strings.Contains(logs, "level=ERROR") || !strings.Contains(logs, "open acts unread") {
t.Errorf("the failed read is not logged with the request id %s: %s", id, logs)
}
}
// An act that was not made for the ladder the Retry was pressed on, a default
// change among them, is not retried: the page is re-rendered and nothing is
// written.
func TestRetryLadderPlanActRefusesAnActOfAnotherLadderOrAnotherKind(t *testing.T) {
database := dbtest.Open(t)
f := newOtcFixture(t, database)
actID, _, _, acts := failedReorder(t, database, f)
h := newOtcHarnessActs(t, database, f.operatorID, acts)
code, body := ladderRetry(h, f.ladderB, actID)
if code != http.StatusOK || !strings.Contains(body, f.ladderB) {
t.Errorf("the answer is %d %.300s, want the other ladder's page", code, body)
}
if n := ladderRetryActs(t, database, actID); n != 0 {
t.Errorf("%d Retry acts written for a reorder of another ladder", n)
}
// A default change of an org type that also names ladder A (its new default)
// is not a reorder of it.
other := newOtcFixture(t, database)
_, pool := otcOrg(t, database, other, "OTC Planless", true)
_ = pool
plain := newOtcHarness(t, database, other.operatorID)
if rec := commitB(plain, other, ""); rec.Code != http.StatusOK {
t.Fatalf("commit status=%d", rec.Code)
}
var change string
if err := database.QueryRowContext(context.Background(), `SELECT act_id::text FROM core.plan_act_details WHERE org_type = $1`, other.orgType).Scan(&change); err != nil {
t.Fatalf("read the default change: %v", err)
}
code, _ = ladderRetry(plain, other.ladderB, change)
if code != http.StatusOK {
t.Errorf("the answer is %d, want the page", code)
}
if n := ladderRetryActs(t, database, change); n != 0 {
t.Errorf("%d Retry acts written through the ladder page for a default change", n)
}
if n := otcScalar(t, database, `SELECT count(*) FROM core.plan_act_obligations WHERE act_id = $1 AND status = 'failed'`, actID); n != 1 {
t.Errorf("%d failed obligations, want the one still failed", n)
}
}
// A Retry with nothing failed to return writes no act and re-renders the page.
func TestRetryLadderPlanActWithNothingFailedWritesNothing(t *testing.T) {
database := dbtest.Open(t)
f := newOtcFixture(t, database)
rankZero := trAddTier(t, database, f.ladderA, "nf-c-"+uuid.NewString()[:8])
otcHolders(t, database, f, 2, false)
h := newOtcHarness(t, database, f.operatorID)
if rec := reorderTo(h, f, rankZero, "migrate"); rec.Code != http.StatusOK {
t.Fatalf("commit status=%d body=%.300s", rec.Code, rec.Body)
}
var actID string
if err := database.QueryRowContext(context.Background(),
`SELECT act_id::text FROM core.plan_act_details WHERE plan_ladder_id = $1 AND cause = 'tiers_reordered'`, f.ladderA).Scan(&actID); err != nil {
t.Fatalf("read the act: %v", err)
}
code, body := ladderRetry(h, f.ladderA, actID)
if code != http.StatusOK || !strings.Contains(body, f.ladderA) {
t.Errorf("the answer is %d %.300s, want the page", code, body)
}
if n := ladderRetryActs(t, database, actID); n != 0 {
t.Errorf("%d Retry acts, want none: a Retry that returns nothing writes no act", n)
}
}
// A malformed ladder or act id is a missing page, and nothing is read or
// written.
func TestRetryLadderPlanActWithAMalformedIDIsNotFound(t *testing.T) {
database := dbtest.Open(t)
f := newOtcFixture(t, database)
h := newOtcHarness(t, database, f.operatorID)
for _, ids := range []map[string]string{
{"ladderID": f.ladderA, "actID": "not-an-act"},
{"ladderID": "not-a-ladder", "actID": uuid.NewString()},
} {
rec := h.postPath(h.handler.RetryLadderPlanAct, ids, url.Values{})
if rec.Code != http.StatusNotFound {
t.Errorf("Retry of %v: status = %d, want %d", ids, rec.Code, http.StatusNotFound)
}
}
}
// A Retry that fails answers "Failed to retry." with the id of the request its
// log line carries, and the failed obligation stays failed.
func TestRetryLadderPlanActFailureCarriesTheRequestID(t *testing.T) {
database := dbtest.Open(t)
f := newOtcFixture(t, database)
actID, stuckPool, _, _ := failedReorder(t, database, f)
acts := refusingActs(planActLimits(50, time.Minute), refusingEntitlementsQ{brokenPool: stuckPool, requeue: true})
h := newOtcHarnessActs(t, database, f.operatorID, acts)
_, body := ladderRetry(h, f.ladderA, actID)
id := referenceIn(t, body)
if want := "Failed to retry. Reference: " + id + "."; !strings.Contains(body, want) {
t.Errorf("the page lacks %q: %.600s", want, body)
}
if logs := h.logs.String(); !strings.Contains(logs, "request_id="+id) || !strings.Contains(logs, "level=ERROR") || !strings.Contains(logs, "requeue refused") {
t.Errorf("the failure is not logged with the request id %s: %s", id, logs)
}
if n := ladderRetryActs(t, database, actID); n != 0 {
t.Errorf("%d Retry acts committed by a Retry whose requeue failed", n)
}
if n := otcScalar(t, database, `SELECT count(*) FROM core.plan_act_obligations WHERE act_id = $1 AND status = 'failed'`, actID); n != 1 {
t.Errorf("%d failed obligations, want the one still failed", n)
}
}
// A Retry that waits for the rendezvous past the request's deadline answers the
// contention refusal and writes nothing.
func TestRetryLadderPlanActBehindAnExclusiveHolderAnswersTheContentionRefusal(t *testing.T) {
database := dbtest.Open(t)
f := newOtcFixture(t, database)
actID, stuckPool, _, _ := failedReorder(t, database, f)
acts := refusingActs(planActLimits(50, 300*time.Millisecond), refusingEntitlementsQ{brokenPool: stuckPool})
h := newOtcHarnessActs(t, database, f.operatorID, acts)
rc, err := entitlements.BeginRuleChange(context.Background(), database)
if err != nil {
t.Fatalf("hold the exclusive rendezvous: %v", err)
}
defer rc.Rollback()
began := time.Now()
code, body := ladderRetry(h, f.ladderA, actID)
if elapsed := time.Since(began); elapsed > 3*time.Second {
t.Errorf("the Retry waited %v, want the 300 ms deadline to end it", elapsed)
}
if code != http.StatusOK || !strings.Contains(body, "Another change collided with this one; try again.") {
t.Errorf("the answer is %d %.400s, want the page with the contention refusal", code, body)
}
if n := ladderRetryActs(t, database, actID); n != 0 {
t.Errorf("%d Retry acts written behind an exclusive holder", n)
}
}
// A Retry of a reorder that reached two types, one of which a default change
// has since taken, returns only the failed organization of the type the
// reorder still owns; the default change's listing supersedes the other row
// without waiting for the Retry.
func TestRetryLadderPlanActRequeuesOnlyTheTypeTheReorderStillOwns(t *testing.T) {
database := dbtest.Open(t)
ctx := context.Background()
f := newOtcFixture(t, database)
second := newOtcFixture(t, database)
rankZero := trAddTier(t, database, f.ladderA, "two-c-"+uuid.NewString()[:8])
_, firstPool := otcOrg(t, database, f, "OTC First", true)
otcConferDefaultA(t, database, f, firstPool)
_, secondPool := otcOrg(t, database, second, "OTC Second", true)
otcConferDefaultA(t, database, second, secondPool)
// The second type defaults to ladder A as well, so the reorder reaches both.
if _, err := organization.New(database).UpdateOrgTypeDefaultPlanLadder(ctx, organization.UpdateOrgTypeDefaultPlanLadderParams{
OrgType: second.orgType, DefaultPlanLadderID: uuid.NullUUID{UUID: uuid.MustParse(f.ladderA), Valid: true},
}); err != nil {
t.Fatalf("set the second type's default: %v", err)
}
// Every move fails at the read of the default, so both rows go to failed.
broken := refusingEntitlementsQ{brokenPool: firstPool, alsoBroken: secondPool}
acts := refusingActs(planActLimits(50, time.Minute), broken)
failing := newOtcHarnessActs(t, database, f.operatorID, acts)
if rec := reorderTo(failing, f, rankZero, "migrate"); rec.Code != http.StatusOK {
t.Fatalf("commit status=%d body=%.300s", rec.Code, rec.Body)
}
var actID string
if err := database.QueryRowContext(ctx, `SELECT act_id::text FROM core.plan_act_details WHERE plan_ladder_id = $1 AND cause = 'tiers_reordered'`, f.ladderA).Scan(&actID); err != nil {
t.Fatalf("read the act: %v", err)
}
drainUntilFailed(t, database, acts, actID, firstPool)
drainUntilFailed(t, database, acts, actID, secondPool)
if n := otcScalar(t, database, `SELECT count(*) FROM core.plan_act_obligations WHERE act_id = $1 AND status = 'failed'`, actID); n != 2 {
t.Fatalf("%d failed obligations, want one on each type", n)
}
// A default change of the first type takes it over; the Retry requeues only
// the second type's row.
plain := newOtcHarness(t, database, f.operatorID)
if rec := commitB(plain, f, "migrate"); rec.Code != http.StatusOK || !strings.Contains(rec.Header().Get("HX-Trigger"), "showSuccessToast") {
t.Fatalf("the default change: status=%d trigger=%q", rec.Code, rec.Header().Get("HX-Trigger"))
}
code, _ := ladderRetry(plain, f.ladderA, actID)
if code != http.StatusOK {
t.Fatalf("Retry status = %d", code)
}
if n := ladderRetryActs(t, database, actID); n != 1 {
t.Errorf("%d Retry acts, want 1", n)
}
if got := otcScalar(t, database, `SELECT count(*) FROM core.plan_act_obligations WHERE act_id = $1 AND pool_id = $2 AND requeued_by_act_id IS NOT NULL`, actID, secondPool); got != 1 {
t.Errorf("the second type's row was not requeued")
}
if got := otcScalar(t, database, `SELECT count(*) FROM core.plan_act_obligations WHERE act_id = $1 AND pool_id = $2 AND requeued_by_act_id IS NOT NULL`, actID, firstPool); got != 0 {
t.Errorf("the row of the type the default change took was requeued")
}
if got := otcScalar(t, database, `SELECT count(*) FROM core.plan_act_obligations WHERE act_id = $1 AND pool_id = $2 AND status = 'superseded'`, actID, firstPool); got != 1 {
t.Errorf("the reorder's row on the type the default change took is not superseded")
}
}
// ListOrgTypes failing is not "no org type defaults to this ladder": the tier
// card shows the sentence with the request id in place of its default badges,
// and logs the failure with the same id.
func TestTierCardShowsTheOrgTypesFailureInPlaceOfItsBadges(t *testing.T) {
database := dbtest.Open(t)
f := newOtcFixture(t, database)
otcOrgTypeDefaultsToA(t, database, f)
h := newOtcHarnessWith(t, database, f.operatorID, otcOptions{orgQ: orgTypesUnread{Querier: organization.New(database)}})
rec := h.postLadderRecorded(h.handler.PreviewPlanLadderTiersReorder, f.ladderA, url.Values{"product": {f.prodA}})
body := rec.Body.String()
id := referenceIn(t, body)
sentence := "Failed to read the org types that default to this ladder. Reference: " + id + "."
row := htmltest.Parse(t, body).One(t, "tbody tr")
if !strings.Contains(row.Text(), sentence) {
t.Errorf("the rank-0 row reads %q, want %q", row.Text(), sentence)
}
var display string
if err := database.QueryRowContext(context.Background(), `SELECT display_name FROM core.org_types WHERE org_type = $1`, f.orgType).Scan(&display); err != nil {
t.Fatalf("read the org type's name: %v", err)
}
if strings.Contains(row.Text(), display) {
t.Errorf("the rank-0 row reads %q beside the sentence: a failed read is not a badge", row.Text())
}
if logs := h.logs.String(); !strings.Contains(logs, "request_id="+id) || !strings.Contains(logs, "level=ERROR") || !strings.Contains(logs, "org types unavailable") {
t.Errorf("the failed read is not logged with the request id %s: %s", id, logs)
}
}
// The reorder preview shows the same sentence in place of its consequence panel
// and offers no commit: nothing it could apply is known.
func TestReorderPreviewShowsTheOrgTypesFailureAndOffersNoCommit(t *testing.T) {
database := dbtest.Open(t)
f := newOtcFixture(t, database)
otcOrgTypeDefaultsToA(t, database, f)
prodC := trAddTier(t, database, f.ladderA, "ou-c-"+uuid.NewString()[:8])
h := newOtcHarnessWith(t, database, f.operatorID, otcOptions{orgQ: orgTypesUnread{Querier: organization.New(database)}})
rec := h.postLadderRecorded(h.handler.PreviewPlanLadderTiersReorder, f.ladderA, url.Values{"product": {prodC, f.prodA}})
body := rec.Body.String()
id := referenceIn(t, body)
page := htmltest.Parse(t, body)
form := page.One(t, `[data-form="operator.plan-ladder.tier.reorder.commit"]`)
if got := form.Text(); !strings.Contains(got, "Failed to read the org types that default to this ladder. Reference: "+id+".") {
t.Errorf("the preview reads %q, want the sentence", got)
}
for _, banned := range []string{"Rank 0 changes from", "Display order only", "Apply change"} {
if strings.Contains(form.Text(), banned) {
t.Errorf("the preview reads %q beside the sentence that says the types could not be read", banned)
}
}
form.None(t, `button[type="submit"]`)
form.None(t, `input[name="bucket2_disposition"]`)
if r := trRank(t, database, f.ladderA, f.prodA); r != 0 {
t.Errorf("prodA rank = %d after the preview, want 0 (read-only)", r)
}
}
// A commit the gate refuses, whose rebuilt preview cannot read the org types,
// answers as the preview does: the sentence with the request id, no commit, and
// no false error about an outgoing default the rebuild lacks.
func TestReorderCommitRefusedByTheGateShowsTheOrgTypesFailureAndOffersNoCommit(t *testing.T) {
database := dbtest.Open(t)
f := newOtcFixture(t, database)
prodC := trAddTier(t, database, f.ladderA, "og-c-"+uuid.NewString()[:8])
_, pool := otcOrg(t, database, f, "OG Holder", true)
otcConferDefaultA(t, database, f, pool)
h := newOtcHarnessWith(t, database, f.operatorID, otcOptions{orgQ: orgTypesUnread{Querier: organization.New(database)}})
rec := h.postLadderRecorded(h.handler.ReorderPlanLadderTiers, f.ladderA, url.Values{"order": {prodC + "," + f.prodA}})
body := rec.Body.String()
if rec.Code != http.StatusUnprocessableEntity {
t.Errorf("status = %d, want 422", rec.Code)
}
id := referenceIn(t, body)
form := htmltest.Parse(t, body).One(t, `[data-form="operator.plan-ladder.tier.reorder.commit"]`)
if got := form.Text(); !strings.Contains(got, "Failed to read the org types that default to this ladder. Reference: "+id+".") {
t.Errorf("the 422 reads %q, want the sentence", got)
}
form.None(t, `button[type="submit"]`)
form.None(t, `input[name="bucket2_disposition"]`)
if strings.Contains(body, "Failed to reorder tiers.") {
t.Errorf("the 422 answers the generic failure: %.500s", body)
}
if logs := h.logs.String(); strings.Contains(logs, "disposition gate found an outgoing default") || strings.Count(logs, "failed to list org types for the reorder preview") != 1 || !strings.Contains(logs, "request_id="+id) {
t.Errorf("the failed read is not logged once, with request id %s,: %s", id, logs)
}
if r := trRank(t, database, f.ladderA, f.prodA); r != 0 {
t.Errorf("prodA rank = %d, want 0: nothing was applied", r)
}
}
// otcOrgTypeDefaultsToA makes the fixture's org type default to ladder A.
func otcOrgTypeDefaultsToA(t *testing.T, database *sql.DB, f otcFixture) {
t.Helper()
if _, err := organization.New(database).UpdateOrgTypeDefaultPlanLadder(context.Background(), organization.UpdateOrgTypeDefaultPlanLadderParams{
OrgType: f.orgType, DefaultPlanLadderID: uuid.NullUUID{UUID: uuid.MustParse(f.ladderA), Valid: true},
}); err != nil {
t.Fatalf("set the default to ladder A: %v", err)
}
}
// The reorder preview's classification failure takes the reference form (copy
// 57) and names the request id its log line carries; the commit's 422, rebuilt
// after T0's gate refused, says the same and applies nothing.
func TestReorderClassificationFailureCarriesTheRequestIDOfItsLogLine(t *testing.T) {
database := dbtest.Open(t)
f := newOtcFixture(t, database)
prodC := trAddTier(t, database, f.ladderA, "cf-c-"+uuid.NewString()[:8])
_, pool := otcOrg(t, database, f, "CF Holder", true)
otcConferDefaultA(t, database, f, pool)
opts := func() otcOptions {
return otcOptions{entitlementsQ: refusingEntitlementsQ{Querier: entitlements.New(database), listBuckets: true}}
}
const sentence = "Failed to classify affected organizations. Reference: "
for name, run := range map[string]func(h *otcHarness) *httptest.ResponseRecorder{
"preview": func(h *otcHarness) *httptest.ResponseRecorder {
return h.postLadderRecorded(h.handler.PreviewPlanLadderTiersReorder, f.ladderA, url.Values{"product": {prodC, f.prodA}})
},
"commit": func(h *otcHarness) *httptest.ResponseRecorder {
return h.postLadderRecorded(h.handler.ReorderPlanLadderTiers, f.ladderA, url.Values{"order": {prodC + "," + f.prodA}})
},
} {
t.Run(name, func(t *testing.T) {
h := newOtcHarnessWith(t, database, f.operatorID, opts())
body := run(h).Body.String()
if !strings.Contains(body, sentence) {
t.Fatalf("the answer lacks %q: %.500s", sentence, body)
}
id := referenceIn(t, body[strings.Index(body, sentence):])
if !strings.Contains(h.logs.String(), "level=ERROR") || !strings.Contains(h.logs.String(), "request_id="+id) {
t.Errorf("no ERROR line carries request_id=%s: %s", id, h.logs)
}
if r := trRank(t, database, f.ladderA, f.prodA); r != 0 {
t.Errorf("prodA rank = %d, want 0: nothing was applied", r)
}
})
}
}
@@ -62,17 +62,22 @@ func planActLimits(cap int, deadline time.Duration) entitlements.PlanActConfig {
// statements named, or makes a named pool's move fail.
type refusingEntitlementsQ struct {
entitlements.Querier
listOpen bool
listPending bool
listBuckets bool
// brokenPool is a pool whose move fails at its read of the type's default.
listOpen bool
// listOpenLadder refuses the ladder page's read of its open acts.
listOpenLadder bool
owed bool
listPending bool
listBuckets bool
// brokenPool and alsoBroken are the pools whose moves fail at their read of
// the type's default.
brokenPool string
alsoBroken string
// requeue refuses a Retry's requeue.
requeue bool
}
func (q refusingEntitlementsQ) GetPoolDefault(ctx context.Context, poolID string) (entitlements.GetPoolDefaultRow, error) {
if q.brokenPool != "" && poolID == q.brokenPool {
if q.brokenPool != "" && (poolID == q.brokenPool || poolID == q.alsoBroken) {
return entitlements.GetPoolDefaultRow{}, errors.New("default unread")
}
return q.Querier.GetPoolDefault(ctx, poolID)
@@ -92,6 +97,20 @@ func (q refusingEntitlementsQ) ListOpenPlanActsByOrgTypes(ctx context.Context, o
return q.Querier.ListOpenPlanActsByOrgTypes(ctx, orgTypes)
}
func (q refusingEntitlementsQ) GetPlanActOwed(ctx context.Context, actID string) (entitlements.GetPlanActOwedRow, error) {
if q.owed {
return entitlements.GetPlanActOwedRow{}, errors.New("owed unread")
}
return q.Querier.GetPlanActOwed(ctx, actID)
}
func (q refusingEntitlementsQ) ListOpenPlanActsByLadder(ctx context.Context, planLadderID string) ([]entitlements.ListOpenPlanActsByLadderRow, error) {
if q.listOpenLadder {
return nil, errors.New("open acts unread")
}
return q.Querier.ListOpenPlanActsByLadder(ctx, planLadderID)
}
func (q refusingEntitlementsQ) ListPendingPlanActObligations(ctx context.Context, arg entitlements.ListPendingPlanActObligationsParams) ([]entitlements.ListPendingPlanActObligationsRow, error) {
if q.listPending {
return nil, errors.New("pending obligations unread")
@@ -197,7 +216,7 @@ func TestOrgTypeDefaultChange_UnreadCountsLeaveTheirPartsOutOfTheToast(t *testin
database := dbtest.Open(t)
f := newOtcFixture(t, database)
temporal, _ := drainStarts()
acts := refusingActs(planActLimits(50, time.Minute), refusingEntitlementsQ{listOpen: true})
acts := refusingActs(planActLimits(50, time.Minute), refusingEntitlementsQ{owed: true})
h := newOtcHarnessWith(t, database, f.operatorID, otcOptions{acts: acts, temporal: temporal})
otcHolders(t, database, f, 1, true)
+39 -123
View File
@@ -100,7 +100,7 @@ type OrgSourceRef struct {
Source string
}
// OrgTypeChangeFailure is one org that could not be classified or enacted.
// OrgTypeChangeFailure is one org that could not be classified.
type OrgTypeChangeFailure struct {
OrgID string
OrgName string
@@ -222,7 +222,8 @@ func otherSourceLabel(sources []positionSource) string {
return strings.Join(labels, ", ")
}
// OrgTypeChangeResult reports what a commit actually enacted.
// OrgTypeChangeResult reports what a default change's or a reorder's request
// settled and what the act still owes.
type OrgTypeChangeResult struct {
Initiated int // bucket-1 pools initiated onto the new default
AlreadyAt int // pools already at the candidate's rank-0 (idempotent re-runs land here)
@@ -230,13 +231,12 @@ type OrgTypeChangeResult struct {
Migrated int // bucket-2 pools moved to the new default (or ended to baseline)
Left int // bucket-1 pools that stayed plan-less (None candidate — nothing to initiate onto)
Untouched int // bucket-3 pools (other source) — never touched
// NotYetReached and WithoutPool are what a default change still owes when
// its request answers; CountsUnread is set when they could not be read,
// NotYetReached and WithoutPool are what the act still owes when its request
// answers; CountsUnread is set when they could not be read,
// and the summary leaves them out.
NotYetReached int
WithoutPool int
CountsUnread bool
Failures []OrgTypeChangeFailure
}
// Summary renders the result's non-zero counts as prose, so the toast and
@@ -268,18 +268,22 @@ func (r *OrgTypeChangeResult) summaryParts() []string {
if r.WithoutPool > 0 {
parts = append(parts, formatCount(int64(r.WithoutPool))+" without a default pool")
}
add(len(r.Failures), "failed")
return parts
}
// DefaultChangeToast is the success toast of a default change that committed.
// A result whose counts could not be read and that settled nothing states the
// commit alone, since "no organizations affected" would say what was not read.
func (r *OrgTypeChangeResult) DefaultChangeToast(orgType string) string {
return r.toast(fmt.Sprintf("Default change committed for %s", orgType))
}
// toast is head followed by the result's summary. A result whose counts could
// not be read and that settled nothing states the commit alone, since "no
// organizations affected" would say what was not read.
func (r *OrgTypeChangeResult) toast(head string) string {
if r.CountsUnread && len(r.summaryParts()) == 0 {
return fmt.Sprintf("Default change committed for %s.", orgType)
return head + "."
}
return fmt.Sprintf("Default change committed for %s: %s.", orgType, r.Summary())
return head + ": " + r.Summary() + "."
}
// OrgTypeCardData is everything one org type's card renders from: the type
@@ -419,7 +423,13 @@ func (h *OperatorPartialsHandler) nameOtherSources(ctx context.Context, preview
// failureReference is a failure sentence that hands the person who can read
// the logs the request id every log line of the failure carries (design D23).
func failureReference(r *http.Request, failure string) string {
return failure + ". Reference: " + middleware.GetRequestID(r.Context()) + "."
return failureReferenceCtx(r.Context(), failure)
}
// failureReferenceCtx is failureReference for code that holds the request's
// context and not the request.
func failureReferenceCtx(ctx context.Context, failure string) string {
return failure + ". Reference: " + middleware.GetRequestID(ctx) + "."
}
// resolveCandidate validates the posted candidate default for an org type:
@@ -602,7 +612,7 @@ func (h *OperatorPartialsHandler) CommitOrgTypeDefaultChange(w http.ResponseWrit
if fresh, err := h.OrgQ.GetOrgType(r.Context(), orgType); err == nil {
ot = fresh
}
result := orgTypeDefaultChangeResult(changed)
result := planActToastResult(changed.PlanActShare)
fireSuccessToast(w, result.DefaultChangeToast(orgType))
h.renderOrgTypeCard(w, r, ot, "", nil, "")
if !changed.Pass.Done {
@@ -610,10 +620,11 @@ func (h *OperatorPartialsHandler) CommitOrgTypeDefaultChange(w http.ResponseWrit
}
}
// orgTypeDefaultChangeResult is the toast's view of a committed default change:
// what its request settled and, when they could be read, what it still owes.
func orgTypeDefaultChangeResult(changed *entitlements.DefaultChangeResult) *OrgTypeChangeResult {
m := changed.Pass.Moves
// planActToastResult is the toast's view of a committed default change or
// reorder: what its request settled and, when they could be read, what it still
// owes.
func planActToastResult(share entitlements.PlanActShare) *OrgTypeChangeResult {
m := share.Pass.Moves
result := &OrgTypeChangeResult{
Initiated: m.Initiated,
AlreadyAt: m.AlreadyAt,
@@ -621,11 +632,11 @@ func orgTypeDefaultChangeResult(changed *entitlements.DefaultChangeResult) *OrgT
Migrated: m.Migrated,
Left: m.Left,
Untouched: m.Untouched,
CountsUnread: !changed.CountsRead,
CountsUnread: !share.CountsRead,
}
if changed.CountsRead {
result.NotYetReached = changed.Counts.NotYetReached
result.WithoutPool = changed.Counts.WithoutPool
if share.CountsRead {
result.NotYetReached = share.Counts.NotYetReached
result.WithoutPool = share.Counts.WithoutPool
}
return result
}
@@ -683,16 +694,14 @@ func (h *OperatorPartialsHandler) previewBesideRefusal(r *http.Request, ot organ
// outgoing, because a move settled the last organization in between, answers the
// form's generic failure, since the card has no radio to choose with.
func (h *OperatorPartialsHandler) renderDispositionRequired(w http.ResponseWriter, r *http.Request, ot organization.OrgType, candidate string, needs *entitlements.DispositionRequiredError) {
log := logging.FromContext(r.Context())
preview, err := h.buildOrgTypeChangePreview(r.Context(), ot.OrgType, candidate, needs.TargetProductID)
if err != nil {
log.Error("failed to classify for commit", slog.Any("error", err), slog.String("org_type", ot.OrgType))
logging.FromContext(r.Context()).Error("failed to classify for commit", slog.Any("error", err), slog.String("org_type", ot.OrgType))
h.renderOrgTypeCard(w, r, ot, candidate, nil, failureReference(r, "Failed to classify existing organizations"))
return
}
if preview.OutgoingDefaultCount == 0 {
log.Error("disposition gate found an outgoing default that the rebuilt preview does not",
slog.Any("org_types", needs.OrgTypes), slog.String("target", needs.TargetProductID))
logGateWithoutOutgoing(r, needs.OrgTypes, needs.TargetProductID)
h.renderOrgTypeCard(w, r, ot, candidate, preview, failureReference(r, "Failed to update the default plan"))
return
}
@@ -700,97 +709,15 @@ func (h *OperatorPartialsHandler) renderDispositionRequired(w http.ResponseWrite
fmt.Sprintf("%d organization(s) hold the outgoing default. Choose whether to grandfather or migrate them before committing.", preview.OutgoingDefaultCount))
}
// dispositionRequired is the commit's gate: whether any organization of the
// types holds a live default-sourced position the outgoing rule marks
// outgoing under the candidate's rank-0 product ("" for None). It reads the
// rule the preview reads, through an index, so it does not grow with the
// types.
func (h *OperatorPartialsHandler) dispositionRequired(ctx context.Context, orgTypes []string, candidateProductID string) (bool, error) {
target, err := entitlements.PlanTarget(candidateProductID)
if err != nil {
return false, err
}
return h.EntitlementsQ.GetOutgoingDefaultExists(ctx, entitlements.GetOutgoingDefaultExistsParams{OrgTypes: orgTypes, Target: target})
}
// logGateWithoutOutgoing logs a gate that found an outgoing default when the
// preview built for the answer finds none: a move settled the last outgoing
// organization between the gate's read and the preview's. The answer is the
// form's generic failure, since the card has no radio to choose a disposition
// with, and resubmitting succeeds.
func (h *OperatorPartialsHandler) logGateWithoutOutgoing(orgTypes []string, candidateProductID string) {
h.Logger.Error("disposition gate found an outgoing default that the rebuilt preview does not",
slog.Any("org_types", orgTypes), slog.String("target", candidateProductID))
}
// orgTypeChangeResult is the reorder's view of an enactment:
// its counts, and each failed organization with the reason the operator
// reads.
func orgTypeChangeResult(enacted *entitlements.EnactResult) *OrgTypeChangeResult {
result := &OrgTypeChangeResult{
Initiated: enacted.Initiated,
AlreadyAt: enacted.AlreadyAt,
Grandfathered: enacted.Grandfathered,
Migrated: enacted.Migrated,
Left: enacted.Left,
Untouched: enacted.Untouched,
}
for _, f := range enacted.Failures {
result.Failures = append(result.Failures, OrgTypeChangeFailure{OrgID: f.OrgID, OrgName: f.OrgName, Reason: enactFailureReason(f)})
}
return result
}
// enactFailureReason is the reason an organization the enactment did not
// move shows under its name. Where the failure is lock contention it is the
// retryable refusal (dispositionFailure).
func enactFailureReason(f entitlements.EnactFailure) string {
switch f.Step {
case entitlements.StepFindPool:
return "no default pool"
case entitlements.StepBegin:
return dispositionFailure(f.Err, "could not begin transaction")
case entitlements.StepLockPools:
return dispositionFailure(f.Err, "could not lock the pool")
case entitlements.StepReadPositions:
return "could not read the pool's plan position"
case entitlements.StepInitiate:
return dispositionFailure(f.Err, "could not initiate the default: "+f.Err.Error())
case entitlements.StepUndecided:
return "an outgoing-default position appeared after the preview; re-run the commit with a disposition"
case entitlements.StepGrandfather:
return dispositionFailure(f.Err, "could not grandfather: "+f.Err.Error())
case entitlements.StepGrandfatherOutcome:
return fmt.Sprintf("grandfather conferral reported %q, expected the lineage supersession", f.Outcome)
case entitlements.StepEndIncumbents:
return dispositionFailure(f.Err, "could not end the outgoing default position: "+f.Err.Error())
case entitlements.StepRestore:
return dispositionFailure(f.Err, "could not apply the new default: "+f.Err.Error())
case entitlements.StepMaterialize:
return dispositionFailure(f.Err, "could not materialize entitlements: "+f.Err.Error())
}
return dispositionFailure(f.Err, "commit failed")
}
// logEnactFailures logs the organizations an enactment did not move, in
// order, as each failing step is logged; the begin, the lock, the read of
// the pool's positions, the materialization, the commit, the undecided
// refusal and the grandfather conferral's outcome are not.
func (h *OperatorPartialsHandler) logEnactFailures(failures []entitlements.EnactFailure) {
for _, f := range failures {
switch f.Step {
case entitlements.StepFindPool:
h.Logger.Warn("default-change: no default pool for org", slog.String("org_id", f.OrgID), slog.Any("error", f.Err))
case entitlements.StepInitiate:
h.Logger.Error("default-change: initiate failed", slog.String("pool_id", f.PoolID), slog.Any("error", f.Err))
case entitlements.StepGrandfather:
h.Logger.Error("default-change: grandfather failed", slog.String("pool_id", f.PoolID), slog.Any("error", f.Err))
case entitlements.StepEndIncumbents:
h.Logger.Error("default-change: migrate end failed", slog.String("pool_id", f.PoolID), slog.Any("error", f.Err))
case entitlements.StepRestore:
h.Logger.Error("default-change: migrate restore failed", slog.String("pool_id", f.PoolID), slog.Any("error", f.Err))
}
}
// form's generic failure, since the page has no radio to choose a disposition
// with, and resubmitting succeeds. The line goes through the request's logger,
// so it carries the request id the answer shows.
func logGateWithoutOutgoing(r *http.Request, orgTypes []string, target string) {
logging.FromContext(r.Context()).Error("disposition gate found an outgoing default that the rebuilt preview does not",
slog.Any("org_types", orgTypes), slog.String("target", target))
}
// renderOrgTypeCard renders a single org type's card (the per-card HTMX swap
@@ -929,14 +856,3 @@ func (h *OperatorPartialsHandler) loadOrgTypesPageData(r *http.Request, baseData
}
return baseData
}
// dispositionFailure is the per-organization failure reason for a default
// change that did not commit: a lock wait past the timeout, a broken
// deadlock or a serialization failure is a change in progress and
// retryable; anything else keeps the caller's reason.
func dispositionFailure(err error, reason string) string {
if db.IsLockContention(err) {
return "Another entitlement set change is in progress. Try again."
}
return reason
}
+1
View File
@@ -340,6 +340,7 @@ func (h *OperatorPartialsHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /partials/operator/plan-ladders/{ladderID}/tiers/{productID}/remove/preview", h.requireOperatorRole(h.PreviewPlanLadderTierRemoval))
mux.HandleFunc("POST /partials/operator/plan-ladders/{ladderID}/tiers/{productID}/remove", h.requireOperatorRole(h.CommitPlanLadderTierRemoval))
mux.HandleFunc("DELETE /partials/operator/plan-ladders/{ladderID}/tiers/{productID}", h.requireOperatorRole(h.DeletePlanLadderTier))
mux.HandleFunc("POST /partials/operator/plan-ladders/{ladderID}/plan-acts/{actID}/retry", h.requireOperatorRole(h.RetryLadderPlanAct))
// Domain moderation. The only write on the operator Domains surface:
// force-release frees a name whatever workspace holds it, through the
// registry's own lock and placement guard (operator_domains.go).
+102 -14
View File
@@ -27,9 +27,13 @@ import (
// act; a longer list ends in "And N more." (design D22).
const planActUnmovedLimit = 50
// orgTypePlanActRetryRoute is the org type card's Retry (declared as an action
// trigger in action_triggers.go).
const orgTypePlanActRetryRoute = "/partials/operator/org-types/{orgType}/plan-acts/{actID}/retry"
// orgTypePlanActRetryRoute and ladderPlanActRetryRoute are the org type card's
// Retry and the ladder page's (declared as action triggers in
// action_triggers.go).
const (
orgTypePlanActRetryRoute = "/partials/operator/org-types/{orgType}/plan-acts/{actID}/retry"
ladderPlanActRetryRoute = "/partials/operator/plan-ladders/{ladderID}/plan-acts/{actID}/retry"
)
// PlanActLinesView is what a place shows of the plan acts that still owe work
// where it was made (design.md "The places"): one block of lines for each act,
@@ -38,8 +42,10 @@ type PlanActLinesView struct {
// Error is "Pending plan changes could not be read. Reference: {request
// id}." when the lines could not be read; Acts is empty then.
Error string
// Target is the element a Retry swaps: the place's own section.
// Target is the element a Retry swaps: the place's own section. Swap is how
// the answer takes its place; empty means outerHTML.
Target string
Swap string
Acts []PlanActLineView
}
@@ -156,6 +162,28 @@ func (h *OperatorPartialsHandler) loadPlanActLines(r *http.Request, orgTypes []s
return views
}
// loadLadderPlanActLines reads the lines of the reorders of one ladder that own
// an org type while they have work left, newest first, in two statements (design
// D22). Like loadPlanActLines, a failed read is logged through the request's
// logger and stands as the sentence that names the request, in place of the
// lines. A Retry swaps the whole ladder page, as every other act on it does.
func (h *OperatorPartialsHandler) loadLadderPlanActLines(r *http.Request, ladderID string) PlanActLinesView {
acts, err := entitlements.ReadOpenPlanActsByLadder(r.Context(), h.Database, h.PlanActs, ladderID, planActUnmovedLimit)
if err != nil {
logging.FromContext(r.Context()).Error("failed to read the pending plan changes", slog.Any("error", err), slog.String("plan_ladder_id", ladderID))
return PlanActLinesView{Error: failureReference(r, "Pending plan changes could not be read")}
}
view := PlanActLinesView{Target: "#operator-body", Swap: "innerHTML"}
for _, a := range acts {
retryURL, err := web.RouteURL(ladderPlanActRetryRoute, ladderID, a.ActID)
if err != nil {
retryURL = "/partials/operator/plan-ladders/" + ladderID + "/plan-acts/" + a.ActID + "/retry"
}
view.Acts = append(view.Acts, planActLineView(a, retryURL))
}
return view
}
// startPlanActDrain starts the act's drain workflow once the request's own work
// is done, so what the request could not list or move settles in seconds
// rather than at the poller's next tick. The workflow id is one per act and the
@@ -227,23 +255,83 @@ func (h *OperatorPartialsHandler) RetryOrgTypePlanAct(w http.ResponseWriter, r *
return
}
if msg, drain := h.retryPlanAct(r, session.PersonID, actID, slog.String("org_type", orgType)); msg != "" {
h.renderOrgTypeCard(w, r, ot, "", nil, msg)
} else {
h.renderOrgTypeCard(w, r, ot, "", nil, "")
if drain {
h.startPlanActDrain(r.Context(), actID)
}
}
}
// retryPlanAct runs a Retry of actID for the operator, the transaction both
// places share, and says what the place answers: the sentence of a Retry that
// failed ("" when it did not) and whether the act's drain should start once the
// place is rendered. A lock wait or a deadlock answers the contention refusal;
// any other failure answers "Failed to retry." with the request id its log line
// carries. Where names the place in the log lines.
func (h *OperatorPartialsHandler) retryPlanAct(r *http.Request, personID, actID string, where slog.Attr) (msg string, drain bool) {
log := logging.FromContext(r.Context())
retried, err := entitlements.RetryPlanAct(r.Context(), h.Database, entitlements.RetryPlanActInput{
ActID: actID,
OperatorPersonID: session.PersonID,
OperatorPersonID: personID,
RequestID: middleware.GetRequestID(r.Context()),
Acts: h.PlanActs,
})
switch {
case db.IsLockContention(err) || errors.Is(err, entitlements.ErrPlanActDeadline):
log.Warn("retry refused for a change in progress", slog.Any("error", err), slog.String("act_id", actID), slog.String("org_type", orgType))
h.renderOrgTypeCard(w, r, ot, "", nil, entitlements.MessageInProgress)
log.Warn("retry refused for a change in progress", slog.Any("error", err), slog.String("act_id", actID), where)
return entitlements.MessageInProgress, false
case err != nil:
log.Error("failed to retry a plan act", slog.Any("error", err), slog.String("act_id", actID), slog.String("org_type", orgType))
h.renderOrgTypeCard(w, r, ot, "", nil, failureReference(r, "Failed to retry"))
default:
h.renderOrgTypeCard(w, r, ot, "", nil, "")
if retried.Requeued > 0 && !retried.Pass.Done {
h.startPlanActDrain(r.Context(), actID)
}
log.Error("failed to retry a plan act", slog.Any("error", err), slog.String("act_id", actID), where)
return failureReference(r, "Failed to retry"), false
}
return "", retried.Requeued > 0 && !retried.Pass.Done
}
// RetryLadderPlanAct handles
// POST /partials/operator/plan-ladders/{ladderID}/plan-acts/{actID}/retry, the
// ladder page's Retry beside the organizations a reorder did not move. It does
// for a reorder of the ladder what RetryOrgTypePlanAct does for a default change
// of the type: the act's failed obligations return to pending, the request
// settles what its budget allows and the ladder page is re-rendered; the act's
// drain, started afterwards, moves the rest. An act that was not made for this
// ladder, and a Retry with nothing failed to return, write nothing and
// re-render the page with its current lines.
func (h *OperatorPartialsHandler) RetryLadderPlanAct(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
h.renderPlanLadderTiersPage(w, r, r.PathValue("ladderID"), "", "Unauthorized")
return
}
ladderID, ok := h.pathUUID(w, r, "ladderID")
if !ok {
return
}
actID, ok := h.pathUUID(w, r, "actID")
if !ok {
return
}
log := logging.FromContext(r.Context())
// The act must be a reorder of this ladder; any other act is not this page's
// to retry, and the page shows its current lines.
place, err := h.EntitlementsQ.GetPlanActPlace(r.Context(), actID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
log.Error("failed to read the plan act to retry", slog.Any("error", err), slog.String("act_id", actID), slog.String("plan_ladder_id", ladderID))
h.renderPlanLadderTiersPage(w, r, ladderID, "", failureReference(r, "Failed to retry"))
return
}
if err != nil || entitlements.ActCause(place.Cause) != entitlements.ActTiersReordered || place.PlanLadderID.UUID.String() != ladderID {
log.Warn("a retry named a plan act that was not made for this ladder", slog.String("act_id", actID), slog.String("plan_ladder_id", ladderID))
h.renderPlanLadderTiersPage(w, r, ladderID, "", "")
return
}
msg, drain := h.retryPlanAct(r, session.PersonID, actID, slog.String("plan_ladder_id", ladderID))
h.renderPlanLadderTiersPage(w, r, ladderID, "", msg)
if drain {
h.startPlanActDrain(r.Context(), actID)
}
}
+214 -222
View File
@@ -148,6 +148,12 @@ type PlanLadderTiersData struct {
// type's default, and rank 0 is only the ladder's base tier (provisions
// no one). See orgTypesDefaultingToLadder.
DefaultForOrgTypeNames []string
// DefaultTypesError is the sentence that stands in place of the default
// badges when the org types could not be read (copy 45).
DefaultTypesError string
// PlanActs is the open work of the reorders that own an org type, or the
// sentence saying it could not be read (design D22).
PlanActs PlanActLinesView
// Pending is the server-rendered preview of a dropped-but-uncommitted tier
// order; nil when no reorder is in flight. See PendingTierReorder.
Pending *PendingTierReorder
@@ -225,6 +231,10 @@ type PendingTierReorder struct {
RuleLessWarning template.HTML
TypePreviews []TierReorderTypePreview
NeedsDisposition bool // some type's outgoing-default bucket is non-empty
// OrgTypesError is the sentence that stands in the consequence panel when
// the org types that default to the ladder could not be read (copy 45); the
// preview offers no commit then.
OrgTypesError string
// Form is the reorder preview's commit form (operator.plan-ladder.
// tier.reorder.commit), the preview idiom's own render (design D10;
// finding FA-13).
@@ -484,15 +494,6 @@ func tierAddCollisionSentence(c *entitlements.TierAddCollisionError) string {
return "Cannot add this tier; " + c.Organization + " already holds " + c.Product + " on this ladder."
}
// reconcileTierOrder merges a submitted product order with the ladder's
// current tiers: submitted products that are no longer tiers are dropped, and
// tiers absent from the submission are appended in their current relative
// order (tiers arrive rank ASC), so the result always covers the whole ladder
// and renumbering can never collide with an untouched tier that still holds a
// target rank (audit finding #42). Returns the merged order, the product
// currently at rank 0, and whether the submission was stale (something was
// dropped or appended). Shared by the reorder preview and commit so the order
// the operator saw is the order the commit gates on.
// splitCommaList splits the reorder-commit form's comma-joined "order"
// field back into its product ids: a single Hidden field carrying a list,
// rather than repeated same-named hidden inputs, which the library's Field
@@ -505,56 +506,35 @@ func splitCommaList(joined string) []string {
return strings.Split(joined, ",")
}
func reconcileTierOrder(tiers []billing.PlanLadderTier, submitted []string) (final []string, prevTop string, stale bool) {
valid := make(map[string]bool, len(tiers))
for _, t := range tiers {
valid[t.ProductID] = true
if t.Rank == 0 {
prevTop = t.ProductID
}
}
taken := make(map[string]bool, len(submitted))
final = make([]string, 0, len(tiers))
for _, pid := range submitted {
if valid[pid] && !taken[pid] {
taken[pid] = true
final = append(final, pid)
}
}
stale = len(final) != len(submitted) // a submitted product was not a valid tier
for _, t := range tiers {
if !taken[t.ProductID] {
final = append(final, t.ProductID)
stale = true
}
}
return final, prevTop, stale
}
// buildPendingTierReorder reconciles a submitted product order into a fully
// hydrated preview: pending rows with pending ranks, whether rank 0 changes,
// and — when it does on a ladder that is some org type's default — each
// affected type's default-change classification with the pending rank-0
// product as candidate. Read-only. A (nil, "") return means there was nothing
// to preview (empty submission or a fully-stale one); callers re-render the
// persisted order.
// persisted order. The commit renders it only to answer 422: the act itself
// reads and reconciles the order inside its transaction. A failed read is
// logged through the request's logger; the classification's failure answers with
// the request's id (copy 57), and the org types' failure stands in the
// preview's consequence panel with no commit to offer (copy 45).
func (h *OperatorPartialsHandler) buildPendingTierReorder(ctx context.Context, ladderID string, submitted []string) (*PendingTierReorder, string) {
if len(submitted) == 0 {
return nil, ""
}
log := logging.FromContext(ctx)
tiers, err := h.BillingQ.ListTiersByLadder(ctx, ladderID)
if err != nil {
h.Logger.Error("failed to list tiers for reorder preview", slog.Any("error", err))
log.Error("failed to list tiers for reorder preview", slog.Any("error", err))
return nil, "Failed to load tiers for the reorder preview."
}
final, prevTop, stale := reconcileTierOrder(tiers, submitted)
final, prevTop, stale := entitlements.ReconcileTierOrder(tiers, submitted)
if len(final) == 0 {
return nil, ""
}
withProducts, err := h.BillingQ.ListTiersByLadderWithProducts(ctx, ladderID)
if err != nil {
h.Logger.Error("failed to load tier products for reorder preview", slog.Any("error", err))
log.Error("failed to load tier products for reorder preview", slog.Any("error", err))
return nil, "Failed to load tiers for the reorder preview."
}
names := make(map[string]string, len(withProducts))
@@ -590,31 +570,62 @@ func (h *OperatorPartialsHandler) buildPendingTierReorder(ctx context.Context, l
if pending.RankZeroChanged {
pending.RuleLessWarning = h.ruleLessWarning(ctx, final[0])
for _, ref := range h.orgTypesDefaultingToLadder(ctx, ladderID) {
preview, err := h.buildOrgTypeChangePreview(ctx, ref.OrgType, ladderID, final[0])
if err != nil {
h.Logger.Error("failed to classify for reorder preview", slog.Any("error", err), slog.String("org_type", ref.OrgType))
return nil, "Failed to classify affected organizations. Details are in the server logs."
}
pending.TypePreviews = append(pending.TypePreviews, TierReorderTypePreview{
OrgType: ref.OrgType,
DisplayName: ref.DisplayName,
Preview: preview,
})
if preview.OutgoingDefaultCount > 0 {
pending.NeedsDisposition = true
}
if msg := h.classifyPendingReorder(ctx, ladderID, pending); msg != "" {
return nil, msg
}
}
pending.Form = h.reorderPreviewForm(ladderID, pending, planLadderTierReorderValues(pending.OrderIDs, pending.NeedsDisposition, ""), nil)
return pending, ""
}
// classifyPendingReorder fills the pending reorder's per-type classification:
// each org type that defaults to the ladder, against the pending rank-0
// product. Org types that cannot be read leave OrgTypesError in place of the
// classification. It returns the sentence of a classification that failed.
func (h *OperatorPartialsHandler) classifyPendingReorder(ctx context.Context, ladderID string, pending *PendingTierReorder) string {
log := logging.FromContext(ctx)
refs, err := h.orgTypesDefaultingToLadder(ctx, ladderID)
if err != nil {
log.Error("failed to list org types for the reorder preview", slog.Any("error", err), slog.String("plan_ladder_id", ladderID))
pending.OrgTypesError = failureReferenceCtx(ctx, orgTypesUnreadSentence)
return ""
}
for _, ref := range refs {
preview, err := h.buildOrgTypeChangePreview(ctx, ref.OrgType, ladderID, pending.OrderIDs[0])
if err != nil {
log.Error("failed to classify for reorder preview", slog.Any("error", err), slog.String("org_type", ref.OrgType))
return failureReferenceCtx(ctx, "Failed to classify affected organizations")
}
pending.TypePreviews = append(pending.TypePreviews, TierReorderTypePreview{
OrgType: ref.OrgType,
DisplayName: ref.DisplayName,
Preview: preview,
})
if preview.OutgoingDefaultCount > 0 {
pending.NeedsDisposition = true
}
}
return ""
}
// orgTypesUnreadSentence opens the sentence the tier card and the reorder
// preview show when the org types that default to a ladder cannot be read
// (copy 45); failureReference ends it with the request's id.
const orgTypesUnreadSentence = "Failed to read the org types that default to this ladder"
// reorderPreviewForm is the reorder preview's commit form for the pending
// order, bound to values and errs. A preview that could not read the org types
// has nothing to apply: it offers no commit.
func (h *OperatorPartialsHandler) reorderPreviewForm(ladderID string, pending *PendingTierReorder, values forms.Values, errs *forms.Errors) forms.FormView {
ladderURL, err := web.RouteURL("/operator/plan-ladders/{ladderID}", ladderID)
if err != nil {
ladderURL = "/operator/plan-ladders"
}
pending.Form = planLadderTierReorderPreview(ladderID, ladderURL,
planLadderTierReorderValues(pending.OrderIDs, pending.NeedsDisposition, ""), nil,
view := planLadderTierReorderPreview(ladderID, ladderURL, values, errs,
h.Templates.Fragment("operator_plan_ladder_tier_reorder_message.html", pending))
return pending, ""
view.CommitHidden = pending.OrgTypesError != ""
return view
}
// PreviewPlanLadderTiersReorder handles
@@ -651,12 +662,14 @@ func (h *OperatorPartialsHandler) PreviewPlanLadderTiersReorder(w http.ResponseW
// when the pending rank 0 differs on a ladder that is some org type's
// default and organizations hold the outgoing rank-0 through
// default-sourced positions, a required `bucket2_disposition`
// (`grandfather` | `migrate`). Enactment mirrors the
// org-type default-change commit: validation gate (nothing written, not even
// ranks), then renumber (the moment the effective default changes), then
// per-pool dispositions with in-tx re-derivation. A tier's rank is display
// order plus the rank-0 provisioning default — member provisions bind by
// product_id (pool_provision_ladders), so reordering never re-plans a member.
// (`grandfather` | `migrate`).
//
// It parses, calls entitlements.ReorderTiers and renders what came of it: T0
// renumbers the ladder, records the act and the org types it reaches and runs
// the disposition gate, the request lists and moves what its budget allows, and
// the act's drain owns the rest (design D1, D14). The 422 preview is rebuilt
// after T0's rollback, the toast counts what the request did and still owes,
// and the drain starts on the request's own context after the page is rendered.
func (h *OperatorPartialsHandler) ReorderPlanLadderTiers(w http.ResponseWriter, r *http.Request) {
ladderID := r.PathValue("ladderID")
session := h.AuthConfig.GetUserSession(r.Context())
@@ -667,9 +680,38 @@ func (h *OperatorPartialsHandler) ReorderPlanLadderTiers(w http.ResponseWriter,
values, errs := planLadderTierReorderForm.Parse(r)
submitted := splitCommaList(values.String("order"))
disposition := values.String("bucket2_disposition")
if len(submitted) == 0 {
h.renderPlanLadderTiersPage(w, r, ladderID, "", "")
return
}
if errs.Any() {
h.renderReorderRefusal(w, r, ladderID, submitted, disposition, errs)
return
}
ctx := r.Context()
pending, errMsg := h.buildPendingTierReorder(ctx, ladderID, submitted)
done, err := entitlements.ReorderTiers(r.Context(), h.Database, entitlements.ReorderTiersInput{
LadderID: ladderID,
Submitted: submitted,
Disposition: disposition,
OperatorPersonID: session.PersonID,
RequestID: middleware.GetRequestID(r.Context()),
Acts: h.PlanActs,
})
if err != nil {
h.answerReorderFailure(w, r, ladderID, submitted, disposition, err)
return
}
h.renderPlanLadderTiersPage(w, r, ladderID, tierReorderAnswer(done), "")
if len(done.ReachedOrgTypes) > 0 && !done.Pass.Done {
h.startPlanActDrain(r.Context(), done.ActID)
}
}
// renderReorderRefusal answers a commit the form refused, or a gate that asks
// for a disposition: the pending preview rebuilt from the submitted order, at
// 422, with errs under their fields.
func (h *OperatorPartialsHandler) renderReorderRefusal(w http.ResponseWriter, r *http.Request, ladderID string, submitted []string, disposition string, errs *forms.Errors) {
pending, errMsg := h.buildPendingTierReorder(r.Context(), ladderID, submitted)
if errMsg != "" {
h.renderPlanLadderTiersPage(w, r, ladderID, "", errMsg)
return
@@ -678,148 +720,94 @@ func (h *OperatorPartialsHandler) ReorderPlanLadderTiers(w http.ResponseWriter,
h.renderPlanLadderTiersPage(w, r, ladderID, "", "")
return
}
// Validation gate: an outgoing-default population anywhere requires an
// explicit disposition. Rejecting here writes nothing — not even ranks.
// The gate is read from the database, never the submitted
// needs_disposition marker (that marker only decides whether the radio
// rendered; a stale marker re-previews here rather than trusting an
// out-of-date "not needed"). The pending previews were built with the
// order; when the gate is true and they count no outgoing organization,
// a move settled the last one between the two reads, and the answer is
// the generic failure.
if disposition == "" && !errs.Has("bucket2_disposition") {
required, err := h.reorderDispositionRequired(ctx, pending)
if err != nil {
h.Logger.Error("failed to read the disposition gate", slog.Any("error", err), slog.String("plan_ladder_id", ladderID))
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to reorder tiers.")
return
}
if required {
outgoing := 0
for _, tp := range pending.TypePreviews {
outgoing += tp.Preview.OutgoingDefaultCount
}
if outgoing == 0 {
h.logGateWithoutOutgoing(pendingOrgTypes(pending), pending.OrderIDs[0])
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to reorder tiers.")
return
}
errs.Field("bucket2_disposition", fmt.Sprintf("Choose whether to grandfather or migrate the %d organization(s) on the outgoing default.", outgoing))
}
}
if errs.Any() {
h.renderPlanLadderTiersReorderRefusal(w, r, ladderID, pending,
planLadderTierReorderValues(pending.OrderIDs, pending.NeedsDisposition, disposition), errs)
return
}
in := entitlements.ReorderTiersInput{LadderID: ladderID, Order: pending.OrderIDs}
if pending.RankZeroChanged && len(pending.TypePreviews) > 0 {
// The effective default changed: enact per-pool dispositions for
// every affected type's orgs, exactly like the org-type default-change
// commit (per-pool transactions, in-transaction bucket re-derivation,
// per-org failure isolation).
in.Enact = &entitlements.ReorderEnactment{
Disposition: disposition,
OperatorPersonID: session.PersonID,
Ladders: h.BillingQ,
Orgs: h.OrgQ,
Pools: h.EntitlementsQ,
}
for _, tp := range pending.TypePreviews {
in.Enact.OrgTypes = append(in.Enact.OrgTypes, tp.OrgType)
}
}
enacted, err := entitlements.ReorderTiers(ctx, h.Database, in)
if err != nil {
h.renderPlanLadderTiersPage(w, r, ladderID, "", h.tierReorderFailure(enacted, err))
return
}
if enacted != nil {
h.logEnactFailures(enacted.Failures)
}
msg, errMsg := tierReorderAnswer(pending, enacted)
h.renderPlanLadderTiersPage(w, r, ladderID, msg, errMsg)
h.renderPlanLadderTiersReorderRefusal(w, r, ladderID, pending,
planLadderTierReorderValues(pending.OrderIDs, pending.NeedsDisposition, disposition), errs)
}
// pendingOrgTypes lists the org types the pending reorder reaches: the types
// whose default ladder it is, when its rank 0 changes.
func pendingOrgTypes(pending *PendingTierReorder) []string {
types := make([]string, 0, len(pending.TypePreviews))
for _, tp := range pending.TypePreviews {
types = append(types, tp.OrgType)
}
return types
}
// reorderDispositionRequired is the reorder's gate: whether any organization
// of the types the pending rank 0 reaches holds a position the outgoing rule
// marks outgoing under the new rank-0 product. A reorder that leaves rank 0
// alone, or reaches no type, requires none.
func (h *OperatorPartialsHandler) reorderDispositionRequired(ctx context.Context, pending *PendingTierReorder) (bool, error) {
if !pending.RankZeroChanged || len(pending.TypePreviews) == 0 {
return false, nil
}
return h.dispositionRequired(ctx, pendingOrgTypes(pending), pending.OrderIDs[0])
}
// tierReorderAnswer is what a committed reorder says: the order saved
// (noting a stale page), and when the rank 0 changed, either what the
// enactment did on the types defaulting to the ladder, as an error alone
// when an organization failed, or that no organizations were affected.
func tierReorderAnswer(pending *PendingTierReorder, enacted *entitlements.EnactResult) (msg, errMsg string) {
msg = "Tier order updated."
if pending.Stale {
msg = "Tier order saved. The page was out of date, so this reflects the current order."
}
if enacted == nil {
if pending.RankZeroChanged {
msg = fmt.Sprintf("%s is now this ladder's rank-0 tier. The ladder is not any org type's default, so no organizations were affected.", pending.NewRankZeroName)
}
return msg, ""
}
result := orgTypeChangeResult(enacted)
typeNames := make([]string, 0, len(pending.TypePreviews))
for _, tp := range pending.TypePreviews {
typeNames = append(typeNames, tp.DisplayName)
}
msg = fmt.Sprintf("%s is now the default plan for new %s organizations: %s.", pending.NewRankZeroName, strings.Join(typeNames, ", "), result.Summary())
if len(result.Failures) == 0 {
return msg, ""
}
parts := make([]string, 0, len(result.Failures))
for _, f := range result.Failures {
parts = append(parts, fmt.Sprintf("%s: %s", f.OrgName, f.Reason))
}
return "", msg + " Failed organizations were skipped; fix the cause, re-drop the same order, and commit again: " + strings.Join(parts, "; ")
}
// tierReorderFailure logs a reorder that stopped as the step that failed is
// logged and returns the sentence the operator reads for it. A renumbering
// that fails means the page was out of date (audit finding #42); a failed
// listing of a type's organizations comes after the order was saved, and the
// organizations enacted before it are logged first.
func (h *OperatorPartialsHandler) tierReorderFailure(enacted *entitlements.EnactResult, err error) string {
// answerReorderFailure answers a reorder that wrote nothing: nothing to
// reorder, the gate that asks for a disposition, the contention refusal, a page
// that was out of date, or the generic failure. Every failure is logged through
// the request's logger with the step that failed.
func (h *OperatorPartialsHandler) answerReorderFailure(w http.ResponseWriter, r *http.Request, ladderID string, submitted []string, disposition string, err error) {
log := logging.FromContext(r.Context())
var needs *entitlements.DispositionRequiredError
var failed *entitlements.PlanActError
if !errors.As(err, &failed) {
return "Failed to reorder tiers."
switch {
case errors.Is(err, entitlements.ErrNoTiersToReorder):
h.renderPlanLadderTiersPage(w, r, ladderID, "", "")
case errors.As(err, &needs):
h.renderReorderDispositionRequired(w, r, ladderID, submitted, disposition, needs)
case db.IsLockContention(err) || errors.Is(err, entitlements.ErrPlanActDeadline):
log.Warn("reorder refused for a change in progress", slog.Any("error", err), slog.String("plan_ladder_id", ladderID))
h.renderPlanLadderTiersPage(w, r, ladderID, "", entitlements.MessageInProgress)
case errors.As(err, &failed) && failed.Step == entitlements.StepRenumber:
// A renumbering that fails means the page was out of date (audit finding #42).
log.Error("failed to reorder tiers", slog.String("step", string(failed.Step)), slog.Any("error", err), slog.String("plan_ladder_id", ladderID))
h.renderPlanLadderTiersPage(w, r, ladderID, "", "This page was out of date; showing the current tier order. Try again.")
default:
step := entitlements.PlanActStep("")
if errors.As(err, &failed) {
step = failed.Step
}
log.Error("failed to reorder tiers", slog.String("step", string(step)), slog.Any("error", err), slog.String("plan_ladder_id", ladderID))
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to reorder tiers.")
}
cause := failed.Err
switch failed.Step {
case entitlements.StepBegin:
h.Logger.Error("failed to begin tier reorder tx", slog.Any("error", cause))
case entitlements.StepRenumber:
h.Logger.Error("failed to renumber tiers", slog.Any("error", cause))
return "This page was out of date; showing the current tier order. Try again."
case entitlements.StepCommit:
h.Logger.Error("failed to commit tier reorder tx", slog.Any("error", cause))
case entitlements.StepListOrganizations:
h.logEnactFailures(enacted.Failures)
h.Logger.Error("failed to list orgs for reorder enactment", slog.Any("error", cause), slog.String("org_type", failed.OrgType))
return "Tier order saved, but affected organizations could not be listed for enactment. Re-drop the same order and commit again."
}
// renderReorderDispositionRequired answers a reorder the gate refused: the
// preview rebuilt after T0's rollback, through the capped read, with the field
// error that asks for a disposition. A rebuilt preview that could not read the
// org types answers as the preview does, with copy 45 and no commit. A rebuilt
// preview with nothing outgoing, because a move settled the last organization
// in between, answers the generic failure, since the preview has no radio to
// choose with.
func (h *OperatorPartialsHandler) renderReorderDispositionRequired(w http.ResponseWriter, r *http.Request, ladderID string, submitted []string, disposition string, needs *entitlements.DispositionRequiredError) {
pending, errMsg := h.buildPendingTierReorder(r.Context(), ladderID, submitted)
if errMsg != "" {
h.renderPlanLadderTiersPage(w, r, ladderID, "", errMsg)
return
}
return "Failed to reorder tiers."
if pending == nil {
h.renderPlanLadderTiersPage(w, r, ladderID, "", "")
return
}
if pending.OrgTypesError != "" {
// The failed read is logged once, with the request id, where it is read.
h.renderPlanLadderTiersReorderRefusal(w, r, ladderID, pending,
planLadderTierReorderValues(pending.OrderIDs, pending.NeedsDisposition, disposition), nil)
return
}
outgoing := 0
for _, tp := range pending.TypePreviews {
outgoing += tp.Preview.OutgoingDefaultCount
}
if outgoing == 0 {
logGateWithoutOutgoing(r, needs.OrgTypes, needs.TargetProductID)
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to reorder tiers.")
return
}
errs := forms.NewErrors()
errs.Field("bucket2_disposition", fmt.Sprintf("Choose whether to grandfather or migrate the %d organization(s) on the outgoing default.", outgoing))
h.renderPlanLadderTiersReorderRefusal(w, r, ladderID, pending,
planLadderTierReorderValues(pending.OrderIDs, pending.NeedsDisposition, disposition), errs)
}
// tierReorderAnswer is what a committed reorder says: the order saved (noting a
// stale page) and, when the rank 0 changed, either that no organizations were
// affected or what the request settled and still owes on the types defaulting to
// the ladder, as the default change's toast counts them.
func tierReorderAnswer(done *entitlements.ReorderTiersResult) string {
if len(done.ReachedOrgTypes) == 0 {
switch {
case done.RankZeroChanged:
return fmt.Sprintf("%s is now this ladder's rank-0 tier. The ladder is not any org type's default, so no organizations were affected.", done.NewRankZero)
case done.Stale:
return "Tier order saved. The page was out of date, so this reflects the current order."
}
return "Tier order updated."
}
head := fmt.Sprintf("%s is now the default plan for new %s organizations", done.NewRankZero, strings.Join(done.ReachedOrgTypes, ", "))
return planActToastResult(done.PlanActShare).toast(head)
}
// DeletePlanLadderTier handles DELETE /partials/operator/plan-ladders/{ladderID}/tiers/{productID}
@@ -943,7 +931,11 @@ func (h *OperatorPartialsHandler) buildTierRemovalPreview(ctx context.Context, l
tiers[0].ProductID == productID && tiers[0].Rank == 0 {
pending.PromotedName = tiers[1].ProductName
pending.RuleLessWarning = h.ruleLessWarning(ctx, tiers[1].ProductID)
for _, ref := range h.orgTypesDefaultingToLadder(ctx, ladderID) {
refs, err := h.orgTypesDefaultingToLadder(ctx, ladderID)
if err != nil {
h.Logger.Error("failed to list org types for the promoted-default note", slog.Any("error", err))
}
for _, ref := range refs {
pending.PromotedForTypes = append(pending.PromotedForTypes, ref.DisplayName)
}
}
@@ -1429,12 +1421,7 @@ func (h *OperatorPartialsHandler) renderPlanLadderTiersReorderRefusal(w http.Res
h.renderPlanLaddersPage(w, r, "", "Plan ladder not found")
return
}
ladderURL, err := web.RouteURL("/operator/plan-ladders/{ladderID}", ladderID)
if err != nil {
ladderURL = "/operator/plan-ladders"
}
pending.Form = planLadderTierReorderPreview(ladderID, ladderURL, values, errs,
h.Templates.Fragment("operator_plan_ladder_tier_reorder_message.html", pending))
pending.Form = h.reorderPreviewForm(ladderID, pending, values, errs)
tiersData := h.loadPlanLadderTiersData(r, ladderID)
tiersData.Pending = pending
h.Templates.Render(w, "operator_plan_ladder_detail.html", PlanLadderDetailData{
@@ -1443,27 +1430,20 @@ func (h *OperatorPartialsHandler) renderPlanLadderTiersReorderRefusal(w http.Res
})
}
// loadPlanLadderTiersData hydrates the tier-management view model for one ladder:
// the existing tiers (with active-attachment flags) and the products still
// available to add as tiers. Best-effort — a query failure yields a partial
// result rather than aborting, since this feeds the composite detail page where
// the ladder's existence is already established.
// orgTypesDefaultingToLadder returns the org types (key + display name) whose
// configured default plan ladder is ladderID. New organizations of those types
// are auto-provisioned onto this ladder's rank-0 tier; for any ladder that is
// no org type's default, rank 0 is merely the base tier and provisions no one.
// Best-effort: a query failure logs and returns nil (the badge simply doesn't
// render; the reorder preview treats the ladder as no type's default) rather
// than failing the whole tier view.
func (h *OperatorPartialsHandler) orgTypesDefaultingToLadder(ctx context.Context, ladderID string) []orgTypeRef {
// A failed read is an error, never "no org type defaults to this ladder": the
// caller decides what to show in its place.
func (h *OperatorPartialsHandler) orgTypesDefaultingToLadder(ctx context.Context, ladderID string) ([]orgTypeRef, error) {
lid, err := uuid.Parse(ladderID)
if err != nil {
return nil
return nil, err
}
orgTypes, err := h.OrgQ.ListOrgTypes(ctx)
if err != nil {
h.Logger.Error("failed to list org types for default-ladder badge", slog.Any("error", err))
return nil
return nil, err
}
var refs []orgTypeRef
for _, ot := range orgTypes {
@@ -1471,9 +1451,15 @@ func (h *OperatorPartialsHandler) orgTypesDefaultingToLadder(ctx context.Context
refs = append(refs, orgTypeRef{OrgType: ot.OrgType, DisplayName: ot.DisplayName})
}
}
return refs
return refs, nil
}
// loadPlanLadderTiersData hydrates the tier-management view model for one ladder:
// the existing tiers (with active-attachment flags), the products still
// available to add as tiers and the lines of the reorders that still owe work.
// Best-effort — a query failure yields a partial result rather than aborting,
// since this feeds the composite detail page where the ladder's existence is
// already established; the org types and the lines say so in their place.
func (h *OperatorPartialsHandler) loadPlanLadderTiersData(r *http.Request, ladderID string) PlanLadderTiersData {
data := PlanLadderTiersData{}
if ladder, err := h.BillingQ.GetPlanLadderByID(r.Context(), ladderID); err == nil {
@@ -1483,9 +1469,15 @@ func (h *OperatorPartialsHandler) loadPlanLadderTiersData(r *http.Request, ladde
IsActive: ladder.IsActive,
}
}
for _, ref := range h.orgTypesDefaultingToLadder(r.Context(), ladderID) {
refs, err := h.orgTypesDefaultingToLadder(r.Context(), ladderID)
if err != nil {
logging.FromContext(r.Context()).Error("failed to list org types for the default-ladder badges", slog.Any("error", err), slog.String("plan_ladder_id", ladderID))
data.DefaultTypesError = failureReference(r, orgTypesUnreadSentence)
}
for _, ref := range refs {
data.DefaultForOrgTypeNames = append(data.DefaultForOrgTypeNames, ref.DisplayName)
}
data.PlanActs = h.loadLadderPlanActLines(r, ladderID)
// Load tiers with product info
tiers, err := h.BillingQ.ListTiersByLadderWithProducts(r.Context(), ladderID)
@@ -93,6 +93,7 @@ var readCountPages = []readCountPage{
return grow, "/operator/plan-ladders/" + l.ID
}},
tierReorderPreviewPage(),
ladderWithOpenActsPage(),
{name: "entitlement-set-history", perRow: 0, build: func(w *world.World) (func(), string) {
registerGoldenKeys(w.TB, w)
p := w.Product("History")
@@ -301,6 +302,21 @@ func tierReorderPreviewPage() readCountPage {
}
}
// ladderWithOpenActsPage is the row of the ladder page with open reorders:
// each added row is a reorder that owns a type of its own and still owes work
// on it, so the page's lines grow by one act per row.
func ladderWithOpenActsPage() readCountPage {
return readCountPage{
name: "plan-ladder-open-acts", perRow: 0,
build: func(w *world.World) (func(), string) {
grow, l, products := newCountedLadder(w)
grow()
reorders := newOpenReorders(w, w.Person("Owner"), l, products)
return reorders.add, "/operator/plan-ladders/" + l.ID
},
}
}
// htmxRequest marks a request as htmx's, so a page answers with its fragment.
func htmxRequest(r *http.Request) { r.Header.Set("HX-Request", "true") }
@@ -454,3 +470,97 @@ func TestOrgTypesPageReadsItsLinesInTwoStatements(t *testing.T) {
}
}
}
// openReorders makes reorders of one ladder that stay open together. Each add
// gives the ladder an organization type with two organizations and reorders it,
// after the types the earlier reorders reached have left the ladder as their
// default, so the new reorder reaches its own type only and every earlier one
// keeps owning the type it reached.
type openReorders struct {
w *world.World
owner world.Person
l world.Ladder
products *[]world.Product
away world.Ladder
types []string
}
func newOpenReorders(w *world.World, owner world.Person, l world.Ladder, products *[]world.Product) *openReorders {
return &openReorders{w: w, owner: owner, l: l, products: products, away: w.Ladder("Away", w.Product("Away"))}
}
// add reorders the ladder so the reorder reaches a new type. The request moves
// one organization (Cap 1) and the other fails every attempt, so the ladder
// page lists it and offers Retry.
func (o *openReorders) add() {
w := o.w
w.TB.Helper()
for _, earlier := range o.types {
w.DefaultLadder(earlier, o.away)
}
n := len(o.types) + 1
orgType := w.OrgType(fmt.Sprintf("r%d", n))
o.types = append(o.types, orgType)
w.DefaultLadder(orgType, o.l)
w.Org(fmt.Sprintf("First %d", n), orgType, o.owner)
w.Org(fmt.Sprintf("Second %d", n), orgType, o.owner)
// Rank 0 alternates between the first two products, so every reorder
// changes it.
products := *o.products
order := []string{products[0].ID, products[1].ID}
if n%2 == 1 {
order[0], order[1] = order[1], order[0]
}
cfg := entitlements.PlanActConfig{Limits: entitlements.PlanActLimits{
LockTimeout: 5 * time.Second, Cap: 1, Budget: time.Minute, ListBatch: 500, Deadline: time.Minute,
}}
res, err := entitlements.ReorderTiers(w.Ctx, w.DB, entitlements.ReorderTiersInput{
LadderID: o.l.ID, Submitted: order, Disposition: "grandfather", OperatorPersonID: o.owner.ID, RequestID: fmt.Sprintf("req-%d", n), Acts: cfg,
})
if err != nil {
w.TB.Fatalf("reorder the ladder: %v", err)
}
// The obligation the request left pending fails every attempt.
var pool string
if err := w.DB.QueryRowContext(w.Ctx, `SELECT pool_id::text FROM core.plan_act_obligations
WHERE act_id = $1 AND status = 'pending' LIMIT 1`, res.ActID).Scan(&pool); err != nil {
w.TB.Fatalf("find the pending obligation: %v", err)
}
q := entitlements.New(w.DB)
for range 5 {
if _, err := q.RecordPlanActObligationFailure(w.Ctx, entitlements.RecordPlanActObligationFailureParams{
ActID: res.ActID, PoolID: pool, Step: "begin", Error: "boom"}); err != nil {
w.TB.Fatalf("fail the obligation: %v", err)
}
}
}
// The ladder page reads the open reorders of the ladder in one statement and
// their unmoved organizations in another, however many reorders are open
// (plan-acts "The org-types page reads a fixed number of times", applied to the
// ladder). The table above counts the page's statements as the reorders grow;
// this test names the two.
func TestLadderPageReadsItsLinesInTwoStatements(t *testing.T) {
e := newReadCountEnv(t, nil, false)
owner := e.w.Person("Owner")
grow, l, products := newCountedLadder(e.w)
grow()
reorders := newOpenReorders(e.w, owner, l, products)
for range 2 {
reorders.add()
}
for _, grown := range []int{2, 4} {
for len(reorders.types) < grown {
reorders.add()
}
if open, err := entitlements.ReadOpenPlanActsByLadder(e.w.Ctx, e.w.DB, entitlements.PlanActConfig{}, l.ID, 50); err != nil || len(open) != grown {
t.Fatalf("the ladder has %d open reorders (error %v), want %d", len(open), err, grown)
}
got := e.get("/operator/plan-ladders/" + l.ID)
if got["ListOpenPlanActsByLadder"] != 1 || got["ListUnmovedOrganizations"] != 1 {
t.Errorf("with %d open reorders the page ran ListOpenPlanActsByLadder %d times and ListUnmovedOrganizations %d times, want once each",
grown, got["ListOpenPlanActsByLadder"], got["ListUnmovedOrganizations"])
}
}
}
+204 -24
View File
@@ -14,18 +14,21 @@ package server_test
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
"github.com/google/uuid"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/htmltest"
)
// postLadder invokes a ladder-scoped handler with the harness's
@@ -34,14 +37,17 @@ import (
// §3), not in the body.
func (h *otcHarness) postLadder(handler http.HandlerFunc, ladderID string, form url.Values) (int, string, string) {
h.t.Helper()
req := httptest.NewRequestWithContext(h.ctx, http.MethodPost, "/", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetPathValue("ladderID", ladderID)
rec := httptest.NewRecorder()
handler(rec, req)
rec := h.postLadderRecorded(handler, ladderID, form)
return rec.Code, rec.Body.String(), rec.Header().Get("HX-Trigger")
}
// postLadderRecorded is postLadder with the whole response. The request runs as
// the server runs one (postPath): its logger carries the request id.
func (h *otcHarness) postLadderRecorded(handler http.HandlerFunc, ladderID string, form url.Values) *httptest.ResponseRecorder {
h.t.Helper()
return h.postPath(handler, map[string]string{"ladderID": ladderID}, form)
}
// trAddTier appends a fresh published product as the ladder's bottom tier
// (rank self-assigned), returning the product ID.
func trAddTier(t *testing.T, database *sql.DB, ladderID, name string) string {
@@ -222,20 +228,106 @@ func TestTierReorder_CosmeticCommitWritesRanksOnly(t *testing.T) {
}
}
// A reorder whose counts cannot be read, with nothing settled, answers the
// bare toast of the rank change and starts the drain once for the work it
// could not see.
func TestTierReorder_UnreadCountsAnswerTheBareToastAndStartTheDrain(t *testing.T) {
database := dbtest.Open(t)
f := newOtcFixture(t, database)
temporal, started := drainStarts()
acts := refusingActs(planActLimits(50, time.Minute), refusingEntitlementsQ{listPending: true})
h := newOtcHarnessWith(t, database, f.operatorID, otcOptions{acts: acts, temporal: temporal})
prodC := trAddTier(t, database, f.ladderA, "tr-bare-"+uuid.New().String()[:8])
_, holderPool := otcOrg(t, database, f, "TR Bare Holder", true)
otcConferDefaultA(t, database, f, holderPool)
code, body, trigger := h.postLadder(h.handler.ReorderPlanLadderTiers, f.ladderA,
url.Values{"order": {prodC + "," + f.prodA}, "needs_disposition": {"true"}, "bucket2_disposition": {"grandfather"}})
if code != http.StatusOK {
t.Fatalf("commit status=%d body=%.300s", code, body)
}
var product, display string
if err := database.QueryRowContext(context.Background(), `SELECT name FROM core.products WHERE product_id = $1`, prodC).Scan(&product); err != nil {
t.Fatalf("read the product's name: %v", err)
}
if err := database.QueryRowContext(context.Background(), `SELECT display_name FROM core.org_types WHERE org_type = $1`, f.orgType).Scan(&display); err != nil {
t.Fatalf("read the org type's name: %v", err)
}
var events map[string]string
if err := json.Unmarshal([]byte(trigger), &events); err != nil {
t.Fatalf("the trigger %q is not JSON: %v", trigger, err)
}
if want := product + " is now the default plan for new " + display + " organizations."; events["showSuccessToast"] != want {
t.Errorf("the toast reads %q, want %q", events["showSuccessToast"], want)
}
if got := started(); len(got) != 1 {
t.Errorf("drain starts = %v, want the drain started once", got)
}
if r := trRank(t, database, f.ladderA, prodC); r != 0 {
t.Errorf("prodC rank = %d, want 0", r)
}
}
// A commit whose submission names none of the ladder's tiers, a page that
// shows nothing of the ladder as it stands, commits nothing: no act, no rank
// written, no toast, and the page re-rendered with the current order.
func TestTierReorder_WhollyStaleCommitWritesNothingAndRerendersThePage(t *testing.T) {
database := dbtest.Open(t)
f := newOtcFixture(t, database)
h := newOtcHarness(t, database, f.operatorID)
prodC := trAddTier(t, database, f.ladderA, "tr-ws-"+uuid.New().String()[:8])
ranks := func() (int, int) {
return trRank(t, database, f.ladderA, f.prodA), trRank(t, database, f.ladderA, prodC)
}
rankA, rankC := ranks()
acts := func() int {
var n int
if err := database.QueryRowContext(context.Background(), `SELECT count(*) FROM core.plan_act_details WHERE plan_ladder_id = $1`, f.ladderA).Scan(&n); err != nil {
t.Fatalf("count the ladder's acts: %v", err)
}
return n
}
before := acts()
code, body, trigger := h.postLadder(h.handler.ReorderPlanLadderTiers, f.ladderA,
url.Values{"order": {uuid.NewString() + "," + uuid.NewString()}})
if code != http.StatusOK {
t.Fatalf("commit status=%d body=%.300s", code, body)
}
if trigger != "" {
t.Errorf("a wholly stale commit answered with the trigger %q, want none", trigger)
}
if gotA, gotC := ranks(); gotA != rankA || gotC != rankC {
t.Errorf("ranks = %d, %d after the commit, want %d, %d", gotA, gotC, rankA, rankC)
}
if n := acts(); n != before {
t.Errorf("the commit recorded %d acts, want none", n-before)
}
htmltest.Parse(t, body).One(t, "tbody tr:first-child")
}
// Grandfather enactment through the reorder commit: ranks renumber, the
// incumbent is reissued as an operator-attributed legacy grant with a transfer
// supersession (incumbent grant row untouched), plan-less pools initiate onto
// the new rank 0, org_types is never written, and re-running the same order is
// a no-op.
// the new rank 0, org_types is never written, re-running the same order is a
// no-op, and an organization whose move failed is moved by Retry from the
// ladder page without the ranks being written again.
func TestTierReorder_GrandfatherCommitAndRerun(t *testing.T) {
database := dbtest.Open(t)
ctx := context.Background()
f := newOtcFixture(t, database)
h := newOtcHarness(t, database, f.operatorID)
prodC := trAddTier(t, database, f.ladderA, "tr-c-"+uuid.New().String()[:8])
_, planlessPool := otcOrg(t, database, f, "TR Planless", true)
holderOrg, holderPool := otcOrg(t, database, f, "TR Holder", true)
otcConferDefaultA(t, database, f, holderPool)
stuckOrg, stuckPool := otcOrg(t, database, f, "TR Stuck", true)
otcConferDefaultA(t, database, f, stuckPool)
// The stuck organization's move fails at its read of the type's default until
// the cause is fixed below.
broken := refusingActs(planActLimits(50, time.Minute), refusingEntitlementsQ{brokenPool: stuckPool})
h := newOtcHarnessActs(t, database, f.operatorID, broken)
var incumbentGrant string
if err := database.QueryRowContext(ctx,
@@ -244,13 +336,20 @@ func TestTierReorder_GrandfatherCommitAndRerun(t *testing.T) {
t.Fatalf("resolve incumbent: %v", err)
}
code, body, trigger := h.postLadder(h.handler.ReorderPlanLadderTiers, f.ladderA,
rec := h.postLadderRecorded(h.handler.ReorderPlanLadderTiers, f.ladderA,
url.Values{"order": {strings.Join([]string{prodC, f.prodA}, ",")}, "needs_disposition": {"true"}, "bucket2_disposition": {"grandfather"}})
if code != http.StatusOK {
t.Fatalf("commit status=%d body=%.300s", code, body)
if rec.Code != http.StatusOK {
t.Fatalf("commit status=%d body=%.300s", rec.Code, rec.Body)
}
if !strings.Contains(trigger, "is now the default plan for new") {
t.Errorf("commit toast missing the default-change headline; trigger=%q", trigger)
trigger := rec.Header().Get("HX-Trigger")
for _, want := range []string{"is now the default plan for new", "1 grandfathered", "1 initiated", "1 not yet reached"} {
if !strings.Contains(trigger, want) {
t.Errorf("the toast %q lacks %q", trigger, want)
}
}
var actID string
if err := database.QueryRowContext(ctx, `SELECT act_id::text FROM core.plan_act_details WHERE plan_ladder_id = $1 AND cause = 'tiers_reordered'`, f.ladderA).Scan(&actID); err != nil {
t.Fatalf("read the act: %v", err)
}
if r := trRank(t, database, f.ladderA, prodC); r != 0 {
@@ -260,13 +359,15 @@ func TestTierReorder_GrandfatherCommitAndRerun(t *testing.T) {
if got := otcDefaultLadder(t, database, f.orgType); got != f.ladderA {
t.Errorf("org_types default ladder changed to %q; a reorder must never write it", got)
}
// Legacy grant: operator-attributed, lineaged to the incumbent; transfer
// recorded; incumbent grant row untouched; holder still delivers prodA.
// Legacy grant: operator-attributed, lineaged to the incumbent, naming the
// reorder and its act; transfer recorded; incumbent grant row untouched;
// holder still delivers prodA.
if n := otcScalar(t, database, `
SELECT count(*) FROM core.grants
WHERE granted_to_org_id = $1 AND grant_reason = 'legacy'
AND granted_by_person_id = $2 AND extends_grant_id = $3`,
holderOrg, f.operatorID, incumbentGrant); n != 1 {
AND granted_by_person_id = $2 AND extends_grant_id = $3 AND act_id = $4
AND description LIKE 'Grandfathered: plan-ladder tier reorder (%)'`,
holderOrg, f.operatorID, incumbentGrant, actID); n != 1 {
t.Errorf("legacy grants = %d, want 1", n)
}
if n := otcScalar(t, database, `
@@ -296,7 +397,7 @@ func TestTierReorder_GrandfatherCommitAndRerun(t *testing.T) {
// Idempotent re-run: the same order finds rank 0 unchanged — no enactment,
// zero new conferral rows.
before := otcCounts(t, database, f)
code, _, _ = h.postLadder(h.handler.ReorderPlanLadderTiers, f.ladderA,
code, _, _ := h.postLadder(h.handler.ReorderPlanLadderTiers, f.ladderA,
url.Values{"order": {strings.Join([]string{prodC, f.prodA}, ",")}, "needs_disposition": {"true"}, "bucket2_disposition": {"grandfather"}})
if code != http.StatusOK {
t.Fatalf("re-run status=%d", code)
@@ -305,6 +406,83 @@ func TestTierReorder_GrandfatherCommitAndRerun(t *testing.T) {
before["junctions"] != after["junctions"] || before["transitions"] != after["transitions"] {
t.Errorf("re-run wrote rows: before=%v after=%v", before, after)
}
retryFailedReorderedOrganization(t, database, f, h, broken, actID, stuckOrg, stuckPool, prodC)
}
// retryFailedReorderedOrganization is the second half of the reorder's story:
// the stuck organization's move failed in the request, fails four more times in
// the drain until its obligation is failed, shows on the ladder page as not
// moved, and is moved onto the type's current default by Retry once the cause
// is fixed, without the ranks being written or the act recorded again.
func retryFailedReorderedOrganization(t *testing.T, database *sql.DB, f otcFixture, broken *otcHarness, brokenActs entitlements.PlanActConfig, actID, stuckOrg, stuckPool, prodC string) {
t.Helper()
ctx := context.Background()
drainUntilFailed(t, database, brokenActs, actID, stuckPool)
// The page names the organization and the act, with Retry beside it.
lines := htmltest.Parse(t, ladderPage(broken, f.ladderA, prodC, f.prodA)).One(t, "#plan-act-"+actID)
if got := lines.Text(); !strings.Contains(got, "1 organization not moved.") || !strings.Contains(got, "TR Stuck: The move could not be completed.") || !strings.Contains(got, "Reference: "+actID) {
t.Fatalf("the page's lines read %q, want the stuck organization, its sentence and the act's reference", got)
}
retryRoute := "/partials/operator/plan-ladders/" + f.ladderA + "/plan-acts/" + actID + "/retry"
if hx, _ := lines.One(t, "button").Attr("hx-post"); hx != retryRoute {
t.Fatalf("Retry posts to %q, want %q", hx, retryRoute)
}
// The cause is fixed (a handler with no broken pool); Retry moves the
// organization onto the current default.
temporal, started := drainStarts()
fixed := newOtcHarnessWith(t, database, f.operatorID, otcOptions{acts: planActLimits(50, time.Minute), temporal: temporal})
var rankedAt time.Time
if err := database.QueryRowContext(ctx, `SELECT max(updated_at) FROM core.plan_ladder_tiers WHERE plan_ladder_id = $1`, f.ladderA).Scan(&rankedAt); err != nil {
t.Fatalf("read when the ranks were written: %v", err)
}
reorders := otcScalar(t, database, `SELECT count(*) FROM core.plan_act_details WHERE plan_ladder_id = $1 AND cause = 'tiers_reordered'`, f.ladderA)
code, body := ladderRetry(fixed, f.ladderA, actID)
if code != http.StatusOK || strings.Contains(body, "plan-act-"+actID) {
t.Fatalf("Retry answered %d %.400s, want the page with no lines left", code, body)
}
if n := otcScalar(t, database, `SELECT count(*) FROM core.grants WHERE granted_to_org_id = $1 AND grant_reason = 'legacy'`, stuckOrg); n != 1 {
t.Errorf("legacy grants of the stuck organization = %d, want 1: Retry moved it", n)
}
var retryAct string
var requeuedBy sql.NullString
var obligationStatus, outcome string
if err := database.QueryRowContext(ctx, `SELECT ob.status, ob.outcome, ob.requeued_by_act_id::text, d.act_id::text
FROM core.plan_act_obligations ob JOIN core.plan_act_details d ON d.retried_act_id = ob.act_id
WHERE ob.act_id = $1 AND ob.pool_id = $2`, actID, stuckPool).Scan(&obligationStatus, &outcome, &requeuedBy, &retryAct); err != nil {
t.Fatalf("read the retried obligation and its Retry act: %v", err)
}
if obligationStatus != "settled" || outcome != "grandfathered" || requeuedBy.String != retryAct {
t.Errorf("the obligation is %s/%s requeued by %q, want settled/grandfathered requeued by the Retry act %q", obligationStatus, outcome, requeuedBy.String, retryAct)
}
var actor string
if err := database.QueryRowContext(ctx, `SELECT actor_person_id::text FROM core.acts WHERE act_id = $1 AND cause = 'plan_act_retried'`, retryAct).Scan(&actor); err != nil || actor != f.operatorID {
t.Errorf("the Retry act's actor = %q (%v), want the operator %q", actor, err, f.operatorID)
}
// The ranks were not written again, and no second reorder was recorded.
var rankedNow time.Time
if err := database.QueryRowContext(ctx, `SELECT max(updated_at) FROM core.plan_ladder_tiers WHERE plan_ladder_id = $1`, f.ladderA).Scan(&rankedNow); err != nil {
t.Fatalf("read when the ranks were written: %v", err)
}
if !rankedNow.Equal(rankedAt) {
t.Errorf("the ladder's tiers were written at %v by Retry, last written at %v", rankedNow, rankedAt)
}
if n := otcScalar(t, database, `SELECT count(*) FROM core.plan_act_details WHERE plan_ladder_id = $1 AND cause = 'tiers_reordered'`, f.ladderA); n != reorders {
t.Errorf("%d reorders recorded after Retry, want the %d before it", n, reorders)
}
if got := started(); len(got) != 0 {
t.Errorf("drain starts = %v for a Retry that finished its act", got)
}
// A second press has nothing failed to return: it writes no act.
if again, _ := ladderRetry(fixed, f.ladderA, actID); again != http.StatusOK {
t.Errorf("second Retry answered %d", again)
}
if n := otcScalar(t, database, `SELECT count(*) FROM core.plan_act_details WHERE retried_act_id = $1`, actID); n != 1 {
t.Errorf("%d Retry acts after a second press, want 1", n)
}
}
// Migrate enactment through the reorder commit: the incumbent default-source
@@ -381,12 +559,14 @@ func TestAddTierInductionAlignsLiveProvisions(t *testing.T) {
}
// A tier-reorder commit racing an org-type default-change commit on the same
// pool. The two enactments serialize on the per-pool FOR UPDATE lock:
// whichever enacts first grandfathers the incumbent, the other re-derives the
// bucket in-tx and finds a legacy-held (other-source) pool — never a second
// grandfather. The reorder's renumbering takes the rendezvous exclusively, so
// it commits before or after each enactment's conferral, never between that
// conferral's rank reads, and the grandfather is recorded as one transfer.
// type. Each records its act in T0 under the exclusive rendezvous, so one
// commits before the other and the later act owns the type; the earlier act's
// obligation on the pool is superseded or is moved against the current default
// and rank 0, which the moves read when they run. The moves hold the shared
// rendezvous and the pool's lock one at a time, and the second to reach the pool
// finds the incumbent already a legacy grant (other-source), never a second
// grandfather. Whatever the order, the pool ends with one legacy grant, one
// transfer and one live attachment.
func TestTierReorder_CommitRacesDefaultChangeCommit(t *testing.T) {
database := dbtest.Open(t)
f := newOtcFixture(t, database)
@@ -11,8 +11,8 @@ package server
// compared with a file under testdata/plan_acts: a default change whose T0 takes
// the rendezvous exclusively and whose moves run after it, a removal that holds
// every holder's pool, a change to a ladder's tiers that takes the rendezvous
// exclusively, so it waits for every conferral in flight, and a reorder that
// enacts against the rank 0 it saved.
// exclusively, so it waits for every conferral in flight, and a reorder whose
// moves read the rank 0 as it stands when they run.
import (
"context"
@@ -464,17 +464,19 @@ func goldenCase(test, name string) string {
return filepath.Join("testdata", "plan_acts", test, strings.ReplaceAll(name, " ", "-")+".json")
}
// TestReorderEnactsAgainstTheRankZeroItSaved commits a renumbering of the
// ladder between a reorder's own renumbering and its enactment. The reorder
// saved Plus as rank 0 and enacts against it; by then the ladder has Basic at
// rank 0 again, so it grandfathers Holder onto a legacy grant of Basic, the
// rank 0 of the ladder as it now stands (#187). A side transaction holds
// Basic's tier row, so the reorder waits inside its renumbering while it
// holds the exclusive rendezvous; the other renumbering queues on the
// rendezvous behind it, and PostgreSQL grants it the lock when the reorder
// commits, before the reorder's enactment asks for the lock.
func TestReorderEnactsAgainstTheRankZeroItSaved(t *testing.T) {
// TestReorderEnactsAgainstTheCurrentRankZero commits a renumbering of the
// ladder between a reorder's T0 and its first move. The reorder made Plus rank
// 0 and Holder holds Basic through the default; by the time Holder's obligation
// is moved the ladder has Basic at rank 0 again, so the move classifies Holder
// against Basic, finds it already there and settles it as already at the
// default, with no legacy grant of Basic (#187). A side transaction holds
// Basic's tier row, so the reorder waits inside its renumbering while it holds
// the exclusive rendezvous; the other renumbering queues on the rendezvous
// behind it, and PostgreSQL grants it the lock when the reorder commits, before
// the reorder's listing and moves ask for the lock shared.
func TestReorderEnactsAgainstTheCurrentRankZero(t *testing.T) {
f := newActFixture(t)
f.lockQueueLimits() // the listing and the move queue behind the other renumbering
basic, plus := f.product("Basic", 1), f.product("Plus", 2)
main := f.ladder("Main", basic, plus)
f.floored("Holder", f.orgType("T", &main))
+68 -21
View File
@@ -103,13 +103,14 @@ type actHooks struct {
type actRefusals struct {
productName bool // GetProductByID
saveDefault bool // UpdateOrgTypeDefaultPlanLadder
listOrgs bool // ListOrganizationsByType
orgTypes bool // ListOrgTypes
// actOrgTypes refuses ListOrgTypes in the act's organization querier
// only, so the page the handler re-renders reads it.
actOrgTypes bool
// writeObligations refuses WritePlanActObligations, the listing batch.
writeObligations bool
// recordAct refuses RecordPlanAct, the write of an act's record.
recordAct bool
}
// actEntQ is the entitlements querier with the refusals and the hooks applied.
@@ -132,6 +133,14 @@ func (q actEntQ) WritePlanActObligations(ctx context.Context, arg entitlements.W
return q.Querier.WritePlanActObligations(ctx, arg)
}
// RecordPlanAct records the act, or refuses without writing.
func (q actEntQ) RecordPlanAct(ctx context.Context, arg entitlements.RecordPlanActParams) (string, error) {
if q.refuse.recordAct {
return "", errReadFailed
}
return q.Querier.RecordPlanAct(ctx, arg)
}
// ListPlanPoolBucketsByOrgType reads the classes, after the hook.
func (q actEntQ) ListPlanPoolBucketsByOrgType(ctx context.Context, arg entitlements.ListPlanPoolBucketsByOrgTypeParams) ([]entitlements.ListPlanPoolBucketsByOrgTypeRow, error) {
if run := q.hooks.beforeClasses; run != nil {
@@ -188,15 +197,6 @@ func (q actOrgQ) ListOrgTypes(ctx context.Context) ([]organization.OrgType, erro
return types, err
}
// ListOrganizationsByType returns the organizations, or the refusal when the scenario sets one.
func (q actOrgQ) ListOrganizationsByType(ctx context.Context, orgType string) ([]organization.Organization, error) {
orgs, err := q.Querier.ListOrganizationsByType(ctx, orgType)
if q.refuse.listOrgs {
return orgs, errReadFailed
}
return orgs, err
}
// newActFixture is a fresh world, its normalizer and the operator who signs the requests in.
func newActFixture(t *testing.T) *actFixture {
t.Helper()
@@ -555,9 +555,7 @@ func (f *actFixture) failOneMove(orgType string, next world.Ladder, stuck world.
f.t.Helper()
f.hooks.brokenPool = stuck.PoolID
defer func() { f.hooks.brokenPool = "" }()
cfg := entitlements.PlanActConfig{Entitlements: func(d entitlements.DBTX) entitlements.Querier {
return actEntQ{Querier: entitlements.New(d), hooks: &f.hooks}
}}
cfg := f.breakingActs()
res, err := entitlements.ChangeOrgTypeDefault(f.w.Ctx, f.w.DB, entitlements.DefaultChangeInput{
OrgType: orgType, Ladder: uuid.NullUUID{UUID: uuid.MustParse(next.ID), Valid: true}, Disposition: "migrate",
OperatorPersonID: f.operator.ID, Acts: cfg,
@@ -565,16 +563,53 @@ func (f *actFixture) failOneMove(orgType string, next world.Ladder, stuck world.
if err != nil {
f.t.Fatalf("the default change: %v", err)
}
f.drainUntilFailed(cfg, res.ActID, stuck)
return res.ActID
}
// failOneReorderMove commits a reorder of l to order with Migrate whose move of
// stuck's pool fails, and runs the act's drain until that obligation is failed.
// It returns the act's id; the move works again afterwards.
func (f *actFixture) failOneReorderMove(l world.Ladder, stuck world.Org, order ...world.Product) string {
f.t.Helper()
f.hooks.brokenPool = stuck.PoolID
defer func() { f.hooks.brokenPool = "" }()
cfg := f.breakingActs()
ids := make([]string, len(order))
for i, p := range order {
ids[i] = p.ID
}
res, err := entitlements.ReorderTiers(f.w.Ctx, f.w.DB, entitlements.ReorderTiersInput{
LadderID: l.ID, Submitted: ids, Disposition: "migrate", OperatorPersonID: f.operator.ID, Acts: cfg,
})
if err != nil {
f.t.Fatalf("the reorder: %v", err)
}
f.drainUntilFailed(cfg, res.ActID, stuck)
return res.ActID
}
// breakingActs builds the act's queriers with the hooks, so a pool the fixture
// breaks (hooks.brokenPool) fails its move at its read of the type's default.
func (f *actFixture) breakingActs() entitlements.PlanActConfig {
return entitlements.PlanActConfig{Entitlements: func(d entitlements.DBTX) entitlements.Querier {
return actEntQ{Querier: entitlements.New(d), hooks: &f.hooks}
}}
}
// drainUntilFailed runs the drain's passes over the act until the obligation of
// stuck's pool is failed.
func (f *actFixture) drainUntilFailed(cfg entitlements.PlanActConfig, actID string, stuck world.Org) {
f.t.Helper()
q := dbtest.On(f.t, f.w.DB)
for pass := 0; pass < 10 && q.String(`SELECT status FROM core.plan_act_obligations WHERE act_id = $1 AND pool_id = $2`, res.ActID, stuck.PoolID) == "pending"; pass++ {
if _, err := entitlements.DrainPlanAct(f.w.Ctx, f.w.DB, cfg, res.ActID, entitlements.DrainPlanActInput{Mode: entitlements.DrainInBackground}); err != nil {
for pass := 0; pass < 10 && q.String(`SELECT status FROM core.plan_act_obligations WHERE act_id = $1 AND pool_id = $2`, actID, stuck.PoolID) == "pending"; pass++ {
if _, err := entitlements.DrainPlanAct(f.w.Ctx, f.w.DB, cfg, actID, entitlements.DrainPlanActInput{Mode: entitlements.DrainInBackground}); err != nil {
f.t.Fatalf("drain: %v", err)
}
}
if got := q.String(`SELECT status FROM core.plan_act_obligations WHERE act_id = $1 AND pool_id = $2`, res.ActID, stuck.PoolID); got != "failed" {
if got := q.String(`SELECT status FROM core.plan_act_obligations WHERE act_id = $1 AND pool_id = $2`, actID, stuck.PoolID); got != "failed" {
f.t.Fatalf("the stuck obligation is %q, want failed", got)
}
return res.ActID
}
// endDefault ends the default position of o and restores nothing, as the first
@@ -643,6 +678,9 @@ func actChangeDefault(h *OperatorPartialsHandler) http.HandlerFunc {
return h.CommitOrgTypeDefaultChange
}
func actRetry(h *OperatorPartialsHandler) http.HandlerFunc { return h.RetryOrgTypePlanAct }
func actRetryOnLadder(h *OperatorPartialsHandler) http.HandlerFunc {
return h.RetryLadderPlanAct
}
// actOnTier is the path values of a request about one tier.
func actOnTier(l world.Ladder, p world.Product) []authtest.RequestOpt {
@@ -1131,20 +1169,29 @@ var planActScenarios = []struct {
f.org("Planless", t)
return f.act(actReorderTiers, actOrder("grandfather", noset, basic), authtest.PathValue("ladderID", main.ID))
}},
{"reorder-list-fails", func(f *actFixture) actGolden {
{"reorder-listing-fails", func(f *actFixture) actGolden {
basic, plus := f.product("Basic", 1), f.product("Plus", 2)
main := f.ladder("Main", basic, plus)
f.floored("Holder", f.orgType("T", &main))
f.refuse.listOrgs = true
f.refuse.writeObligations = true
return f.act(actReorderTiers, actOrder("grandfather", plus, basic), authtest.PathValue("ladderID", main.ID))
}},
{"reorder-org-types-unread", func(f *actFixture) actGolden {
{"reorder-record-fails", func(f *actFixture) actGolden {
basic, plus := f.product("Basic", 1), f.product("Plus", 2)
main := f.ladder("Main", basic, plus)
f.floored("Holder", f.orgType("T", &main))
f.refuse.orgTypes = true
f.refuse.recordAct = true
return f.act(actReorderTiers, actOrder("grandfather", plus, basic), authtest.PathValue("ladderID", main.ID))
}},
{"reorder-retry", func(f *actFixture) actGolden {
basic, plus := f.product("Basic", 1), f.product("Plus", 2)
main := f.ladder("Main", basic, plus)
t := f.orgType("T", &main)
stuck := f.floored("Stuck", t)
f.floored("Moved", t)
act := f.failOneReorderMove(main, stuck, plus, basic)
return f.act(actRetryOnLadder, url.Values{}, authtest.PathValue("ladderID", main.ID), authtest.PathValue("actID", act))
}},
{"reorder-begin-fails", func(f *actFixture) actGolden {
basic, plus := f.product("Basic", 1), f.product("Plus", 2)
main := f.ladder("Main", basic, plus)
@@ -9,7 +9,9 @@
"begin",
"SET LOCAL lock_timeout = '5s'",
"SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
"rollback"
"rollback",
"begin isolation level repeatable read read only",
"commit"
],
"rows": {
"tiers": [
@@ -9,7 +9,9 @@
"begin",
"SET LOCAL lock_timeout = '5s'",
"SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
"rollback"
"rollback",
"begin isolation level repeatable read read only",
"commit"
],
"rows": {
"tiers": [
@@ -17,6 +17,8 @@
"DeleteContributionsByProvisionID",
"CreateNumericEntitlementContribution",
"UpdateNumericEntitlementLimit",
"commit",
"begin isolation level repeatable read read only",
"commit"
],
"rows": {
+4 -2
View File
@@ -2,11 +2,13 @@
"answer": [
"status 200",
"Hx-Trigger showErrorToast: Failed to add tier. Reference: <uuid:1>.",
"[role=\"alert\"]: Failed to add tier. Reference: <uuid:1>."
"[role=\"alert\"]: Failed to add tier. Reference: <uuid:1>.",
"[role=\"alert\"]: Pending plan changes could not be read. Reference: <uuid:1>."
],
"checked": [],
"logs": [
"level=ERROR msg=\"tier add failed\" request_id=<uuid:1> step=begin ladder_id=<ladder:Main> product_id=<product:Extra> error=\"begin: sql: database is closed\""
"level=ERROR msg=\"tier add failed\" request_id=<uuid:1> step=begin ladder_id=<ladder:Main> product_id=<product:Extra> error=\"begin: sql: database is closed\"",
"level=ERROR msg=\"failed to read the pending plan changes\" request_id=<uuid:1> error=\"sql: database is closed\" plan_ladder_id=<ladder:Main>"
],
"statements": [],
"rows": {
@@ -12,7 +12,9 @@
"LockResourcePool",
"RecordPlanAct",
"CreatePlanLadderTier",
"rollback"
"rollback",
"begin isolation level repeatable read read only",
"commit"
],
"statements_with_reads": [
"ListActiveProducts",
@@ -33,6 +35,9 @@
"GetPlanLadderByID",
"GetPlanLadderByID",
"ListOrgTypes",
"begin isolation level repeatable read read only",
"ListOpenPlanActsByLadder",
"commit",
"ListTiersByLadderWithProducts",
"CountActiveAttachmentsPerTier",
"ListActiveProducts",
@@ -12,7 +12,9 @@
"LockResourcePool",
"RecordPlanAct",
"CreatePlanLadderTier",
"rollback"
"rollback",
"begin isolation level repeatable read read only",
"commit"
],
"rows": {
"tiers": [
+3 -1
View File
@@ -12,7 +12,9 @@
"begin",
"SET LOCAL lock_timeout = '5s'",
"SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
"rollback"
"rollback",
"begin isolation level repeatable read read only",
"commit"
],
"rows": {
"tiers": [
+7 -7
View File
@@ -49,12 +49,12 @@
"reorder": {
"answer": [
"status 200",
"Hx-Trigger showErrorToast: Failed to reorder tiers.",
"[role=\"alert\"]: Failed to reorder tiers."
"Hx-Trigger showErrorToast: Another change collided with this one; try again.",
"[role=\"alert\"]: Another change collided with this one; try again."
],
"checked": [],
"logs": [
"level=ERROR msg=\"failed to begin tier reorder tx\" error=\"take materialization rendezvous: ERROR: canceling statement due to lock timeout (SQLSTATE 55P03)\""
"level=WARN msg=\"reorder refused for a change in progress\" request_id=<uuid:4> error=\"begin: take materialization rendezvous: ERROR: canceling statement due to lock timeout (SQLSTATE 55P03)\" plan_ladder_id=<ladder:Spare>"
]
}
},
@@ -73,19 +73,19 @@
],
"grants": [
"grant_id | org | product | grant_reason | granted_by | extends | description | status | act",
"<uuid:4> | <org:Holder> | <product:Basic> | default | none | none | | active | none"
"<uuid:5> | <org:Holder> | <product:Basic> | default | none | none | | active | none"
],
"provisions": [
"provision | pool | grant_id | status",
"<uuid:5> | <pool:Holder> | <uuid:4> | active"
"<uuid:6> | <pool:Holder> | <uuid:5> | active"
],
"positions": [
"provision | ladder | product | status | ended",
"<uuid:5> | <ladder:Main> | <product:Basic> | active | false"
"<uuid:6> | <ladder:Main> | <product:Basic> | active | false"
],
"transitions": [
"pool | provision | ladder | from_rank | to_rank | transition_type | actor_type | actor | reason",
"<pool:Holder> | <uuid:5> | <ladder:Main> | - | 0 | initiate | system | none | test fixture"
"<pool:Holder> | <uuid:6> | <ladder:Main> | - | 0 | initiate | system | none | test fixture"
],
"entitlements": [
"pool | resource_key | resource_limit",
+4 -2
View File
@@ -2,11 +2,13 @@
"answer": [
"status 200",
"Hx-Trigger showErrorToast: Failed to remove tier. Reference: <uuid:1>.",
"[role=\"alert\"]: Failed to remove tier. Reference: <uuid:1>."
"[role=\"alert\"]: Failed to remove tier. Reference: <uuid:1>.",
"[role=\"alert\"]: Pending plan changes could not be read. Reference: <uuid:1>."
],
"checked": [],
"logs": [
"level=ERROR msg=\"tier delete failed\" request_id=<uuid:1> step=begin ladder_id=<ladder:Main> product_id=<product:Plus> error=\"begin: sql: database is closed\""
"level=ERROR msg=\"tier delete failed\" request_id=<uuid:1> step=begin ladder_id=<ladder:Main> product_id=<product:Plus> error=\"begin: sql: database is closed\"",
"level=ERROR msg=\"failed to read the pending plan changes\" request_id=<uuid:1> error=\"sql: database is closed\" plan_ladder_id=<ladder:Main>"
],
"statements": [],
"rows": {
@@ -15,6 +15,8 @@
"UpdateTierRank",
"UpdateTierRank",
"UpdateTierRank",
"commit",
"begin isolation level repeatable read read only",
"commit"
],
"rows": {
+3 -1
View File
@@ -10,7 +10,9 @@
"begin",
"SET LOCAL lock_timeout = '5s'",
"SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
"rollback"
"rollback",
"begin isolation level repeatable read read only",
"commit"
],
"rows": {
"tiers": [
@@ -10,7 +10,9 @@
"begin",
"SET LOCAL lock_timeout = '5s'",
"SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
"rollback"
"rollback",
"begin isolation level repeatable read read only",
"commit"
],
"rows": {
"tiers": [
@@ -12,7 +12,9 @@
"begin",
"SET LOCAL lock_timeout = '5s'",
"SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
"rollback"
"rollback",
"begin isolation level repeatable read read only",
"commit"
],
"rows": {
"tiers": [
@@ -10,7 +10,9 @@
"begin",
"SET LOCAL lock_timeout = '5s'",
"SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
"rollback"
"rollback",
"begin isolation level repeatable read read only",
"commit"
],
"rows": {
"tiers": [
@@ -15,6 +15,8 @@
"UpdateTierRank",
"UpdateTierRank",
"UpdateTierRank",
"commit",
"begin isolation level repeatable read read only",
"commit"
],
"rows": {
+8 -6
View File
@@ -2,11 +2,13 @@
"answer": [
"status 200",
"Hx-Trigger showErrorToast: Failed to remove the tier; nothing was changed.",
"[role=\"alert\"]: Failed to remove the tier; nothing was changed."
"[role=\"alert\"]: Failed to remove the tier; nothing was changed.",
"[role=\"alert\"]: Pending plan changes could not be read. Reference: <uuid:1>."
],
"checked": [],
"logs": [
"level=ERROR msg=\"failed to begin tier removal tx\" error=\"sql: database is closed\""
"level=ERROR msg=\"failed to begin tier removal tx\" error=\"sql: database is closed\"",
"level=ERROR msg=\"failed to read the pending plan changes\" request_id=<uuid:1> error=\"sql: database is closed\" plan_ladder_id=<ladder:Main>"
],
"statements": [],
"rows": {
@@ -21,19 +23,19 @@
],
"grants": [
"grant_id | org | product | grant_reason | granted_by | extends | description | status | act",
"<uuid:1> | <org:Holder> | <product:Basic> | default | none | none | | active | none"
"<uuid:2> | <org:Holder> | <product:Basic> | default | none | none | | active | none"
],
"provisions": [
"provision | pool | grant_id | status",
"<uuid:2> | <pool:Holder> | <uuid:1> | active"
"<uuid:3> | <pool:Holder> | <uuid:2> | active"
],
"positions": [
"provision | ladder | product | status | ended",
"<uuid:2> | <ladder:Main> | <product:Basic> | active | false"
"<uuid:3> | <ladder:Main> | <product:Basic> | active | false"
],
"transitions": [
"pool | provision | ladder | from_rank | to_rank | transition_type | actor_type | actor | reason",
"<pool:Holder> | <uuid:2> | <ladder:Main> | - | 0 | initiate | system | none | test fixture"
"<pool:Holder> | <uuid:3> | <ladder:Main> | - | 0 | initiate | system | none | test fixture"
],
"entitlements": [
"pool | resource_key | resource_limit",
@@ -18,6 +18,8 @@
"DeleteContributionsByProvisionID",
"CreateNumericEntitlementContribution",
"UpdateNumericEntitlementLimit",
"commit",
"begin isolation level repeatable read read only",
"commit"
],
"rows": {
@@ -16,6 +16,8 @@
"DeleteContributionsByProvisionID",
"CreateNumericEntitlementContribution",
"UpdateNumericEntitlementLimit",
"commit",
"begin isolation level repeatable read read only",
"commit"
],
"rows": {
@@ -15,7 +15,9 @@
"LockResourcePool",
"DeleteTier",
"SELECT core.end_conferral($1, $2, $3, $4, $5, $6, $7, $8)",
"rollback"
"rollback",
"begin isolation level repeatable read read only",
"commit"
],
"rows": {
"tiers": [
@@ -19,6 +19,8 @@
"DeleteContributionsByProvisionID",
"CreateNumericEntitlementContribution",
"UpdateNumericEntitlementLimit",
"commit",
"begin isolation level repeatable read read only",
"commit"
],
"rows": {
@@ -24,6 +24,8 @@
"DeleteContributionsByProvisionID",
"CreateNumericEntitlementContribution",
"UpdateNumericEntitlementLimit",
"commit",
"begin isolation level repeatable read read only",
"commit"
],
"rows": {
@@ -10,7 +10,9 @@
"SET LOCAL lock_timeout = '5s'",
"SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
"LockResourcePool",
"rollback"
"rollback",
"begin isolation level repeatable read read only",
"commit"
],
"rows": {
"tiers": [
@@ -12,6 +12,8 @@
"DeleteTier",
"UpdateTierRank",
"UpdateTierRank",
"commit",
"begin isolation level repeatable read read only",
"commit"
],
"rows": {
@@ -18,6 +18,8 @@
"DeleteContributionsByProvisionID",
"CreateNumericEntitlementContribution",
"UpdateNumericEntitlementLimit",
"commit",
"begin isolation level repeatable read read only",
"commit"
],
"rows": {
@@ -18,6 +18,8 @@
"DeleteContributionsByProvisionID",
"CreateNumericEntitlementContribution",
"UpdateNumericEntitlementLimit",
"commit",
"begin isolation level repeatable read read only",
"commit"
],
"rows": {
@@ -5,7 +5,10 @@
],
"checked": [],
"logs": [],
"statements": [],
"statements": [
"begin isolation level repeatable read read only",
"commit"
],
"rows": {
"tiers": [
"ladder | rank | product",
@@ -31,7 +31,9 @@
"SELECT core.end_conferral($1, $2, $3, $4, $5, $6, $7, $8)",
"CreateGrant",
"SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
"rollback"
"rollback",
"begin isolation level repeatable read read only",
"commit"
],
"rows": {
"tiers": [
@@ -30,6 +30,8 @@
"DeleteContributionsByProvisionID",
"CreateNumericEntitlementContribution",
"UpdateNumericEntitlementLimit",
"commit",
"begin isolation level repeatable read read only",
"commit"
],
"rows": {
@@ -1,12 +1,13 @@
{
"answer": [
"status 200",
"Hx-Trigger showErrorToast: NoSet is now the default plan for new T organizations: 1 grandfathered, 1 failed. Failed organizations were skipped; fix the cause, re-drop the same order, and commit again: Planless: could not initiate the default: reapply-defaults: confer default grant: confer grant: ERROR: confer: product <product:NoSet> has no entitlement set and confers nothing (SQLSTATE P0001)",
"[role=\"alert\"]: NoSet is now the default plan for new T organizations: 1 grandfathered, 1 failed. Failed organizations were skipped; fix the cause, re-drop the same order, and commit again: Planless: could not initiate the default: reapply-defaults: confer default grant: confer grant: ERROR: confer: product <product:NoSet> has no entitlement set and confers nothing (SQLSTATE P0001)"
"Hx-Trigger showSuccessToast: NoSet is now the default plan for new T organizations: 1 grandfathered, 1 not yet reached.",
"[id^=\"plan-act-\"]: 1 organization not yet reached."
],
"checked": [],
"logs": [
"level=ERROR msg=\"default-change: initiate failed\" pool_id=<pool:Planless> error=\"reapply-defaults: confer default grant: confer grant: ERROR: confer: product <product:NoSet> has no entitlement set and confers nothing (SQLSTATE P0001)\""
"level=ERROR msg=\"a plan act move failed\" request_id=<uuid:1> act_id=<act:1> org_id=<org:Planless> pool_id=<pool:Planless> step=initiate error=\"reapply-defaults: confer default grant: confer grant: ERROR: confer: product <product:NoSet> has no entitlement set and confers nothing (SQLSTATE P0001)\"",
"level=WARN msg=\"no Temporal client configured; the plan act drain was not started\" request_id=<uuid:1> act_id=<act:1>"
],
"statements": [
"begin",
@@ -16,25 +17,42 @@
"UpdateTierRank",
"UpdateTierRank",
"UpdateTierRank",
"RecordPlanAct",
"commit",
"begin",
"SET LOCAL lock_timeout = '5s'",
"SET LOCAL transaction_timeout = '2s'",
"SET LOCAL lock_timeout = '500ms'",
"SELECT pg_advisory_xact_lock_shared(hashtextextended($1, 0))",
"WritePlanActObligations",
"commit",
"begin",
"SET LOCAL transaction_timeout = '2s'",
"SET LOCAL lock_timeout = '500ms'",
"SELECT pg_advisory_xact_lock_shared(hashtextextended($1, 0))",
"LockResourcePool",
"LockPlanActObligation",
"CreateGrant",
"SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
"SELECT core.assert_rendezvous(false)",
"DeleteContributionsByProvisionID",
"CreateNumericEntitlementContribution",
"UpdateNumericEntitlementLimit",
"SettlePlanActObligation",
"commit",
"begin",
"SET LOCAL lock_timeout = '5s'",
"SET LOCAL transaction_timeout = '2s'",
"SET LOCAL lock_timeout = '500ms'",
"SELECT pg_advisory_xact_lock_shared(hashtextextended($1, 0))",
"LockResourcePool",
"LockPlanActObligation",
"CreateGrant",
"SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
"rollback"
"rollback",
"begin",
"RecordPlanActObligationFailure",
"commit",
"begin isolation level repeatable read read only",
"commit"
],
"rows": {
"tiers": [
@@ -48,43 +66,48 @@
],
"grants": [
"grant_id | org | product | grant_reason | granted_by | extends | description | status | act",
"<uuid:1> | <org:Holder> | <product:Basic> | default | none | none | | active | none",
"<uuid:2> | <org:Holder> | <product:Basic> | legacy | <person:Operator> | <uuid:1> | Grandfathered on org-type default change: plan-ladder tier reorder (Main) | active | none"
"<uuid:2> | <org:Holder> | <product:Basic> | default | none | none | | active | none",
"<uuid:3> | <org:Holder> | <product:Basic> | legacy | <person:Operator> | <uuid:2> | Grandfathered: plan-ladder tier reorder (Main) | active | <act:1>"
],
"provisions": [
"provision | pool | grant_id | status",
"<uuid:3> | <pool:Holder> | <uuid:1> | ended",
"<uuid:4> | <pool:Holder> | <uuid:2> | active"
"<uuid:4> | <pool:Holder> | <uuid:2> | ended",
"<uuid:5> | <pool:Holder> | <uuid:3> | active"
],
"positions": [
"provision | ladder | product | status | ended",
"<uuid:3> | <ladder:Main> | <product:Basic> | ended | true",
"<uuid:4> | <ladder:Main> | <product:Basic> | active | false"
"<uuid:4> | <ladder:Main> | <product:Basic> | ended | true",
"<uuid:5> | <ladder:Main> | <product:Basic> | active | false"
],
"transitions": [
"pool | provision | ladder | from_rank | to_rank | transition_type | actor_type | actor | reason",
"<pool:Holder> | <uuid:3> | <ladder:Main> | - | 0 | initiate | system | none | test fixture",
"<pool:Holder> | <uuid:3> | <ladder:Main> | 1 | - | end | operator | <person:Operator> | plan-ladder tier reorder (Main): grandfathered as legacy",
"<pool:Holder> | <uuid:4> | <ladder:Main> | 1 | 1 | transfer | operator | <person:Operator> | plan-ladder tier reorder (Main): grandfathered as legacy"
"<pool:Holder> | <uuid:4> | <ladder:Main> | - | 0 | initiate | system | none | test fixture",
"<pool:Holder> | <uuid:4> | <ladder:Main> | 1 | - | end | operator | <person:Operator> | plan-ladder tier reorder (Main): grandfathered as legacy",
"<pool:Holder> | <uuid:5> | <ladder:Main> | 1 | 1 | transfer | operator | <person:Operator> | plan-ladder tier reorder (Main): grandfathered as legacy"
],
"entitlements": [
"pool | resource_key | resource_limit",
"<pool:Holder> | plan_golden_seats | 1"
],
"acts": [
"act | cause | actor_type | actor | note | request_id"
"act | cause | actor_type | actor | note | request_id",
"<act:1> | tiers_reordered | person | <person:Operator> | none | <uuid:1>"
],
"act_details": [
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried",
"<act:1> | tiers_reordered | <ladder:Main> | Main | <product:NoSet> | NoSet | <product:Basic> | Basic | none | none | none | none | grandfather | none"
],
"reached_org_types": [
"act | org_type | org_type_name | last_org"
"act | org_type | org_type_name | last_org",
"<act:1> | <orgtype:T> | T | <org:Planless>"
],
"orgs_without_pool": [
"act | org_type | org"
],
"obligations": [
"act | org_type | org | pool | status | outcome | attempts | error_step | superseded_by | requeued_by"
"act | org_type | org | pool | status | outcome | attempts | error_step | superseded_by | requeued_by",
"<act:1> | <orgtype:T> | <org:Holder> | <pool:Holder> | settled | grandfathered | 0 | none | none | none",
"<act:1> | <orgtype:T> | <org:Planless> | <pool:Planless> | pending | none | 1 | initiate | none | none"
]
}
}
@@ -2,11 +2,13 @@
"answer": [
"status 200",
"Hx-Trigger showErrorToast: Failed to reorder tiers.",
"[role=\"alert\"]: Failed to reorder tiers."
"[role=\"alert\"]: Failed to reorder tiers.",
"[role=\"alert\"]: Pending plan changes could not be read. Reference: <uuid:1>."
],
"checked": [],
"logs": [
"level=ERROR msg=\"failed to begin tier reorder tx\" error=\"sql: database is closed\""
"level=ERROR msg=\"failed to reorder tiers\" request_id=<uuid:1> step=begin error=\"begin: sql: database is closed\" plan_ladder_id=<ladder:Main>",
"level=ERROR msg=\"failed to read the pending plan changes\" request_id=<uuid:1> error=\"sql: database is closed\" plan_ladder_id=<ladder:Main>"
],
"statements": [],
"rows": {
+7 -2
View File
@@ -15,6 +15,9 @@
"UpdateTierRank",
"UpdateTierRank",
"UpdateTierRank",
"RecordPlanAct",
"commit",
"begin isolation level repeatable read read only",
"commit"
],
"rows": {
@@ -49,10 +52,12 @@
"<pool:Holder> | plan_golden_seats | 1"
],
"acts": [
"act | cause | actor_type | actor | note | request_id"
"act | cause | actor_type | actor | note | request_id",
"<act:1> | tiers_reordered | person | <person:Operator> | none | <uuid:3>"
],
"act_details": [
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried",
"<act:1> | tiers_reordered | <ladder:Main> | Main | none | none | none | none | none | none | none | none | none | none"
],
"reached_org_types": [
"act | org_type | org_type_name | last_org"
+41 -11
View File
@@ -13,27 +13,42 @@
"UpdateTierRank",
"UpdateTierRank",
"UpdateTierRank",
"RecordPlanAct",
"commit",
"begin",
"SET LOCAL lock_timeout = '5s'",
"SET LOCAL transaction_timeout = '2s'",
"SET LOCAL lock_timeout = '500ms'",
"SELECT pg_advisory_xact_lock_shared(hashtextextended($1, 0))",
"LockResourcePool",
"rollback",
"WritePlanActObligations",
"commit",
"begin",
"SET LOCAL lock_timeout = '5s'",
"SET LOCAL transaction_timeout = '2s'",
"SET LOCAL lock_timeout = '500ms'",
"SELECT pg_advisory_xact_lock_shared(hashtextextended($1, 0))",
"LockResourcePool",
"LockPlanActObligation",
"SettlePlanActObligation",
"commit",
"begin",
"SET LOCAL transaction_timeout = '2s'",
"SET LOCAL lock_timeout = '500ms'",
"SELECT pg_advisory_xact_lock_shared(hashtextextended($1, 0))",
"LockResourcePool",
"LockPlanActObligation",
"CreateGrant",
"SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
"SELECT core.assert_rendezvous(false)",
"DeleteContributionsByProvisionID",
"CreateNumericEntitlementContribution",
"UpdateNumericEntitlementLimit",
"SettlePlanActObligation",
"commit",
"begin",
"SET LOCAL lock_timeout = '5s'",
"SET LOCAL transaction_timeout = '2s'",
"SET LOCAL lock_timeout = '500ms'",
"SELECT pg_advisory_xact_lock_shared(hashtextextended($1, 0))",
"LockResourcePool",
"LockPlanActObligation",
"CreateGrant",
"SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
"SELECT core.assert_rendezvous(false)",
@@ -41,6 +56,15 @@
"CreateNumericEntitlementContribution",
"UpdateNumericEntitlementLimit",
"CreateNumericEntitlementUsage",
"SettlePlanActObligation",
"commit",
"HasPlanActSettledOutgoing",
"begin",
"SET LOCAL transaction_timeout = '2s'",
"SET LOCAL lock_timeout = '500ms'",
"SELECT pg_advisory_xact_lock_shared(hashtextextended($1, 0))",
"commit",
"begin isolation level repeatable read read only",
"commit"
],
"rows": {
@@ -57,8 +81,8 @@
"grant_id | org | product | grant_reason | granted_by | extends | description | status | act",
"<uuid:1> | <org:AtNew> | <product:Plus> | default | none | none | | active | none",
"<uuid:2> | <org:Holder> | <product:Basic> | default | none | none | | active | none",
"<uuid:3> | <org:Holder> | <product:Basic> | legacy | <person:Operator> | <uuid:2> | Grandfathered on org-type default change: plan-ladder tier reorder (Main) | active | none",
"<uuid:4> | <org:Planless> | <product:Plus> | default | none | none | | active | none"
"<uuid:3> | <org:Holder> | <product:Basic> | legacy | <person:Operator> | <uuid:2> | Grandfathered: plan-ladder tier reorder (Main) | active | <act:1>",
"<uuid:4> | <org:Planless> | <product:Plus> | default | none | none | | active | <act:1>"
],
"provisions": [
"provision | pool | grant_id | status",
@@ -89,19 +113,25 @@
"<pool:Planless> | plan_golden_seats | 2"
],
"acts": [
"act | cause | actor_type | actor | note | request_id"
"act | cause | actor_type | actor | note | request_id",
"<act:1> | tiers_reordered | person | <person:Operator> | none | <uuid:9>"
],
"act_details": [
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried",
"<act:1> | tiers_reordered | <ladder:Main> | Main | <product:Plus> | Plus | <product:Basic> | Basic | none | none | none | none | grandfather | none"
],
"reached_org_types": [
"act | org_type | org_type_name | last_org"
"act | org_type | org_type_name | last_org",
"<act:1> | <orgtype:T> | T | <org:Planless>"
],
"orgs_without_pool": [
"act | org_type | org"
],
"obligations": [
"act | org_type | org | pool | status | outcome | attempts | error_step | superseded_by | requeued_by"
"act | org_type | org | pool | status | outcome | attempts | error_step | superseded_by | requeued_by",
"<act:1> | <orgtype:T> | <org:AtNew> | <pool:AtNew> | settled | already_at | 0 | none | none | none",
"<act:1> | <orgtype:T> | <org:Holder> | <pool:Holder> | settled | grandfathered | 0 | none | none | none",
"<act:1> | <orgtype:T> | <org:Planless> | <pool:Planless> | settled | initiated | 0 | none | none | none"
]
}
}
@@ -1,12 +1,13 @@
{
"answer": [
"status 200",
"Hx-Trigger showErrorToast: Tier order saved, but affected organizations could not be listed for enactment. Re-drop the same order and commit again.",
"[role=\"alert\"]: Tier order saved, but affected organizations could not be listed for enactment. Re-drop the same order and commit again."
"Hx-Trigger showSuccessToast: Plus is now the default plan for new T organizations: 1 not yet reached.",
"[id^=\"plan-act-\"]: 1 organization not yet reached."
],
"checked": [],
"logs": [
"level=ERROR msg=\"failed to list orgs for reorder enactment\" error=\"read failed\" org_type=<orgtype:T>"
"level=ERROR msg=\"the plan act's listing batch failed\" request_id=<uuid:1> act_id=<act:1> error=\"list a batch of plan act <act:1>: read failed\"",
"level=WARN msg=\"no Temporal client configured; the plan act drain was not started\" request_id=<uuid:1> act_id=<act:1>"
],
"statements": [
"begin",
@@ -16,6 +17,14 @@
"UpdateTierRank",
"UpdateTierRank",
"UpdateTierRank",
"RecordPlanAct",
"commit",
"begin",
"SET LOCAL transaction_timeout = '2s'",
"SET LOCAL lock_timeout = '500ms'",
"SELECT pg_advisory_xact_lock_shared(hashtextextended($1, 0))",
"rollback",
"begin isolation level repeatable read read only",
"commit"
],
"rows": {
@@ -30,32 +39,35 @@
],
"grants": [
"grant_id | org | product | grant_reason | granted_by | extends | description | status | act",
"<uuid:1> | <org:Holder> | <product:Basic> | default | none | none | | active | none"
"<uuid:2> | <org:Holder> | <product:Basic> | default | none | none | | active | none"
],
"provisions": [
"provision | pool | grant_id | status",
"<uuid:2> | <pool:Holder> | <uuid:1> | active"
"<uuid:3> | <pool:Holder> | <uuid:2> | active"
],
"positions": [
"provision | ladder | product | status | ended",
"<uuid:2> | <ladder:Main> | <product:Basic> | active | false"
"<uuid:3> | <ladder:Main> | <product:Basic> | active | false"
],
"transitions": [
"pool | provision | ladder | from_rank | to_rank | transition_type | actor_type | actor | reason",
"<pool:Holder> | <uuid:2> | <ladder:Main> | - | 0 | initiate | system | none | test fixture"
"<pool:Holder> | <uuid:3> | <ladder:Main> | - | 0 | initiate | system | none | test fixture"
],
"entitlements": [
"pool | resource_key | resource_limit",
"<pool:Holder> | plan_golden_seats | 1"
],
"acts": [
"act | cause | actor_type | actor | note | request_id"
"act | cause | actor_type | actor | note | request_id",
"<act:1> | tiers_reordered | person | <person:Operator> | none | <uuid:1>"
],
"act_details": [
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried",
"<act:1> | tiers_reordered | <ladder:Main> | Main | <product:Plus> | Plus | <product:Basic> | Basic | none | none | none | none | grandfather | none"
],
"reached_org_types": [
"act | org_type | org_type_name | last_org"
"act | org_type | org_type_name | last_org",
"<act:1> | <orgtype:T> | T | <org:Holder>"
],
"orgs_without_pool": [
"act | org_type | org"
+28 -6
View File
@@ -13,11 +13,20 @@
"UpdateTierRank",
"UpdateTierRank",
"UpdateTierRank",
"RecordPlanAct",
"commit",
"begin",
"SET LOCAL lock_timeout = '5s'",
"SET LOCAL transaction_timeout = '2s'",
"SET LOCAL lock_timeout = '500ms'",
"SELECT pg_advisory_xact_lock_shared(hashtextextended($1, 0))",
"WritePlanActObligations",
"commit",
"begin",
"SET LOCAL transaction_timeout = '2s'",
"SET LOCAL lock_timeout = '500ms'",
"SELECT pg_advisory_xact_lock_shared(hashtextextended($1, 0))",
"LockResourcePool",
"LockPlanActObligation",
"SELECT core.end_conferral($1, $2, $3, $4, $5, $6, $7, $8)",
"CreateGrant",
"SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
@@ -29,6 +38,15 @@
"DeleteContributionsByProvisionID",
"CreateNumericEntitlementContribution",
"UpdateNumericEntitlementLimit",
"SettlePlanActObligation",
"commit",
"HasPlanActSettledOutgoing",
"begin",
"SET LOCAL transaction_timeout = '2s'",
"SET LOCAL lock_timeout = '500ms'",
"SELECT pg_advisory_xact_lock_shared(hashtextextended($1, 0))",
"commit",
"begin isolation level repeatable read read only",
"commit"
],
"rows": {
@@ -44,7 +62,7 @@
"grants": [
"grant_id | org | product | grant_reason | granted_by | extends | description | status | act",
"<uuid:1> | <org:Holder> | <product:Basic> | default | none | none | | active | none",
"<uuid:2> | <org:Holder> | <product:Plus> | default | none | none | | active | none"
"<uuid:2> | <org:Holder> | <product:Plus> | default | none | none | | active | <act:1>"
],
"provisions": [
"provision | pool | grant_id | status",
@@ -67,19 +85,23 @@
"<pool:Holder> | plan_golden_seats | 2"
],
"acts": [
"act | cause | actor_type | actor | note | request_id"
"act | cause | actor_type | actor | note | request_id",
"<act:1> | tiers_reordered | person | <person:Operator> | none | <uuid:5>"
],
"act_details": [
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried",
"<act:1> | tiers_reordered | <ladder:Main> | Main | <product:Plus> | Plus | <product:Basic> | Basic | none | none | none | none | migrate | none"
],
"reached_org_types": [
"act | org_type | org_type_name | last_org"
"act | org_type | org_type_name | last_org",
"<act:1> | <orgtype:T> | T | <org:Holder>"
],
"orgs_without_pool": [
"act | org_type | org"
],
"obligations": [
"act | org_type | org | pool | status | outcome | attempts | error_step | superseded_by | requeued_by"
"act | org_type | org | pool | status | outcome | attempts | error_step | superseded_by | requeued_by",
"<act:1> | <orgtype:T> | <org:Holder> | <pool:Holder> | settled | migrated | 0 | none | none | none"
]
}
}
@@ -5,7 +5,19 @@
],
"checked": [],
"logs": [],
"statements": [],
"statements": [
"begin",
"SET LOCAL lock_timeout = '5s'",
"SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
"UpdateTierRank",
"UpdateTierRank",
"UpdateTierRank",
"UpdateTierRank",
"RecordPlanAct",
"rollback",
"begin isolation level repeatable read read only",
"commit"
],
"rows": {
"tiers": [
"ladder | rank | product",
@@ -13,6 +13,9 @@
"UpdateTierRank",
"UpdateTierRank",
"UpdateTierRank",
"RecordPlanAct",
"commit",
"begin isolation level repeatable read read only",
"commit"
],
"rows": {
@@ -40,10 +43,12 @@
"pool | resource_key | resource_limit"
],
"acts": [
"act | cause | actor_type | actor | note | request_id"
"act | cause | actor_type | actor | note | request_id",
"<act:1> | tiers_reordered | person | <person:Operator> | none | <uuid:1>"
],
"act_details": [
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried",
"<act:1> | tiers_reordered | <ladder:Main> | Main | <product:Plus> | Plus | <product:Basic> | Basic | none | none | none | none | none | none"
],
"reached_org_types": [
"act | org_type | org_type_name | last_org"
@@ -1,12 +1,12 @@
{
"answer": [
"status 200",
"Hx-Trigger showSuccessToast: Plus is now this ladder's rank-0 tier. The ladder is not any org type's default, so no organizations were affected."
"Hx-Trigger showErrorToast: Failed to reorder tiers.",
"[role=\"alert\"]: Failed to reorder tiers."
],
"checked": [],
"logs": [
"level=ERROR msg=\"failed to list org types for default-ladder badge\" error=\"read failed\"",
"level=ERROR msg=\"failed to list org types for default-ladder badge\" error=\"read failed\""
"level=ERROR msg=\"failed to reorder tiers\" request_id=<uuid:1> step=\"record the act\" error=\"record the act: read failed\" plan_ladder_id=<ladder:Main>"
],
"statements": [
"begin",
@@ -16,13 +16,15 @@
"UpdateTierRank",
"UpdateTierRank",
"UpdateTierRank",
"rollback",
"begin isolation level repeatable read read only",
"commit"
],
"rows": {
"tiers": [
"ladder | rank | product",
"<ladder:Main> | 0 | <product:Plus>",
"<ladder:Main> | 1 | <product:Basic>"
"<ladder:Main> | 0 | <product:Basic>",
"<ladder:Main> | 1 | <product:Plus>"
],
"defaults": [
"org_type | default_ladder",
@@ -30,19 +32,19 @@
],
"grants": [
"grant_id | org | product | grant_reason | granted_by | extends | description | status | act",
"<uuid:1> | <org:Holder> | <product:Basic> | default | none | none | | active | none"
"<uuid:2> | <org:Holder> | <product:Basic> | default | none | none | | active | none"
],
"provisions": [
"provision | pool | grant_id | status",
"<uuid:2> | <pool:Holder> | <uuid:1> | active"
"<uuid:3> | <pool:Holder> | <uuid:2> | active"
],
"positions": [
"provision | ladder | product | status | ended",
"<uuid:2> | <ladder:Main> | <product:Basic> | active | false"
"<uuid:3> | <ladder:Main> | <product:Basic> | active | false"
],
"transitions": [
"pool | provision | ladder | from_rank | to_rank | transition_type | actor_type | actor | reason",
"<pool:Holder> | <uuid:2> | <ladder:Main> | - | 0 | initiate | system | none | test fixture"
"<pool:Holder> | <uuid:3> | <ladder:Main> | - | 0 | initiate | system | none | test fixture"
],
"entitlements": [
"pool | resource_key | resource_limit",
+7 -2
View File
@@ -13,6 +13,9 @@
"UpdateTierRank",
"UpdateTierRank",
"UpdateTierRank",
"RecordPlanAct",
"commit",
"begin isolation level repeatable read read only",
"commit"
],
"rows": {
@@ -46,10 +49,12 @@
"<pool:Holder> | plan_golden_seats | 2"
],
"acts": [
"act | cause | actor_type | actor | note | request_id"
"act | cause | actor_type | actor | note | request_id",
"<act:1> | tiers_reordered | person | <person:Operator> | none | <uuid:3>"
],
"act_details": [
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried",
"<act:1> | tiers_reordered | <ladder:Main> | Main | none | none | none | none | none | none | none | none | none | none"
],
"reached_org_types": [
"act | org_type | org_type_name | last_org"
+110
View File
@@ -0,0 +1,110 @@
{
"answer": [
"status 200"
],
"checked": [],
"logs": [],
"statements": [
"begin",
"SET LOCAL lock_timeout = '5s'",
"SELECT pg_advisory_xact_lock_shared(hashtextextended($1, 0))",
"RecordPlanAct",
"RequeuePlanActObligations",
"commit",
"begin",
"SET LOCAL transaction_timeout = '2s'",
"SET LOCAL lock_timeout = '500ms'",
"SELECT pg_advisory_xact_lock_shared(hashtextextended($1, 0))",
"LockResourcePool",
"LockPlanActObligation",
"SELECT core.end_conferral($1, $2, $3, $4, $5, $6, $7, $8)",
"CreateGrant",
"SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
"SELECT core.assert_rendezvous(false)",
"DeleteContributionsByProvisionID",
"CreateNumericEntitlementContribution",
"UpdateNumericEntitlementLimit",
"SELECT core.assert_rendezvous(false)",
"DeleteContributionsByProvisionID",
"CreateNumericEntitlementContribution",
"UpdateNumericEntitlementLimit",
"SettlePlanActObligation",
"commit",
"HasPlanActSettledOutgoing",
"begin",
"SET LOCAL transaction_timeout = '2s'",
"SET LOCAL lock_timeout = '500ms'",
"SELECT pg_advisory_xact_lock_shared(hashtextextended($1, 0))",
"commit",
"begin isolation level repeatable read read only",
"commit"
],
"rows": {
"tiers": [
"ladder | rank | product",
"<ladder:Main> | 0 | <product:Plus>",
"<ladder:Main> | 1 | <product:Basic>"
],
"defaults": [
"org_type | default_ladder",
"<orgtype:T> | <ladder:Main>"
],
"grants": [
"grant_id | org | product | grant_reason | granted_by | extends | description | status | act",
"<uuid:1> | <org:Stuck> | <product:Basic> | default | none | none | | active | none",
"<uuid:2> | <org:Moved> | <product:Basic> | default | none | none | | active | none",
"<uuid:3> | <org:Moved> | <product:Plus> | default | none | none | | active | <act:1>",
"<uuid:4> | <org:Stuck> | <product:Plus> | default | none | none | | active | <act:2>"
],
"provisions": [
"provision | pool | grant_id | status",
"<uuid:5> | <pool:Stuck> | <uuid:1> | ended",
"<uuid:6> | <pool:Moved> | <uuid:2> | ended",
"<uuid:7> | <pool:Moved> | <uuid:3> | active",
"<uuid:8> | <pool:Stuck> | <uuid:4> | active"
],
"positions": [
"provision | ladder | product | status | ended",
"<uuid:5> | <ladder:Main> | <product:Basic> | ended | true",
"<uuid:6> | <ladder:Main> | <product:Basic> | ended | true",
"<uuid:7> | <ladder:Main> | <product:Plus> | active | false",
"<uuid:8> | <ladder:Main> | <product:Plus> | active | false"
],
"transitions": [
"pool | provision | ladder | from_rank | to_rank | transition_type | actor_type | actor | reason",
"<pool:Stuck> | <uuid:5> | <ladder:Main> | - | 0 | initiate | system | none | test fixture",
"<pool:Moved> | <uuid:6> | <ladder:Main> | - | 0 | initiate | system | none | test fixture",
"<pool:Moved> | <uuid:6> | <ladder:Main> | 1 | - | end | operator | <person:Operator> | plan-ladder tier reorder (Main): migrated off the outgoing default",
"<pool:Moved> | <uuid:7> | <ladder:Main> | - | 0 | initiate | operator | <person:Operator> | plan-ladder tier reorder (Main)",
"<pool:Stuck> | <uuid:5> | <ladder:Main> | 1 | - | end | operator | <person:Operator> | plan-ladder tier reorder (Main): migrated off the outgoing default",
"<pool:Stuck> | <uuid:8> | <ladder:Main> | - | 0 | initiate | operator | <person:Operator> | plan-ladder tier reorder (Main)"
],
"entitlements": [
"pool | resource_key | resource_limit",
"<pool:Stuck> | plan_golden_seats | 2",
"<pool:Moved> | plan_golden_seats | 2"
],
"acts": [
"act | cause | actor_type | actor | note | request_id",
"<act:1> | tiers_reordered | person | <person:Operator> | none | none",
"<act:2> | plan_act_retried | person | <person:Operator> | none | <uuid:9>"
],
"act_details": [
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried",
"<act:1> | tiers_reordered | <ladder:Main> | Main | <product:Plus> | Plus | <product:Basic> | Basic | none | none | none | none | migrate | none",
"<act:2> | plan_act_retried | none | none | none | none | none | none | none | none | none | none | none | <act:1>"
],
"reached_org_types": [
"act | org_type | org_type_name | last_org",
"<act:1> | <orgtype:T> | T | <org:Moved>"
],
"orgs_without_pool": [
"act | org_type | org"
],
"obligations": [
"act | org_type | org | pool | status | outcome | attempts | error_step | superseded_by | requeued_by",
"<act:1> | <orgtype:T> | <org:Stuck> | <pool:Stuck> | settled | migrated | 0 | none | none | <act:2>",
"<act:1> | <orgtype:T> | <org:Moved> | <pool:Moved> | settled | migrated | 0 | none | none | none"
]
}
}
+7 -2
View File
@@ -15,6 +15,9 @@
"UpdateTierRank",
"UpdateTierRank",
"UpdateTierRank",
"RecordPlanAct",
"commit",
"begin isolation level repeatable read read only",
"commit"
],
"rows": {
@@ -43,10 +46,12 @@
"pool | resource_key | resource_limit"
],
"acts": [
"act | cause | actor_type | actor | note | request_id"
"act | cause | actor_type | actor | note | request_id",
"<act:1> | tiers_reordered | person | <person:Operator> | none | <uuid:1>"
],
"act_details": [
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried",
"<act:1> | tiers_reordered | <ladder:Main> | Main | none | none | none | none | none | none | none | none | none | none"
],
"reached_org_types": [
"act | org_type | org_type_name | last_org"
+35 -8
View File
@@ -13,28 +13,49 @@
"UpdateTierRank",
"UpdateTierRank",
"UpdateTierRank",
"RecordPlanAct",
"commit",
"begin",
"SET LOCAL lock_timeout = '5s'",
"SET LOCAL transaction_timeout = '2s'",
"SET LOCAL lock_timeout = '500ms'",
"SELECT pg_advisory_xact_lock_shared(hashtextextended($1, 0))",
"WritePlanActObligations",
"commit",
"begin",
"SET LOCAL transaction_timeout = '2s'",
"SET LOCAL lock_timeout = '500ms'",
"SELECT pg_advisory_xact_lock_shared(hashtextextended($1, 0))",
"LockResourcePool",
"LockPlanActObligation",
"CreateGrant",
"SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
"SELECT core.assert_rendezvous(false)",
"DeleteContributionsByProvisionID",
"CreateNumericEntitlementContribution",
"UpdateNumericEntitlementLimit",
"SettlePlanActObligation",
"commit",
"begin",
"SET LOCAL lock_timeout = '5s'",
"SET LOCAL transaction_timeout = '2s'",
"SET LOCAL lock_timeout = '500ms'",
"SELECT pg_advisory_xact_lock_shared(hashtextextended($1, 0))",
"LockResourcePool",
"LockPlanActObligation",
"CreateGrant",
"SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
"SELECT core.assert_rendezvous(false)",
"DeleteContributionsByProvisionID",
"CreateNumericEntitlementContribution",
"UpdateNumericEntitlementLimit",
"SettlePlanActObligation",
"commit",
"HasPlanActSettledOutgoing",
"begin",
"SET LOCAL transaction_timeout = '2s'",
"SET LOCAL lock_timeout = '500ms'",
"SELECT pg_advisory_xact_lock_shared(hashtextextended($1, 0))",
"commit",
"begin isolation level repeatable read read only",
"commit"
],
"rows": {
@@ -52,8 +73,8 @@
"grant_id | org | product | grant_reason | granted_by | extends | description | status | act",
"<uuid:1> | <org:First> | <product:Basic> | default | none | none | | active | none",
"<uuid:2> | <org:Second> | <product:Basic> | default | none | none | | active | none",
"<uuid:3> | <org:First> | <product:Basic> | legacy | <person:Operator> | <uuid:1> | Grandfathered on org-type default change: plan-ladder tier reorder (Main) | active | none",
"<uuid:4> | <org:Second> | <product:Basic> | legacy | <person:Operator> | <uuid:2> | Grandfathered on org-type default change: plan-ladder tier reorder (Main) | active | none"
"<uuid:3> | <org:First> | <product:Basic> | legacy | <person:Operator> | <uuid:1> | Grandfathered: plan-ladder tier reorder (Main) | active | <act:1>",
"<uuid:4> | <org:Second> | <product:Basic> | legacy | <person:Operator> | <uuid:2> | Grandfathered: plan-ladder tier reorder (Main) | active | <act:1>"
],
"provisions": [
"provision | pool | grant_id | status",
@@ -84,19 +105,25 @@
"<pool:Second> | plan_golden_seats | 1"
],
"acts": [
"act | cause | actor_type | actor | note | request_id"
"act | cause | actor_type | actor | note | request_id",
"<act:1> | tiers_reordered | person | <person:Operator> | none | <uuid:9>"
],
"act_details": [
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried",
"<act:1> | tiers_reordered | <ladder:Main> | Main | <product:Plus> | Plus | <product:Basic> | Basic | none | none | none | none | grandfather | none"
],
"reached_org_types": [
"act | org_type | org_type_name | last_org"
"act | org_type | org_type_name | last_org",
"<act:1> | <orgtype:T> | T | <org:First>",
"<act:1> | <orgtype:U> | U | <org:Second>"
],
"orgs_without_pool": [
"act | org_type | org"
],
"obligations": [
"act | org_type | org | pool | status | outcome | attempts | error_step | superseded_by | requeued_by"
"act | org_type | org | pool | status | outcome | attempts | error_step | superseded_by | requeued_by",
"<act:1> | <orgtype:T> | <org:First> | <pool:First> | settled | grandfathered | 0 | none | none | none",
"<act:1> | <orgtype:U> | <org:Second> | <pool:Second> | settled | grandfathered | 0 | none | none | none"
]
}
}
+13 -14
View File
@@ -2,7 +2,7 @@
"first": {
"answer": [
"status 200",
"Hx-Trigger showSuccessToast: Plus is now the default plan for new T organizations: 1 grandfathered."
"Hx-Trigger showSuccessToast: Plus is now the default plan for new T organizations: 1 already at the default."
],
"checked": [],
"logs": []
@@ -19,43 +19,42 @@
],
"grants": [
"grant_id | org | product | grant_reason | granted_by | extends | description | status | act",
"<uuid:1> | <org:Holder> | <product:Basic> | default | none | none | | active | none",
"<uuid:2> | <org:Holder> | <product:Basic> | legacy | <person:Operator> | <uuid:1> | Grandfathered on org-type default change: plan-ladder tier reorder (Main) | active | none"
"<uuid:1> | <org:Holder> | <product:Basic> | default | none | none | | active | none"
],
"provisions": [
"provision | pool | grant_id | status",
"<uuid:3> | <pool:Holder> | <uuid:1> | ended",
"<uuid:4> | <pool:Holder> | <uuid:2> | active"
"<uuid:2> | <pool:Holder> | <uuid:1> | active"
],
"positions": [
"provision | ladder | product | status | ended",
"<uuid:3> | <ladder:Main> | <product:Basic> | ended | true",
"<uuid:4> | <ladder:Main> | <product:Basic> | active | false"
"<uuid:2> | <ladder:Main> | <product:Basic> | active | false"
],
"transitions": [
"pool | provision | ladder | from_rank | to_rank | transition_type | actor_type | actor | reason",
"<pool:Holder> | <uuid:3> | <ladder:Main> | - | 0 | initiate | system | none | test fixture",
"<pool:Holder> | <uuid:3> | <ladder:Main> | 0 | - | end | operator | <person:Operator> | plan-ladder tier reorder (Main): grandfathered as legacy",
"<pool:Holder> | <uuid:4> | <ladder:Main> | 0 | 0 | transfer | operator | <person:Operator> | plan-ladder tier reorder (Main): grandfathered as legacy"
"<pool:Holder> | <uuid:2> | <ladder:Main> | - | 0 | initiate | system | none | test fixture"
],
"entitlements": [
"pool | resource_key | resource_limit",
"<pool:Holder> | plan_golden_seats | 1"
],
"acts": [
"act | cause | actor_type | actor | note | request_id"
"act | cause | actor_type | actor | note | request_id",
"<act:1> | tiers_reordered | person | <person:Operator> | none | <uuid:3>"
],
"act_details": [
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried"
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried",
"<act:1> | tiers_reordered | <ladder:Main> | Main | <product:Plus> | Plus | <product:Basic> | Basic | none | none | none | none | grandfather | none"
],
"reached_org_types": [
"act | org_type | org_type_name | last_org"
"act | org_type | org_type_name | last_org",
"<act:1> | <orgtype:T> | T | <org:Holder>"
],
"orgs_without_pool": [
"act | org_type | org"
],
"obligations": [
"act | org_type | org | pool | status | outcome | attempts | error_step | superseded_by | requeued_by"
"act | org_type | org | pool | status | outcome | attempts | error_step | superseded_by | requeued_by",
"<act:1> | <orgtype:T> | <org:Holder> | <pool:Holder> | settled | already_at | 0 | none | none | none"
]
}
}
@@ -53,11 +53,13 @@
],
"acts": [
"act | cause | actor_type | actor | note | request_id",
"<act:1> | org_type_default_changed | person | <person:Operator> | none | <uuid:5>"
"<act:1> | org_type_default_changed | person | <person:Operator> | none | <uuid:5>",
"<act:2> | tiers_reordered | person | <person:Operator> | none | <uuid:6>"
],
"act_details": [
"act | cause | ladder | ladder_name | product | product_name | previous_product | previous_product_name | org_type | org_type_name | previous_ladder | previous_ladder_name | disposition | retried",
"<act:1> | org_type_default_changed | <ladder:Next> | Next | <product:Gold> | Gold | none | none | <orgtype:T> | T | <ladder:Main> | Main | grandfather | none"
"<act:1> | org_type_default_changed | <ladder:Next> | Next | <product:Gold> | Gold | none | none | <orgtype:T> | T | <ladder:Main> | Main | grandfather | none",
"<act:2> | tiers_reordered | <ladder:Main> | Main | <product:Plus> | Plus | <product:Basic> | Basic | none | none | none | none | grandfather | none"
],
"reached_org_types": [
"act | org_type | org_type_name | last_org",
+4
View File
@@ -262,6 +262,10 @@ POST /partials/operator/plan-ladders/reorder probe=/partials/operator/plan-ladd
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
POST /partials/operator/plan-ladders/{ladderID}/plan-acts/{actID}/retry probe=/partials/operator/plan-ladders/00000000-0000-0000-0000-000000000000/plan-acts/00000000-0000-0000-0000-000000000000/retry pattern=POST /partials/operator/plan-ladders/{ladderID}/plan-acts/{actID}/retry
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
member: 403 type=text/plain text=Forbidden h=nosniff,rid
POST /partials/operator/plan-ladders/{ladderID}/tiers probe=/partials/operator/plan-ladders/00000000-0000-0000-0000-000000000000/tiers pattern=POST /partials/operator/plan-ladders/{ladderID}/tiers
anon: 302 loc=/login h=nosniff,rid
xsite: 403 type=text/plain text=CSRF check failed: this request did not come from this site. h=nosniff,rid
@@ -811,7 +811,7 @@ Every sentence a person reads that this change adds, changes, reuses or removes.
| 44 | withdrawn | was the fallback for a colliding position that could not be read after the rollback | withdrawn: the collision is read inside the add's transaction, so its names are always read with it |
| 45 | "Failed to read the org types that default to this ladder. Reference: {request id}." | the tier card in place of the default-ladder badges, and the reorder preview in place of its consequence panel, when `ListOrgTypes` fails; the logged line carries the request id | |
| 48 | "Cannot add this tier; {Organization} holds {Product} more than once, and an organization holds one position per ladder." | the tier-add form's product field error when a pool holds more than one live provision of the product being added, so the add would have to place both on the ladder | new |
| 62 | "Default change committed for {type}." | the default change's success toast when none of its counts could be read: the opening of the toast of copies 19 and 20 with its parts left out | added with task 6; shown to him for approval |
| 62 | "Default change committed for {type}." and "{Product} is now the default plan for new {types} organizations." | the default change's and the reorder's success toasts when none of their counts could be read and nothing settled: the opening of the toast of copies 19 and 20 with its parts left out | added with tasks 6 and 7; approved |
### Reused in a new place
@@ -10,7 +10,7 @@ Each numbered section below is one commit, and its items are that commit's parts
- one listing batch of 500 organizations at the start, the middle and the end of the type: the shared advisory lock, the batch's pools `FOR KEY SHARE` in ascending order, the supersession of 500 open rows of an earlier act, and the 500 inserts;
- the preview's read (design D4: each class's exact count and at most 50 names per class, in one statement), with a tenth of the positions at a product other than the target, so outgoing, and another tenth not default-sourced, so held by another source, under the server's default `work_mem`.
Part B times today's code at 50 to 3,200 holders: one organization's move under each disposition (`EnactDefault` with grandfather, migrate and initiate), a tier removal with migrate per holder (`RemoveTier`), and a tier add per holder (`AddTier`).
Part B times today's code at 50 to 3,200 holders: one organization's move under each disposition (`EnactDefault` with grandfather, migrate and initiate; since task 7 deleted it, `EnactObligation`, timed once rather than at each holder count), a tier removal with migrate per holder (`RemoveTier`), and a tier add per holder (`AddTier`).
- [ ] 1.2 Write the stop rule in the test's comment and log its verdict:
- T0's median at 256,000 organizations is within twice its median at 1,000 and under 20 ms;
- at 256,000 organizations, the gate's first probe after as many endings as autovacuum's default threshold is under 50 ms, one hundredth of the 5 s lock timeout, and so is each of the three probes while the older snapshot is held: each probe repeats the walk for as long as that snapshot stays open, which for the read-only role is at most its 15 s statement timeout in a read-committed statement or its one-minute idle limit in an idle transaction (`scripts/db-reader-role.sql:177-178`, design D4);
@@ -76,12 +76,12 @@ Each numbered section below is one commit, and its items are that commit's parts
## 7. The reorder on obligations and the ladder page's lines
- [ ] 7.1 Rewrite `ReorderTiers`: T0 on the runner under the exclusive kind (read the tiers and reconcile the submitted order inside, renumber, record the act, whose function reads the org types defaulting to the ladder when rank 0 changed, and run the gate when it reaches a type and no disposition was given), then the listing, the moves and `DrainPlanAct` as for the default change. `ReorderPlanLadderTiers` only parses, calls and renders: it runs `buildPendingTierReorder` on the commit path only to render a 422; a lock wait or deadlock answers the new refusal (#216); `tierReorderAnswer` loses its failure suffix and builds its summary from the act's result, as the default change's toast does. Now that nothing calls them, these go, and `scripts/deadcode.sh` and `unused` pass after the deletion: in `internal/entitlements`, `EnactDefault`, `enactDefault`, `enactReorder`, `Enactment`, `EnactResult`, `EnactFailure`, `ReorderEnactment`, `OrgLister`, `DefaultPools`, `LadderNames`, `StepUndecided`, `StepFindPool`, `StepListOrganizations`, and every helper of the old enactment that `EnactObligation` does not call (6.5); in `internal/server`, `orgTypeChangeResult`, `enactFailureReason`, `dispositionFailure` (whose only caller is `enactFailureReason`), `logEnactFailures`, the listing branch of `tierReorderFailure`, `OrgTypeChangeResult.Failures` and the "failed" part of `OrgTypeChangeResult.Summary` (copy 29). `OrgTypeChangeFailure` stays: the preview's would-fail list uses it.
- [ ] 7.2 The ladder page: add `ListOpenPlanActsByLadder`, include the place partial in `operator_plan_ladder_tiers.html` below the tiers table, one act's lines per reorder that owns a type, newest first, read in two statements in one read-only `REPEATABLE READ` transaction; add `POST /partials/operator/plan-ladders/{ladderID}/plan-acts/{actID}/retry` as 6.9 describes for the card, declare it in `action_triggers.go`, and claim it with a line in the `// covers:` block of `test/e2e/operator-walkthroughs/plan_ladders_test.go`, whose header prose names the handler test of 7.3 as its runtime coverage.
- [ ] 7.3 Tests: `TestReorderEnactsAgainstTheRankZeroItSaved` becomes `TestReorderEnactsAgainstTheCurrentRankZero` (the organization already on the current rank 0 is settled as already at the default and no legacy grant is written); `TestTierReorder_GrandfatherCommitAndRerun` (a failed organization moved by Retry, the ranks not written again); `TestTierReorder_CommitRacesDefaultChangeCommit` re-read against the new shape and its comment corrected; a reorder reaching two types followed by a default change of one of them (the reorder keeps the other type; the default change owns its type); the reorder's 422 reads through task 3's capped read; the reorder preview's "Failed to classify affected organizations." (`internal/server/operator_plan_ladders.go:578`) takes the reference form (copy 57), logged through the request's logger, with a test that the shown id is the log line's `request_id`; render tests for the ladder page's lines; `TestOperatorPagesReadAFixedNumberOfTimes` covers the ladder page with open acts.
- [ ] 7.4 Ride-along: `orgTypesDefaultingToLadder` returns its error, and the tier card shows copy 45 in place of the default-ladder badges and the reorder preview shows it in place of its consequence panel, instead of treating a failed `ListOrgTypes` as "no org type defaults to this ladder". Its other caller, the removal preview's promoted-default note (`internal/server/operator_plan_ladders.go:907`), keeps its current behaviour in this task, logging the error and leaving the note without types, until task 8.3 rewrites the removal preview's reads; the plain delete stopped calling it in task 4. Tests: with `ListOrgTypes` failing, the tier card and the reorder preview show copy 45 with the request id and the preview offers no commit.
- [ ] 7.5 Goldens: `reorder-an-organization-fails.json` (a pending obligation with one attempt, the toast's "not yet reached"), `reorder-begin-fails.json` (the log line), `reorder-cosmetic.json` and `reorder-rank-0-no-default.json` (an act that reaches no type), `reorder-grandfather.json` and `reorder-two-types.json` (the reach, the obligations, the description "Grandfathered: plan-ladder tier reorder (...)"), `reorder-migrate.json`, `reorder-needs-disposition.json` (the gate inside T0), `reorder-redrop.json`, `reorder-stale.json`, `stale-candidate.json` (no legacy grant; already at the default), `tier-change-during-conferral/a-reorder.json` (the reorder's own act, on top of task 6's change) and `contention.json` (the reorder answers the new refusal). Retired: `reorder-list-fails.json`, replaced by `reorder-listing-fails.json` (as `default-listing-fails.json`), and `reorder-org-types-unread.json`, whose read moved into `core.record_plan_act`, replaced by `reorder-record-fails.json` (the act's querier refuses `RecordPlanAct`; the ranks are unchanged). New: `reorder-retry.json`. The commit names which were retired and why.
- [ ] 7.6 Docs: the reorder's T0, its listing and moves in the "Tier add, tier removal, default change and reorder" section of `docs/database-locks.md`, and the reorder's commit and Retry in `docs/plan-management.md`.
- [x] 7.1 Rewrite `ReorderTiers`: T0 on the runner under the exclusive kind (read the tiers and reconcile the submitted order inside, renumber, record the act, whose function reads the org types defaulting to the ladder when rank 0 changed, and run the gate when it reaches a type and no disposition was given), then the listing, the moves and `DrainPlanAct` as for the default change. `ReorderPlanLadderTiers` only parses, calls and renders: it runs `buildPendingTierReorder` on the commit path only to render a 422; a lock wait or deadlock answers the new refusal (#216); `tierReorderAnswer` loses its failure suffix and builds its summary from the act's result, as the default change's toast does. Now that nothing calls them, these go, and `scripts/deadcode.sh` and `unused` pass after the deletion: in `internal/entitlements`, `EnactDefault`, `enactDefault`, `enactReorder`, `Enactment`, `EnactResult`, `EnactFailure`, `ReorderEnactment`, `OrgLister`, `DefaultPools`, `LadderNames`, `StepUndecided`, `StepFindPool`, `StepListOrganizations`, and every helper of the old enactment that `EnactObligation` does not call (6.5); in `internal/server`, `orgTypeChangeResult`, `enactFailureReason`, `dispositionFailure` (whose only caller is `enactFailureReason`), `logEnactFailures`, the listing branch of `tierReorderFailure`, `OrgTypeChangeResult.Failures` and the "failed" part of `OrgTypeChangeResult.Summary` (copy 29). `OrgTypeChangeFailure` stays: the preview's would-fail list uses it.
- [x] 7.2 The ladder page: add `ListOpenPlanActsByLadder`, include the place partial in `operator_plan_ladder_tiers.html` below the tiers table, one act's lines per reorder that owns a type, newest first, read in two statements in one read-only `REPEATABLE READ` transaction; add `POST /partials/operator/plan-ladders/{ladderID}/plan-acts/{actID}/retry` as 6.9 describes for the card, declare it in `action_triggers.go`, and claim it with a line in the `// covers:` block of `test/e2e/operator-walkthroughs/plan_ladders_test.go`, whose header prose names the handler test of 7.3 as its runtime coverage.
- [x] 7.3 Tests: `TestReorderEnactsAgainstTheRankZeroItSaved` becomes `TestReorderEnactsAgainstTheCurrentRankZero` (the organization already on the current rank 0 is settled as already at the default and no legacy grant is written); `TestTierReorder_GrandfatherCommitAndRerun` (a failed organization moved by Retry, the ranks not written again); `TestTierReorder_CommitRacesDefaultChangeCommit` re-read against the new shape and its comment corrected; a reorder reaching two types followed by a default change of one of them (the reorder keeps the other type; the default change owns its type); the reorder's 422 reads through task 3's capped read; the reorder preview's "Failed to classify affected organizations." (`internal/server/operator_plan_ladders.go:578`) takes the reference form (copy 57), logged through the request's logger, with a test that the shown id is the log line's `request_id`; render tests for the ladder page's lines; `TestOperatorPagesReadAFixedNumberOfTimes` covers the ladder page with open acts.
- [x] 7.4 Ride-along: `orgTypesDefaultingToLadder` returns its error, and the tier card shows copy 45 in place of the default-ladder badges and the reorder preview shows it in place of its consequence panel, instead of treating a failed `ListOrgTypes` as "no org type defaults to this ladder". Its other caller, the removal preview's promoted-default note (`internal/server/operator_plan_ladders.go:907`), keeps its current behaviour in this task, logging the error and leaving the note without types, until task 8.3 rewrites the removal preview's reads; the plain delete stopped calling it in task 4. Tests: with `ListOrgTypes` failing, the tier card and the reorder preview show copy 45 with the request id and the preview offers no commit.
- [x] 7.5 Goldens: `reorder-an-organization-fails.json` (a pending obligation with one attempt, the toast's "not yet reached"), `reorder-begin-fails.json` (the log line), `reorder-cosmetic.json` and `reorder-rank-0-no-default.json` (an act that reaches no type), `reorder-grandfather.json` and `reorder-two-types.json` (the reach, the obligations, the description "Grandfathered: plan-ladder tier reorder (...)"), `reorder-migrate.json`, `reorder-needs-disposition.json` (the gate inside T0), `reorder-redrop.json`, `reorder-stale.json`, `stale-candidate.json` (no legacy grant; already at the default), `tier-change-during-conferral/a-reorder.json` (the reorder's own act, on top of task 6's change) and `contention.json` (the reorder answers the new refusal). Retired: `reorder-list-fails.json`, replaced by `reorder-listing-fails.json` (as `default-listing-fails.json`), and `reorder-org-types-unread.json`, whose read moved into `core.record_plan_act`, replaced by `reorder-record-fails.json` (the act's querier refuses `RecordPlanAct`; the ranks are unchanged). New: `reorder-retry.json`. The commit names which were retired and why.
- [x] 7.6 Docs: the reorder's T0, its listing and moves in the "Tier add, tier removal, default change and reorder" section of `docs/database-locks.md`, and the reorder's commit and Retry in `docs/plan-management.md`.
## 8. The tier removal on the runner
@@ -15,6 +15,8 @@
// DELETE /partials/operator/plan-ladders/{ladderID}/tiers/{productID}
// POST /partials/operator/plan-ladders/reorder
//
// POST /partials/operator/plan-ladders/{ladderID}/plan-acts/{actID}/retry
//
// Exercises bullets 1 (422 + field errors) and 2 (create-then-land, design
// D20) for ladder creation, then the two-step tier reorder on the created
// ladder: a drop renders a read-only preview with Commit/Discard, committing
@@ -28,6 +30,14 @@
// (operator_tier_removal_test.go), since a live holder fixture is
// disproportionate for a walkthrough.
//
// The plan-acts/{actID}/retry route is claimed here on the same terms as the
// org type card's Retry: the browser has no way to fail an organization's move
// five times, so its runtime coverage is TestTierReorder_GrandfatherCommitAndRerun
// in internal/server/operator_tier_reorder_test.go, which fails one
// organization's move until its obligation is failed, presses Retry on the
// ladder page and asserts that the organization is moved and the ladder's ranks
// are not written again.
//
// ux-ia-naming 10.2: the cross-ladder topology overview (grid, off-ladder/
// shared/org-type sections, and the column drag-to-reorder scaffolding that
// POSTs to /partials/operator/plan-ladders/reorder) renders map-first at the