Remove a tier on the runner, under the holder limit and last-tier guard
A tier removal with holders ended, aligned and materialized every holder's pool inside one transaction that held the exclusive rendezvous, so its hold grew with the holders and had no limit (#187). Under Keep it removed the last tier of a ladder an org type defaults to, leaving that type's default plan empty; under Migrate the same removal failed at a pool step. Its four step failures answered "Details are in the server logs." RemoveTier now runs on runPlanAct. Inside the exclusive transaction it: - counts the live holders, reads the ladder's tiers and the org types defaulting to it, and refuses through TierRemovalRefusal before any pool row. That function refuses above planActHolderCap, then refuses the last tier of a default ladder under either disposition. A failed read refuses too. - reads the product's and the ladder's names, refusing when they cannot be read; - locks the holders' pools in ascending id and reads them again, then applies the disposition gate; - records the act (tier_removed, with the disposition when default-sourced holders remain), deletes and renumbers, and reconciles each pool. The floor grants Migrate creates carry the act's id. The limit refusal offers "changing that default, or reordering this ladder, with Migrate" only when that advice is true: when the tier is rank 0 of a ladder with another tier and every default-sourced holder's type defaults to it. Otherwise it offers the default change alone. The removal preview reads the same counts and calls the same function with the same limit. A refusal renders "Apply change" disabled with the sentence as its reason. A failed read of the org types or the tiers answers "Failed to preview the removal. Reference: {request id}." in place of the preview, and a failed holder list answers "Failed to load the tier's holders. Reference: {request id}.". The promoted-default note shares the preview's one org-types read. CommitPlanLadderTierRemoval only parses, calls and renders. It answers as follows: - Every failing step is logged through the request's logger with the step, the ladder, the product and the pool or provision. - The four step sentences end "Reference: {request id}.". - A lock wait or deadlock answers "Another change collided with this one; try again.". - A removal with no holder left to reconcile answers "{Product} removed from the ladder.". - A 422 whose preview cannot be rebuilt answers the preview's own read failure. - A gate that was true while the rebuilt preview finds no default-sourced holder answers the generic failure, and logs an Error line with the request id. The design's removal table gains those two rows, and copies 14, 16 and 52 name their new places and condition. docs/database-locks.md lists the six paths that render the collision sentence. docs/plan-management.md gives the removal's limit, its last-tier refusal and its repeated-removal no-op. Goldens: - Behaviour changes in remove-begin-fails, remove-second-pool-fails, remove-keep-rank-0, remove-migrate-pool-held-elsewhere, remove-other-source, remove-migrate-rank-0, remove-two-pools, remove-no-holders-left, remove-last-tier-keep (refused, where it used to empty the default ladder), remove-last-tier-migrate (refused before any pool step), remove-product-name-unread, contention, the four removal-against-default files and tier-change-during-conferral/a-tier-removal. - New: remove-above-the-limit and remove-org-types-unread. - remove-needs-disposition and remove-refused-disposition do not move: the gate writes nothing. Task 8.5's text is corrected to say so. The full suite passes.
This commit is contained in:
+20
-11
@@ -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 `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. |
|
||||
| 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`, `RemoveTier` and `EnactObligation`, a default change's move), 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. |
|
||||
@@ -82,8 +82,9 @@ kind takes the exclusive one. Those two kinds are what set
|
||||
`SET LOCAL lock_timeout = '5s'`, which bounds every later lock wait in the
|
||||
transaction. A plain transaction sets no timeout, so its lock waits last until
|
||||
its context ends. A wait past the bound surfaces as SQLSTATE 55P03. The rule
|
||||
change, tier removal and default change render it as the refusal for a change
|
||||
in progress; the other paths show their own failure copy. Materializing
|
||||
change, tier add, tier delete, tier removal, tier reorder and default change
|
||||
render it as the refusal for a change in progress; the other paths show their
|
||||
own failure copy. Materializing
|
||||
transactions run at READ COMMITTED.
|
||||
|
||||
### Rule change and drain
|
||||
@@ -190,14 +191,22 @@ transaction timeout before the rendezvous statement.
|
||||
any alignment. Then, for each pool in the same order,
|
||||
`core.align_conferral_shape` for each of its provisions in creation order and
|
||||
one materialization. It runs through `runPlanAct`.
|
||||
- **Tier removal with holders** (`entitlements.RemoveTier`): the
|
||||
exclusive rendezvous, then the pool row of every holder through
|
||||
`LockPools`, which sorts and deduplicates them, so they lock in ascending
|
||||
`pool_id` order, then the holders are read again under the locks, the tier
|
||||
is deleted and the ranks renumbered. Then, for each pool in the same order,
|
||||
the incumbents end (`core.end_conferral`), the remaining positions align
|
||||
(`core.align_conferral_shape`), the floor is restored when the disposition is
|
||||
migrate, and the pool materializes.
|
||||
- **Tier removal** (`entitlements.RemoveTier`): the exclusive rendezvous, then
|
||||
the reads that decide the removal, all inside the transaction and before any
|
||||
pool row: the count of the organizations holding a live position on the tier
|
||||
(`CountLiveTierHolders`), the ladder's tiers and the org types
|
||||
(`ListOrgTypes`), from which `TierRemovalRefusal` decides (above
|
||||
`planActHolderCap` the removal is refused; the only tier of a ladder an org
|
||||
type defaults to is refused under either disposition), the names of the
|
||||
ladder and the product, and the tier's holders. A failed read refuses. Then
|
||||
the pool row of every holder through `LockPools`, which sorts and
|
||||
deduplicates them, so they lock in ascending `pool_id` order, and the holders
|
||||
are read again under the locks. Then the act row and its detail row
|
||||
(`core.record_plan_act`), the tier delete and the renumbering. Then, for each
|
||||
pool in the same order, the incumbents end (`core.end_conferral`), the
|
||||
remaining positions align (`core.align_conferral_shape`), the floor is
|
||||
restored when the disposition is migrate, with the act's id on the grants it
|
||||
creates, and the pool materializes. It runs through `runPlanAct`.
|
||||
- **Tier delete without holders** (`entitlements.DeleteTier`): the exclusive
|
||||
rendezvous, then the reads that decide the delete, all inside the
|
||||
transaction: the count of the tier's live holders (positions that are
|
||||
|
||||
@@ -103,6 +103,14 @@ Removing a tier with **no live holders** is a plain confirm-modal delete (the mo
|
||||
|
||||
The commit is **atomic**: tier deletion, renumbering, and every holder reconciliation run in one transaction — a failure rolls back everything, tier included, so no provision is ever left mismatched with its product's shape.
|
||||
|
||||
The removal decides inside that transaction, before it takes any pool lock, and the preview reads the same counts and renders "Apply change" disabled with the same sentence as its reason:
|
||||
|
||||
- **The holder limit.** It counts the organizations holding a live position on the tier and refuses above 200, the same limit as the tier add (see "Attaching Tiers"); the removal's per-holder cost sets it. The refusal names the count and the limit. When more than half of those organizations hold the tier through their org type's default, it adds that changing that default with Migrate moves them in the background, and, for the rank-0 tier of a ladder that has another tier, when every one of the organizations holding it through an org type's default holds it through a type that defaults to this ladder, that reordering this ladder with Migrate does too.
|
||||
- **The last tier of a default ladder.** The removal reads the ladder's tiers and the org types that default to it, and refuses to remove the only tier of a ladder some org type defaults to under Keep as under Migrate, with the sentence the plain delete gives.
|
||||
- **The record.** The removal writes one act (cause `tier_removed`, with the disposition when one was asked for) naming the ladder and the product as they were named at that moment, and the grants Migrate's floor restoration creates carry that act. A name that cannot be read refuses the removal. A lock wait or a deadlock at any step answers "Another change collided with this one; try again."; a failure while reconciling a holder answers "Removal failed and nothing was changed: could not …" for the step, ending "Reference: {request id}.", and every failure's log line carries the same request id and the step that failed. Any other failure (the holder count, the tiers, the org types or the names cannot be read, the act cannot be recorded, the tier cannot be deleted or renumbered, the commit fails) answers "Failed to remove the tier; nothing was changed." and logs the step. When no holder is left by the time the commit runs, it answers "{Product} removed from the ladder." with no count.
|
||||
- **A repeated removal.** A removal of a product that is no tier of the ladder and has no holder, such as the second of two removals of one tier, records no act, deletes nothing and answers as a successful removal does, as the delete below does.
|
||||
- **A preview that cannot read what a guard needs** (the tiers or the org types) shows "Failed to preview the removal. Reference: {request id}." in place of the preview, never an enabled commit.
|
||||
|
||||
The plain delete of a tier with no live holders runs the same way, as a removal with no holders, and decides inside the one transaction that deletes:
|
||||
|
||||
- **Live holders.** A holder is live when its position is `active` or `suspended`, the population a removal reconciles. The delete counts them in its own transaction and refuses a tier that has any, so a tier held only by a past-due subscription's suspended position is not deleted under it; the tier page offers Preview removal for such a tier, and the delete a stale page sends is refused with the live-holder sentence. The Active column on the tier page still counts `active` positions only.
|
||||
|
||||
@@ -1079,8 +1079,8 @@ func timeRemoveTier(t *testing.T, w *world.World, owner world.Person, holders in
|
||||
planActFloored(w, owner, orgType, "Remove holder", holders)
|
||||
start := time.Now()
|
||||
res, err := entitlements.RemoveTier(w.Ctx, w.DB, entitlements.RemoveTierInput{
|
||||
LadderID: ladder.ID, ProductID: first.ID, ProductName: first.Name, LadderLabel: ladder.Name,
|
||||
Disposition: "migrate", OperatorPersonID: owner.ID,
|
||||
LadderID: ladder.ID, ProductID: first.ID, Disposition: "migrate", OperatorPersonID: owner.ID,
|
||||
Acts: entitlements.PlanActConfig{HolderCap: math.MaxInt64},
|
||||
})
|
||||
took := time.Since(start)
|
||||
if err != nil || res.Migrated != holders {
|
||||
|
||||
@@ -116,17 +116,17 @@ func (e *LiveHoldersError) Error() string {
|
||||
return fmt.Sprintf("%d live holder(s) on the tier", e.Holders)
|
||||
}
|
||||
|
||||
// LastTierError is a tier delete refused by LastTierRefusal. Message is the
|
||||
// sentence the operator reads. The delete wrote nothing.
|
||||
// LastTierError is a tier delete or removal refused by LastTierRefusal.
|
||||
// Message is the sentence the operator reads. The act wrote nothing.
|
||||
type LastTierError struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *LastTierError) Error() string { return e.Message }
|
||||
|
||||
// HolderLimitError is a tier add refused because more organizations hold the
|
||||
// product than planActHolderCap. Message is HolderLimitRefusal's sentences.
|
||||
// The act wrote nothing.
|
||||
// HolderLimitError is a tier add or a tier removal refused because more
|
||||
// organizations hold the product than planActHolderCap. Message is
|
||||
// HolderLimitRefusal's sentences. The act wrote nothing.
|
||||
type HolderLimitError struct {
|
||||
Message string
|
||||
}
|
||||
@@ -175,26 +175,31 @@ func operatorActor(operatorPersonID, reason string) Actor {
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveTierInput is a removal of ProductID's tier from LadderID. The names
|
||||
// are the ones the removal's reason records (the ids stand in when a name
|
||||
// could not be read); Disposition is "keep" or "migrate" for the positions
|
||||
// held through an organization type's default, and may be empty when none
|
||||
// is.
|
||||
// RemoveTierInput is a removal of ProductID's tier from LadderID by the
|
||||
// operator. Disposition is "keep" or "migrate" for the positions held through
|
||||
// an organization type's default, and may be empty when none is. RequestID is
|
||||
// the request's identifier, which the act stores for correlating it with log
|
||||
// lines; Acts says where the act builds its queriers.
|
||||
type RemoveTierInput struct {
|
||||
LadderID string
|
||||
ProductID string
|
||||
ProductName string
|
||||
LadderLabel string
|
||||
Disposition string
|
||||
OperatorPersonID string
|
||||
RequestID string
|
||||
Acts PlanActConfig
|
||||
}
|
||||
|
||||
// RemoveTierResult counts what a removal did: the positions on the ladder
|
||||
// it ended while their delivery continues, and the organizations it
|
||||
// migrated to the current default.
|
||||
// migrated to the current default. ProductName is the product's name as the
|
||||
// act read it inside its transaction, for the answer. ActID is the act the
|
||||
// removal recorded; it is empty when the product was no tier of the ladder and
|
||||
// no organization held it, so there was nothing to record.
|
||||
type RemoveTierResult struct {
|
||||
Ended int
|
||||
Migrated int
|
||||
ActID string
|
||||
ProductName string
|
||||
Ended int
|
||||
Migrated int
|
||||
}
|
||||
|
||||
// tierHolders is one pool's positions on the tier being removed:
|
||||
@@ -204,96 +209,125 @@ type tierHolders struct {
|
||||
aligns []string
|
||||
}
|
||||
|
||||
// RemoveTier removes a tier that has live holders, in one transaction, so a
|
||||
// failure changes nothing. Once the tier row is gone no entry point can
|
||||
// RemoveTier removes a tier, with or without live holders, in one transaction,
|
||||
// so a failure changes nothing. Once the tier row is gone no entry point can
|
||||
// re-run a half-reconciled removal, and a provision whose shape no longer
|
||||
// matches its product would block later conferrals of that product, so the
|
||||
// deletion rolls back with any failed pool. The tier is deleted and the
|
||||
// ladder renumbered, then each holder's pool is reconciled in ascending pool
|
||||
// order. The pools are locked in that order before the holders are read
|
||||
// again under the locks; the first read only names them. Other-source
|
||||
// positions align-shrink (the junction ends, delivery continues).
|
||||
// Default-sourced positions follow the disposition: keep aligns them like
|
||||
// any other; migrate ends them, aligns the rest, and restores the
|
||||
// organization type's default when the pool is left with no live plan,
|
||||
// resolved after the renumbering, so removing a default ladder's rank 0
|
||||
// migrates holders onto the promoted tier. Default-sourced holders with no
|
||||
// disposition refuse the removal with a DispositionRequiredError.
|
||||
// deletion rolls back with any failed pool. Inside the exclusive transaction,
|
||||
// before any pool row is taken, it counts the organizations holding the tier
|
||||
// and refuses above planActHolderCap (HolderLimitRefusal), reads the ladder's
|
||||
// tiers and the organization types and refuses the last tier of a ladder an
|
||||
// organization type defaults to (LastTierRefusal) under either disposition,
|
||||
// and reads the product's and the ladder's names; a failed read refuses. It
|
||||
// then locks the holders' pools in ascending order and reads the holders again
|
||||
// under the locks; the first read only names them. Default-sourced holders
|
||||
// with no disposition refuse the removal with a DispositionRequiredError.
|
||||
// Then it records the act (cause ActTierRemoved, the disposition when one was
|
||||
// asked for), deletes the tier and renumbers the ladder, and reconciles each
|
||||
// pool in ascending pool order. Other-source positions align-shrink (the
|
||||
// junction ends, delivery continues). Default-sourced positions follow the
|
||||
// disposition: keep aligns them like any other; migrate ends them, aligns the
|
||||
// rest, and restores the organization type's default when the pool is left
|
||||
// with no live plan, resolved after the renumbering, so removing a default
|
||||
// ladder's rank 0 migrates holders onto the promoted tier; the grants the
|
||||
// restoration creates carry the act's id. A product that is no tier of the
|
||||
// ladder and has no holder was removed already, by a removal this one waited
|
||||
// behind: nothing is recorded or deleted.
|
||||
func RemoveTier(ctx context.Context, b db.Beginner, in RemoveTierInput) (RemoveTierResult, error) {
|
||||
actor := operatorActor(in.OperatorPersonID, fmt.Sprintf("tier removal (%s from %s)", in.ProductName, in.LadderLabel))
|
||||
var res RemoveTierResult
|
||||
tx, err := BeginRuleChange(ctx, b)
|
||||
t := &tierRemoval{in: in}
|
||||
err := runPlanAct(ctx, b, planAct{
|
||||
Kind: RuleChange,
|
||||
Queriers: in.Acts,
|
||||
Find: t.find,
|
||||
Reread: t.reread,
|
||||
Act: t.act,
|
||||
})
|
||||
if err != nil {
|
||||
return res, &PlanActError{Step: StepBegin, Err: err}
|
||||
return RemoveTierResult{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
qtx := New(tx)
|
||||
|
||||
pools, work, defaultHolders, err := lockTierHolders(ctx, qtx, in.LadderID, in.ProductID)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
// Validation gate: default-sourced holders require an explicit
|
||||
// disposition. Refusing here writes nothing; the tier stays.
|
||||
if defaultHolders > 0 && in.Disposition == "" {
|
||||
return res, &DispositionRequiredError{Holders: defaultHolders}
|
||||
}
|
||||
// Delete and renumber first: alignment matches the product's CURRENT
|
||||
// shape, so the shape must already be shrunk, and the restoration below
|
||||
// must resolve rank 0 against the promoted order.
|
||||
if err := deleteTier(ctx, billing.New(tx), in.LadderID, in.ProductID); err != nil {
|
||||
return res, err
|
||||
}
|
||||
for _, poolID := range pools {
|
||||
pw := work[poolID]
|
||||
if pw == nil {
|
||||
continue
|
||||
}
|
||||
if err := removeFromPool(ctx, tx, qtx, poolID, pw, in.Disposition, actor, &res); err != nil {
|
||||
return res, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return res, &PlanActError{Step: StepCommit, Err: err}
|
||||
}
|
||||
return res, nil
|
||||
return t.result, nil
|
||||
}
|
||||
|
||||
// lockTierHolders reads the tier's live holders, locks their pools in
|
||||
// ascending order, and reads the holders again under the locks: the first
|
||||
// read was a projection. It returns the locked pools in lock order, each
|
||||
// pool's positions as of the second read, and how many of those are held
|
||||
// through an organization type's default. A holder of a pool the first read
|
||||
// did not name is in the map but not in the pool list, so the caller skips
|
||||
// it.
|
||||
func lockTierHolders(ctx context.Context, qtx *Queries, ladderID, productID string) ([]string, map[string]*tierHolders, int, error) {
|
||||
params := ListLiveTierHoldersByLadderProductParams{PlanLadderID: ladderID, ProductID: productID}
|
||||
rows, err := qtx.ListLiveTierHoldersByLadderProduct(ctx, params)
|
||||
// tierRemoval is one tier removal's state across the runner's steps: the
|
||||
// names it read, the pools it locked and each one's positions on the tier.
|
||||
type tierRemoval struct {
|
||||
in RemoveTierInput
|
||||
ladderName string
|
||||
productName string
|
||||
isTier bool
|
||||
locked []string
|
||||
work map[string]*tierHolders
|
||||
defaultHolders int
|
||||
result RemoveTierResult
|
||||
}
|
||||
|
||||
// find counts the holders and refuses above the limit, refuses the last tier
|
||||
// of a default ladder, reads the names, and names the pools of the tier's
|
||||
// holders.
|
||||
func (t *tierRemoval) find(ctx context.Context, q planActQueriers) ([]string, error) {
|
||||
in := t.in
|
||||
counts, err := q.Ent.CountLiveTierHolders(ctx, CountLiveTierHoldersParams{PlanLadderID: in.LadderID, ProductID: in.ProductID})
|
||||
if err != nil {
|
||||
return nil, nil, 0, &PlanActError{Step: StepReadHolders, Err: err}
|
||||
return nil, &PlanActError{Step: StepCountHolders, Err: err}
|
||||
}
|
||||
holderPools := make([]string, len(rows))
|
||||
for i, row := range rows {
|
||||
holderPools[i] = row.PoolID
|
||||
}
|
||||
pools, err := qtx.LockPools(ctx, holderPools...)
|
||||
tiers, err := q.Billing.ListTiersByLadder(ctx, in.LadderID)
|
||||
if err != nil {
|
||||
return nil, nil, 0, &PlanActError{Step: StepLockPools, Err: err}
|
||||
return nil, &PlanActError{Step: StepReadTiers, Err: err}
|
||||
}
|
||||
rows, err = qtx.ListLiveTierHoldersByLadderProduct(ctx, params)
|
||||
orgTypes, err := q.Org.ListOrgTypes(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, 0, &PlanActError{Step: StepRereadHolders, Err: err}
|
||||
return nil, &PlanActError{Step: StepReadOrgTypes, Err: err}
|
||||
}
|
||||
defaulting := orgTypesDefaultingTo(orgTypes, in.LadderID)
|
||||
if err := TierRemovalRefusal(tiers, in.ProductID, defaulting, counts.Holders, counts.DefaultSourced, counts.DefaultSourcedOnDefault, in.Acts.HolderLimit()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.isTier = slices.ContainsFunc(tiers, func(tier billing.PlanLadderTier) bool { return tier.ProductID == in.ProductID })
|
||||
ladder, err := q.Billing.GetPlanLadderByID(ctx, in.LadderID)
|
||||
if err != nil {
|
||||
return nil, &PlanActError{Step: StepReadNames, Err: err}
|
||||
}
|
||||
product, err := q.Billing.GetProductByID(ctx, in.ProductID)
|
||||
if err != nil {
|
||||
return nil, &PlanActError{Step: StepReadNames, Err: err}
|
||||
}
|
||||
t.ladderName, t.productName = ladder.Name, product.Name
|
||||
t.result.ProductName = product.Name
|
||||
|
||||
rows, err := q.Ent.ListLiveTierHoldersByLadderProduct(ctx, ListLiveTierHoldersByLadderProductParams{PlanLadderID: in.LadderID, ProductID: in.ProductID})
|
||||
if err != nil {
|
||||
return nil, &PlanActError{Step: StepReadHolders, Err: err}
|
||||
}
|
||||
work := make(map[string]*tierHolders)
|
||||
defaultHolders := 0
|
||||
for _, row := range rows {
|
||||
pw := work[row.PoolID]
|
||||
t.locked = append(t.locked, row.PoolID)
|
||||
}
|
||||
slices.Sort(t.locked)
|
||||
t.locked = slices.Compact(t.locked)
|
||||
return t.locked, nil
|
||||
}
|
||||
|
||||
// reread reads the holders again under the pool locks and groups their
|
||||
// positions by pool. Under the exclusive rendezvous no conferral adds a
|
||||
// position between the reads, so a pool the first read did not name is a
|
||||
// defect: its position would stay on a deleted tier.
|
||||
func (t *tierRemoval) reread(ctx context.Context, q planActQueriers) error {
|
||||
rows, err := q.Ent.ListLiveTierHoldersByLadderProduct(ctx, ListLiveTierHoldersByLadderProductParams{PlanLadderID: t.in.LadderID, ProductID: t.in.ProductID})
|
||||
if err != nil {
|
||||
return &PlanActError{Step: StepRereadHolders, Err: err}
|
||||
}
|
||||
t.work = make(map[string]*tierHolders)
|
||||
for _, row := range rows {
|
||||
if !slices.Contains(t.locked, row.PoolID) {
|
||||
return &PlanActError{Step: StepRereadHolders, PoolID: row.PoolID,
|
||||
Err: errors.New("a holder of a pool the first read did not name")}
|
||||
}
|
||||
pw := t.work[row.PoolID]
|
||||
if pw == nil {
|
||||
pw = &tierHolders{}
|
||||
work[row.PoolID] = pw
|
||||
t.work[row.PoolID] = pw
|
||||
}
|
||||
if row.DefaultSourced {
|
||||
defaultHolders++
|
||||
t.defaultHolders++
|
||||
pw.incumbents = append(pw.incumbents, DefaultIncumbent{
|
||||
ProvisionID: row.ProvisionID,
|
||||
GrantID: row.GrantID.UUID.String(),
|
||||
@@ -303,7 +337,59 @@ func lockTierHolders(ctx context.Context, qtx *Queries, ladderID, productID stri
|
||||
}
|
||||
pw.aligns = append(pw.aligns, row.ProvisionID)
|
||||
}
|
||||
return pools, work, defaultHolders, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// act refuses default-sourced holders that have no disposition, records the
|
||||
// act, deletes the tier and renumbers the ladder, then reconciles each holder's
|
||||
// pool.
|
||||
func (t *tierRemoval) act(ctx context.Context, q planActQueriers) error {
|
||||
in := t.in
|
||||
// Validation gate: default-sourced holders require an explicit
|
||||
// disposition. Refusing here writes nothing; the tier stays.
|
||||
if t.defaultHolders > 0 && in.Disposition == "" {
|
||||
return &DispositionRequiredError{Holders: t.defaultHolders}
|
||||
}
|
||||
if !t.isTier && len(t.work) == 0 {
|
||||
return nil
|
||||
}
|
||||
record := planActRecord{
|
||||
Cause: ActTierRemoved,
|
||||
ActorPersonID: in.OperatorPersonID,
|
||||
RequestID: in.RequestID,
|
||||
EffectiveAt: time.Now(),
|
||||
PlanLadderID: in.LadderID,
|
||||
PlanLadderName: t.ladderName,
|
||||
ProductID: in.ProductID,
|
||||
ProductName: t.productName,
|
||||
}
|
||||
if t.defaultHolders > 0 {
|
||||
record.Disposition = in.Disposition
|
||||
}
|
||||
actID, err := writePlanAct(ctx, q.Ent, record)
|
||||
if err != nil {
|
||||
return &PlanActError{Step: StepRecordAct, Err: err}
|
||||
}
|
||||
t.result.ActID = actID
|
||||
actor := operatorActor(in.OperatorPersonID, PlanActReason(record))
|
||||
actor.ActID = uuid.NullUUID{UUID: uuid.MustParse(actID), Valid: true}
|
||||
|
||||
// Delete and renumber first: alignment matches the product's CURRENT
|
||||
// shape, so the shape must already be shrunk, and the restoration below
|
||||
// must resolve rank 0 against the promoted order.
|
||||
if err := deleteTier(ctx, q.Billing, in.LadderID, in.ProductID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, poolID := range t.locked {
|
||||
pw := t.work[poolID]
|
||||
if pw == nil {
|
||||
continue // its position ended between the reads
|
||||
}
|
||||
if err := removeFromPool(ctx, q, poolID, pw, in.Disposition, actor, &t.result); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteTier deletes the tier and renumbers the ladder's remaining tiers to
|
||||
@@ -332,11 +418,11 @@ func deleteTier(ctx context.Context, bq billing.Querier, ladderID, productID str
|
||||
// remaining position of the product, then restores the default, so a pool
|
||||
// that still holds another live position after alignment is not re-floored;
|
||||
// then it materializes the pool.
|
||||
func removeFromPool(ctx context.Context, tx *sql.Tx, qtx *Queries, poolID string, pw *tierHolders, disposition string, actor Actor, res *RemoveTierResult) error {
|
||||
func removeFromPool(ctx context.Context, q planActQueriers, poolID string, pw *tierHolders, disposition string, actor Actor, res *RemoveTierResult) error {
|
||||
migrate := len(pw.incumbents) > 0 && disposition == "migrate"
|
||||
aligns := pw.aligns
|
||||
if migrate {
|
||||
if err := EndDefaultIncumbents(ctx, qtx, pw.incumbents, actor); err != nil {
|
||||
if err := EndDefaultIncumbents(ctx, q.Conferral, pw.incumbents, actor); err != nil {
|
||||
return &PlanActError{Step: StepEndIncumbents, PoolID: poolID, Err: err}
|
||||
}
|
||||
res.Migrated++
|
||||
@@ -346,7 +432,7 @@ func removeFromPool(ctx context.Context, tx *sql.Tx, qtx *Queries, poolID string
|
||||
}
|
||||
}
|
||||
for _, provisionID := range aligns {
|
||||
if _, err := qtx.AlignConferralShape(ctx, AlignConferralShapeParams{
|
||||
if _, err := q.Conferral.AlignConferralShape(ctx, AlignConferralShapeParams{
|
||||
ProvisionID: provisionID,
|
||||
ActorType: "operator",
|
||||
ActorID: actor.ActorID,
|
||||
@@ -357,11 +443,11 @@ func removeFromPool(ctx context.Context, tx *sql.Tx, qtx *Queries, poolID string
|
||||
res.Ended++
|
||||
}
|
||||
if migrate {
|
||||
if _, err := ReapplyDefaultsIfVacant(ctx, tx, poolID, actor); err != nil {
|
||||
if _, err := ReapplyDefaultsIfVacant(ctx, q.Tx, poolID, actor); err != nil {
|
||||
return &PlanActError{Step: StepRestore, PoolID: poolID, Err: err}
|
||||
}
|
||||
}
|
||||
if err := MaterializePoolEntitlements(ctx, qtx, poolID); err != nil {
|
||||
if err := MaterializePoolEntitlements(ctx, q.Conferral, poolID); err != nil {
|
||||
return &PlanActError{Step: StepMaterialize, PoolID: poolID, Err: err}
|
||||
}
|
||||
return nil
|
||||
@@ -431,7 +517,7 @@ func (t *tierAdd) find(ctx context.Context, q planActQueriers) ([]string, error)
|
||||
if err != nil {
|
||||
return nil, &PlanActError{Step: StepCountHolders, Err: err}
|
||||
}
|
||||
if refusal := HolderLimitRefusal(HolderLimitAdd, counts.Holders, counts.DefaultSourced, t.in.Acts.holderCap(), false); refusal != "" {
|
||||
if refusal := HolderLimitRefusal(HolderLimitAdd, counts.Holders, counts.DefaultSourced, t.in.Acts.HolderLimit(), false); refusal != "" {
|
||||
return nil, &HolderLimitError{Message: refusal}
|
||||
}
|
||||
provisions, err := q.Ent.GetLivePoolProvisionsByProductID(ctx, t.in.ProductID)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -27,9 +28,9 @@ type PlanActConfig struct {
|
||||
Entitlements func(DBTX) Querier
|
||||
Billing func(billing.DBTX) billing.Querier
|
||||
Organization func(organization.DBTX) organization.Querier
|
||||
// HolderCap replaces planActHolderCap, the holder limit of a tier add;
|
||||
// zero means the constant. A test sets it so a scenario can cross the
|
||||
// limit with a few organizations.
|
||||
// HolderCap replaces planActHolderCap, the holder limit of a tier add, of a
|
||||
// tier removal and of the removal's preview; zero means the constant. A
|
||||
// test sets it so a scenario can cross the limit with a few organizations.
|
||||
HolderCap int64
|
||||
// Limits is what a request spends on a plan act's listing and moves. The
|
||||
// zero value means the constants'.
|
||||
@@ -266,8 +267,9 @@ func orgTypesDefaultingTo(types []organization.OrgType, ladderID string) []strin
|
||||
return names
|
||||
}
|
||||
|
||||
// holderCap is the holder limit a tier add enforces.
|
||||
func (c PlanActConfig) holderCap() int64 {
|
||||
// HolderLimit is the holder limit a tier add and a tier removal enforce, and
|
||||
// the removal's preview states: the constant, or HolderCap when a test sets it.
|
||||
func (c PlanActConfig) HolderLimit() int64 {
|
||||
if c.HolderCap > 0 {
|
||||
return c.HolderCap
|
||||
}
|
||||
@@ -291,10 +293,11 @@ const (
|
||||
// and the limit. When more than half of the holders hold it through a
|
||||
// default-sourced position on a ladder (defaultSourced of them), a second
|
||||
// sentence names the way through: changing the default with Migrate, and, for
|
||||
// a removal of the rank-0 tier of a ladder their type defaults to
|
||||
// (rankZeroOfTheirDefault), reordering the ladder with Migrate as well. It
|
||||
// reads no row; the tier add applies it, and the removal's wording is here
|
||||
// for the act that will apply the same rule.
|
||||
// a removal of the rank-0 tier of a ladder that has another tier, held only
|
||||
// through types that default to it (rankZeroOfTheirDefault), reordering the
|
||||
// ladder with Migrate as well. It
|
||||
// reads no row; the tier add applies it, and the removal through
|
||||
// TierRemovalRefusal, which its preview calls too.
|
||||
func HolderLimitRefusal(act HolderLimitAct, holders, defaultSourced, limit int64, rankZeroOfTheirDefault bool) string {
|
||||
if holders <= limit {
|
||||
return ""
|
||||
@@ -313,6 +316,27 @@ func HolderLimitRefusal(act HolderLimitAct, holders, defaultSourced, limit int64
|
||||
return first + " " + formatCount(defaultSourced) + " of them hold it through their org type's default; " + way
|
||||
}
|
||||
|
||||
// TierRemovalRefusal is the guard of a tier removal: the refusal the act
|
||||
// returns, and the preview states as the reason its commit is disabled, or nil.
|
||||
// holders are the organizations holding productID's tier, defaultSourced how
|
||||
// many of them hold it through an org type's default and onDefault how many of
|
||||
// those hold it through a type whose default is this ladder; defaultingTypes are
|
||||
// the display names of the org types whose default is the ladder. Above limit it
|
||||
// is a *HolderLimitError (HolderLimitRefusal, with its rank-0 variant when the
|
||||
// tier is rank 0 of a ladder that has another tier and onDefault is
|
||||
// defaultSourced); otherwise a *LastTierError when LastTierRefusal refuses. The
|
||||
// limit comes first. It reads no row.
|
||||
func TierRemovalRefusal(tiers []billing.PlanLadderTier, productID string, defaultingTypes []string, holders, defaultSourced, onDefault, limit int64) error {
|
||||
rankZero := slices.ContainsFunc(tiers, func(t billing.PlanLadderTier) bool { return t.ProductID == productID && t.Rank == 0 })
|
||||
if refusal := HolderLimitRefusal(HolderLimitRemoval, holders, defaultSourced, limit, rankZero && len(tiers) > 1 && onDefault == defaultSourced); refusal != "" {
|
||||
return &HolderLimitError{Message: refusal}
|
||||
}
|
||||
if refusal := LastTierRefusal(tiers, productID, defaultingTypes); refusal != "" {
|
||||
return &LastTierError{Message: refusal}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PlanActReason is the reason every transition of a plan act records, built
|
||||
// from the act's detail row so the wording lives in one place. The prefixes
|
||||
// are the ones the Tier changes list recognizes. A cause that moves no
|
||||
|
||||
@@ -164,6 +164,62 @@ func TestLastTierRefusal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TierRemovalRefusal puts the holder limit before the last-tier guard, words
|
||||
// the limit's way through with the reorder only for the rank-0 tier of a ladder
|
||||
// some org type defaults to, and refuses nothing otherwise.
|
||||
func TestTierRemovalRefusal(t *testing.T) {
|
||||
only := []billing.PlanLadderTier{{ProductID: "basic", Rank: 0}}
|
||||
two := []billing.PlanLadderTier{{ProductID: "basic", Rank: 0}, {ProductID: "plus", Rank: 1}}
|
||||
const (
|
||||
limit = "3 organizations hold this tier; a removal is refused above 2."
|
||||
viaType = " 2 of them hold it through their org type's default; "
|
||||
change = "changing that default with Migrate moves them in the background."
|
||||
reorder = "changing that default, or reordering this ladder, with Migrate moves them in the background."
|
||||
lastTier = "This ladder's only remaining tier; Personal defaults new signups to it. Deleting it would leave those signups without a starting plan, so the removal is refused."
|
||||
)
|
||||
personal := []string{"Personal"}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
tiers []billing.PlanLadderTier
|
||||
product string
|
||||
defaults []string
|
||||
holders int64
|
||||
defaultSourced int64
|
||||
onDefault int64
|
||||
wantLimit string
|
||||
wantLastTier string
|
||||
}{
|
||||
{"within the limit, a tier among others", two, "basic", personal, 2, 2, 2, "", ""},
|
||||
{"above the limit, rank 0, every default-sourced holder's type defaults to the ladder", two, "basic", personal, 3, 2, 2, limit + viaType + reorder, ""},
|
||||
{"above the limit, rank 0, some holders' types default elsewhere", two, "basic", personal, 3, 2, 1, limit + viaType + change, ""},
|
||||
{"above the limit, rank 0, no holder's type defaults to the ladder", two, "basic", nil, 3, 2, 0, limit + viaType + change, ""},
|
||||
{"above the limit, a tier that is not rank 0", two, "plus", personal, 3, 2, 2, limit + viaType + change, ""},
|
||||
{"above the limit, few default-sourced", two, "basic", personal, 3, 1, 1, limit, ""},
|
||||
{"within the limit, the last tier of a default ladder", only, "basic", personal, 2, 2, 2, "", lastTier},
|
||||
{"above the limit, a one-tier ladder", only, "basic", personal, 3, 2, 2, limit + viaType + change, ""},
|
||||
{"within the limit, the last tier of a ladder no type defaults to", only, "basic", nil, 2, 2, 0, "", ""},
|
||||
{"no holders left, a tier among others", two, "plus", nil, 0, 0, 0, "", ""},
|
||||
} {
|
||||
err := TierRemovalRefusal(tc.tiers, tc.product, tc.defaults, tc.holders, tc.defaultSourced, tc.onDefault, 2)
|
||||
var limited *HolderLimitError
|
||||
var last *LastTierError
|
||||
switch {
|
||||
case errors.As(err, &limited):
|
||||
if limited.Message != tc.wantLimit {
|
||||
t.Errorf("%s: limit refusal = %q, want %q", tc.name, limited.Message, tc.wantLimit)
|
||||
}
|
||||
case errors.As(err, &last):
|
||||
if last.Message != tc.wantLastTier {
|
||||
t.Errorf("%s: last-tier refusal = %q, want %q", tc.name, last.Message, tc.wantLastTier)
|
||||
}
|
||||
case err != nil:
|
||||
t.Errorf("%s: refused with %T, want a limit or last-tier refusal or none", tc.name, err)
|
||||
case tc.wantLimit != "" || tc.wantLastTier != "":
|
||||
t.Errorf("%s: no refusal, want %q%q", tc.name, tc.wantLimit, tc.wantLastTier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A failed begin comes back as the begin step's error before any step runs.
|
||||
func TestRunPlanActNamesAFailedBegin(t *testing.T) {
|
||||
var events []string
|
||||
|
||||
@@ -129,6 +129,41 @@ func (q *Queries) CountLiveAttachmentsByTier(ctx context.Context, arg CountLiveA
|
||||
return count, err
|
||||
}
|
||||
|
||||
const countLiveTierHolders = `-- name: CountLiveTierHolders :one
|
||||
SELECT COUNT(DISTINCT rp.org_id) AS holders,
|
||||
COUNT(DISTINCT rp.org_id) FILTER (WHERE l.default_sourced) AS default_sourced,
|
||||
COUNT(DISTINCT rp.org_id) FILTER (WHERE l.default_sourced AND ot.default_plan_ladder_id = l.plan_ladder_id) AS default_sourced_on_default
|
||||
FROM core.pool_provision_ladders l
|
||||
JOIN core.resource_pools rp ON rp.pool_id = l.pool_id
|
||||
LEFT JOIN core.org_types ot ON ot.org_type = l.org_type
|
||||
WHERE l.plan_ladder_id = $1 AND l.product_id = $2 AND l.status IN ('active', 'suspended')
|
||||
`
|
||||
|
||||
type CountLiveTierHoldersParams struct {
|
||||
PlanLadderID string `json:"plan_ladder_id"`
|
||||
ProductID string `json:"product_id"`
|
||||
}
|
||||
|
||||
type CountLiveTierHoldersRow struct {
|
||||
Holders int64 `json:"holders"`
|
||||
DefaultSourced int64 `json:"default_sourced"`
|
||||
DefaultSourcedOnDefault int64 `json:"default_sourced_on_default"`
|
||||
}
|
||||
|
||||
// The organizations holding a live (active|suspended) position on this
|
||||
// ladder's tier, and how many of them hold it through a default-sourced
|
||||
// position, and how many of those hold it through an org type that defaults to
|
||||
// this ladder (the type the position was made for, copied onto it): the
|
||||
// removal's holder count, which it refuses above its limit on and words the way
|
||||
// through by, and which its preview reads as well. Counting organizations, not
|
||||
// positions, is the limit's unit.
|
||||
func (q *Queries) CountLiveTierHolders(ctx context.Context, arg CountLiveTierHoldersParams) (CountLiveTierHoldersRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, countLiveTierHolders, arg.PlanLadderID, arg.ProductID)
|
||||
var i CountLiveTierHoldersRow
|
||||
err := row.Scan(&i.Holders, &i.DefaultSourced, &i.DefaultSourcedOnDefault)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getActiveAttachmentsByPool = `-- name: GetActiveAttachmentsByPool :many
|
||||
SELECT provision_id, plan_ladder_id, pool_id, status, activated_at, ended_at, created_at, updated_at, product_id, provision_ladder_id, org_type, default_sourced FROM core.pool_provision_ladders
|
||||
WHERE pool_id = $1 AND status = 'active'
|
||||
|
||||
@@ -60,6 +60,14 @@ type Querier interface {
|
||||
// the default would not move it. The tier add refuses above its holder limit
|
||||
// on the first count and words the way through by the second.
|
||||
CountLiveProductHolders(ctx context.Context, productID string) (CountLiveProductHoldersRow, error)
|
||||
// The organizations holding a live (active|suspended) position on this
|
||||
// ladder's tier, and how many of them hold it through a default-sourced
|
||||
// position, and how many of those hold it through an org type that defaults to
|
||||
// this ladder (the type the position was made for, copied onto it): the
|
||||
// removal's holder count, which it refuses above its limit on and words the way
|
||||
// through by, and which its preview reads as well. Counting organizations, not
|
||||
// positions, is the limit's unit.
|
||||
CountLiveTierHolders(ctx context.Context, arg CountLiveTierHoldersParams) (CountLiveTierHoldersRow, error)
|
||||
// The drain's own reads, beside the ledger queries in
|
||||
// entitlement_set_changes.sql. Neither writes: one counts what a change still
|
||||
// owes, the other names the pools whose last attempt left an error so the
|
||||
|
||||
@@ -137,6 +137,22 @@ SELECT core.outgoing_default_exists(sqlc.arg(org_types)::varchar[], sqlc.narg(ta
|
||||
SELECT COUNT(*) FROM core.pool_provision_ladders
|
||||
WHERE plan_ladder_id = $1 AND product_id = $2 AND status IN ('active', 'suspended');
|
||||
|
||||
-- name: CountLiveTierHolders :one
|
||||
-- The organizations holding a live (active|suspended) position on this
|
||||
-- ladder's tier, and how many of them hold it through a default-sourced
|
||||
-- position, and how many of those hold it through an org type that defaults to
|
||||
-- this ladder (the type the position was made for, copied onto it): the
|
||||
-- removal's holder count, which it refuses above its limit on and words the way
|
||||
-- through by, and which its preview reads as well. Counting organizations, not
|
||||
-- positions, is the limit's unit.
|
||||
SELECT COUNT(DISTINCT rp.org_id) AS holders,
|
||||
COUNT(DISTINCT rp.org_id) FILTER (WHERE l.default_sourced) AS default_sourced,
|
||||
COUNT(DISTINCT rp.org_id) FILTER (WHERE l.default_sourced AND ot.default_plan_ladder_id = l.plan_ladder_id) AS default_sourced_on_default
|
||||
FROM core.pool_provision_ladders l
|
||||
JOIN core.resource_pools rp ON rp.pool_id = l.pool_id
|
||||
LEFT JOIN core.org_types ot ON ot.org_type = l.org_type
|
||||
WHERE l.plan_ladder_id = $1 AND l.product_id = $2 AND l.status IN ('active', 'suspended');
|
||||
|
||||
-- name: CountActiveAttachmentsPerTier :many
|
||||
-- The tier's active and live positions for every tier of one ladder in one
|
||||
-- read (#10), keyed by the tier's product: the ladder page and the tier
|
||||
|
||||
@@ -184,8 +184,7 @@ func TestAddTierCountsAKeptProvisionOnlyTowardTheLimit(t *testing.T) {
|
||||
}
|
||||
|
||||
if _, err := entitlements.RemoveTier(w.Ctx, w.DB, entitlements.RemoveTierInput{
|
||||
LadderID: main.ID, ProductID: basic.ID, ProductName: basic.Name, LadderLabel: main.Name,
|
||||
Disposition: "keep", OperatorPersonID: operator.ID,
|
||||
LadderID: main.ID, ProductID: basic.ID, Disposition: "keep", OperatorPersonID: operator.ID,
|
||||
}); err != nil {
|
||||
t.Fatalf("RemoveTier: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
package entitlements_test
|
||||
|
||||
// The tier removal on the runner (openspec change plan-act-obligations, task
|
||||
// 8): the guards it applies inside its transaction before any pool row, the
|
||||
// order it locks pools in, and the act it records.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// removalState is what a removal writes or changes, for the tests that expect
|
||||
// it to have written nothing: the ladder's tiers, its acts, the live positions
|
||||
// on it and the transitions of its pools.
|
||||
func removalState(t *testing.T, w *world.World, ladderID string) []string {
|
||||
t.Helper()
|
||||
q := dbtest.On(t, w.DB)
|
||||
return []string{
|
||||
q.String(`SELECT coalesce(string_agg(product_id::text || ':' || rank, ',' ORDER BY rank), '') FROM core.plan_ladder_tiers WHERE plan_ladder_id = $1`, ladderID),
|
||||
q.String(`SELECT count(*)::text FROM core.plan_act_details WHERE plan_ladder_id = $1`, ladderID),
|
||||
q.String(`SELECT count(*)::text FROM core.pool_provision_ladders WHERE plan_ladder_id = $1 AND status IN ('active', 'suspended')`, ladderID),
|
||||
q.String(`SELECT count(*)::text FROM core.pool_provision_transitions WHERE plan_ladder_id = $1`, ladderID),
|
||||
}
|
||||
}
|
||||
|
||||
// The only tier of a ladder an org type defaults to is refused under either
|
||||
// disposition, inside the removal's transaction: the tier, the positions and
|
||||
// the acts are as they were.
|
||||
func TestRemoveTier_RefusesTheLastTierOfADefaultLadder(t *testing.T) {
|
||||
for _, disposition := range []string{"keep", "migrate"} {
|
||||
t.Run(disposition, func(t *testing.T) {
|
||||
w := world.New(t, dbtest.Open(t))
|
||||
operator := w.Person("Operator")
|
||||
basic := w.Product("Basic")
|
||||
main := w.Ladder("Main", basic)
|
||||
orgType := w.OrgType("last")
|
||||
w.DefaultLadder(orgType, main)
|
||||
w.Floor(w.Org("Holder", orgType, operator))
|
||||
before := removalState(t, w, main.ID)
|
||||
|
||||
_, err := entitlements.RemoveTier(w.Ctx, w.DB, entitlements.RemoveTierInput{
|
||||
LadderID: main.ID, ProductID: basic.ID, Disposition: disposition, OperatorPersonID: operator.ID,
|
||||
})
|
||||
var last *entitlements.LastTierError
|
||||
if !errors.As(err, &last) {
|
||||
t.Fatalf("RemoveTier error = %v, want a *LastTierError", err)
|
||||
}
|
||||
if after := removalState(t, w, main.ID); !slices.Equal(after, before) {
|
||||
t.Errorf("the refused removal changed the ladder: %v, was %v", after, before)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A removal refuses above the holder limit before it takes a pool row or writes
|
||||
// an act, naming the count, the limit and the way through; a holder count at
|
||||
// the limit goes through.
|
||||
func TestRemoveTier_RefusesAboveTheHolderLimit(t *testing.T) {
|
||||
w := world.New(t, dbtest.Open(t))
|
||||
operator := w.Person("Operator")
|
||||
basic, plus := w.Product("Basic"), w.Product("Plus")
|
||||
main := w.Ladder("Main", basic, plus)
|
||||
orgType := w.OrgType("limit")
|
||||
w.DefaultLadder(orgType, main)
|
||||
for _, name := range []string{"First", "Second"} {
|
||||
w.Floor(w.Org(name, orgType, operator))
|
||||
}
|
||||
before := removalState(t, w, main.ID)
|
||||
|
||||
var locked []string
|
||||
acts := entitlements.PlanActConfig{HolderCap: 1, Entitlements: func(d entitlements.DBTX) entitlements.Querier {
|
||||
return poolLocks{Querier: entitlements.New(d), locked: &locked}
|
||||
}}
|
||||
_, err := entitlements.RemoveTier(w.Ctx, w.DB, entitlements.RemoveTierInput{
|
||||
LadderID: main.ID, ProductID: basic.ID, Disposition: "migrate", OperatorPersonID: operator.ID, Acts: acts,
|
||||
})
|
||||
var limit *entitlements.HolderLimitError
|
||||
if !errors.As(err, &limit) {
|
||||
t.Fatalf("RemoveTier error = %v, want a *HolderLimitError", err)
|
||||
}
|
||||
want := "2 organizations hold this tier; a removal is refused above 1. " +
|
||||
"2 of them hold it through their org type's default; changing that default, or reordering this ladder, with Migrate moves them in the background."
|
||||
if limit.Message != want {
|
||||
t.Errorf("refusal = %q, want %q", limit.Message, want)
|
||||
}
|
||||
if len(locked) != 0 {
|
||||
t.Errorf("the refused removal locked pools %v", locked)
|
||||
}
|
||||
if after := removalState(t, w, main.ID); !slices.Equal(after, before) {
|
||||
t.Errorf("the refused removal changed the ladder: %v, was %v", after, before)
|
||||
}
|
||||
|
||||
acts.HolderCap = 2
|
||||
res, err := entitlements.RemoveTier(w.Ctx, w.DB, entitlements.RemoveTierInput{
|
||||
LadderID: main.ID, ProductID: basic.ID, Disposition: "migrate", OperatorPersonID: operator.ID, Acts: acts,
|
||||
})
|
||||
if err != nil || res.Migrated != 2 {
|
||||
t.Fatalf("RemoveTier at the limit = %+v, %v; want 2 organizations migrated", res, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The limit's second sentence names the reorder only when every organization
|
||||
// holding the tier through a type's default holds it through a type that still
|
||||
// defaults to the ladder; one holder whose type now defaults elsewhere takes it
|
||||
// away.
|
||||
func TestRemoveTier_NamesTheReorderOnlyWhenEveryTypeStillDefaultsToTheLadder(t *testing.T) {
|
||||
w := world.New(t, dbtest.Open(t))
|
||||
operator := w.Person("Operator")
|
||||
basic, plus := w.Product("Basic"), w.Product("Plus")
|
||||
main, other := w.Ladder("Main", basic, plus), w.Ladder("Other", plus)
|
||||
kept, moved := w.OrgType("kept"), w.OrgType("moved")
|
||||
w.DefaultLadder(kept, main)
|
||||
w.DefaultLadder(moved, main)
|
||||
w.Floor(w.Org("Kept", kept, operator))
|
||||
w.Floor(w.Org("Moved", moved, operator))
|
||||
acts := entitlements.PlanActConfig{HolderCap: 1}
|
||||
in := entitlements.RemoveTierInput{LadderID: main.ID, ProductID: basic.ID, Disposition: "migrate", OperatorPersonID: operator.ID, Acts: acts}
|
||||
const lead = "2 organizations hold this tier; a removal is refused above 1. 2 of them hold it through their org type's default; "
|
||||
|
||||
for _, tc := range []struct{ name, want string }{
|
||||
{"every type defaults to the ladder", lead + "changing that default, or reordering this ladder, with Migrate moves them in the background."},
|
||||
{"one type defaults elsewhere", lead + "changing that default with Migrate moves them in the background."},
|
||||
} {
|
||||
if tc.name == "one type defaults elsewhere" {
|
||||
w.DefaultLadder(moved, other)
|
||||
}
|
||||
_, err := entitlements.RemoveTier(w.Ctx, w.DB, in)
|
||||
var limit *entitlements.HolderLimitError
|
||||
if !errors.As(err, &limit) {
|
||||
t.Fatalf("%s: RemoveTier error = %v, want a *HolderLimitError", tc.name, err)
|
||||
}
|
||||
if limit.Message != tc.want {
|
||||
t.Errorf("%s: refusal = %q, want %q", tc.name, limit.Message, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate's floor restoration creates the promoted default's grant under the
|
||||
// removal's act: the grant names the act, which records the tier, the ladder
|
||||
// and the disposition by the names it read inside the transaction.
|
||||
func TestRemoveTier_MigrateGrantsTheFloorUnderTheAct(t *testing.T) {
|
||||
w := world.New(t, dbtest.Open(t))
|
||||
operator := w.Person("Operator")
|
||||
basic, plus := w.Product("Basic"), w.Product("Plus")
|
||||
main := w.Ladder("Main", basic, plus)
|
||||
orgType := w.OrgType("floor")
|
||||
w.DefaultLadder(orgType, main)
|
||||
holder := w.Org("Holder", orgType, operator)
|
||||
w.Floor(holder)
|
||||
|
||||
res, err := entitlements.RemoveTier(w.Ctx, w.DB, entitlements.RemoveTierInput{
|
||||
LadderID: main.ID, ProductID: basic.ID, Disposition: "migrate", OperatorPersonID: operator.ID, RequestID: "req-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveTier: %v", err)
|
||||
}
|
||||
if res.ActID == "" || res.ProductName != basic.Name || res.Migrated != 1 {
|
||||
t.Fatalf("result = %+v, want the act's id, %q and one organization migrated", res, basic.Name)
|
||||
}
|
||||
q := dbtest.On(t, w.DB)
|
||||
if got := q.String(`SELECT coalesce(act_id::text, 'none') FROM core.grants WHERE granted_to_org_id = $1 AND product_id = $2`, holder.ID, plus.ID); got != res.ActID {
|
||||
t.Errorf("the floor grant of the promoted tier names act %s, want %s", got, res.ActID)
|
||||
}
|
||||
if got := q.String(`SELECT coalesce(act_id::text, 'none') FROM core.grants WHERE granted_to_org_id = $1 AND product_id = $2`, holder.ID, basic.ID); got != "none" {
|
||||
t.Errorf("the grant the removal ended names act %s, want none: it was made before the act", got)
|
||||
}
|
||||
var cause, ladder, product, disposition, requestID string
|
||||
q.Scan([]any{&cause, &ladder, &product, &disposition, &requestID}, `SELECT a.cause, d.plan_ladder_name, d.product_name,
|
||||
coalesce(d.disposition, 'none'), coalesce(a.request_id, 'none')
|
||||
FROM core.acts a JOIN core.plan_act_details d USING (act_id) WHERE a.act_id = $1`, res.ActID)
|
||||
if cause != "tier_removed" || ladder != main.Name || product != basic.Name || disposition != "migrate" || requestID != "req-1" {
|
||||
t.Errorf("the act = %s %s %s %s %s, want tier_removed %s %s migrate req-1", cause, ladder, product, disposition, requestID, main.Name, basic.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// A removal reconciles the holders' pools in ascending pool id order, whatever
|
||||
// order the holder read returns them in (by organization name, here the higher
|
||||
// pool id first). The reconciliation order is the sort in the removal's find
|
||||
// step; the sort inside the pool locking is not reached with unsorted ids,
|
||||
// since find sorts first.
|
||||
func TestRemoveTier_LocksThePoolsInAscendingOrder(t *testing.T) {
|
||||
w := world.New(t, dbtest.Open(t))
|
||||
operator := w.Person("Operator")
|
||||
basic, plus := w.Product("Basic"), w.Product("Plus")
|
||||
main := w.Ladder("Main", basic, plus)
|
||||
orgType := w.OrgType("order")
|
||||
low, high := w.Org("Beta", orgType, operator), w.Org("Alpha", orgType, operator)
|
||||
if low.PoolID > high.PoolID {
|
||||
t.Fatalf("pool ids %s, %s are not in creation order", low.PoolID, high.PoolID)
|
||||
}
|
||||
w.Issue(low, basic, world.IssueOpts{By: operator})
|
||||
w.Issue(high, basic, world.IssueOpts{By: operator})
|
||||
|
||||
var locked []string
|
||||
if _, err := entitlements.RemoveTier(w.Ctx, w.DB, entitlements.RemoveTierInput{
|
||||
LadderID: main.ID, ProductID: basic.ID, OperatorPersonID: operator.ID,
|
||||
Acts: entitlements.PlanActConfig{Entitlements: func(d entitlements.DBTX) entitlements.Querier {
|
||||
return poolLocks{Querier: entitlements.New(d), locked: &locked}
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("RemoveTier: %v", err)
|
||||
}
|
||||
if want := []string{low.PoolID, high.PoolID}; !slices.Equal(locked, want) {
|
||||
t.Errorf("pools locked as %v, want %v", locked, want)
|
||||
}
|
||||
reconciled := dbtest.On(t, w.DB).Strings(`SELECT pool_id::text FROM core.pool_provision_transitions
|
||||
WHERE plan_ladder_id = $1 AND transition_type = 'end' ORDER BY transition_id`, main.ID)
|
||||
if want := []string{low.PoolID, high.PoolID}; !slices.Equal(reconciled, want) {
|
||||
t.Errorf("pools reconciled as %v, want %v", reconciled, want)
|
||||
}
|
||||
}
|
||||
|
||||
// countRefused is the entitlements querier whose holder count fails.
|
||||
type countRefused struct {
|
||||
entitlements.Querier
|
||||
}
|
||||
|
||||
func (countRefused) CountLiveTierHolders(context.Context, entitlements.CountLiveTierHoldersParams) (entitlements.CountLiveTierHoldersRow, error) {
|
||||
return entitlements.CountLiveTierHoldersRow{}, errors.New("count refused")
|
||||
}
|
||||
|
||||
// A removal whose holder count cannot be read refuses at the count step before
|
||||
// it takes a pool row, with nothing written: it never proceeds for want of an
|
||||
// answer.
|
||||
func TestRemoveTier_RefusesWhenTheHolderCountIsUnread(t *testing.T) {
|
||||
w := world.New(t, dbtest.Open(t))
|
||||
operator := w.Person("Operator")
|
||||
basic, plus := w.Product("Basic"), w.Product("Plus")
|
||||
main := w.Ladder("Main", basic, plus)
|
||||
orgType := w.OrgType("count")
|
||||
w.DefaultLadder(orgType, main)
|
||||
w.Floor(w.Org("Holder", orgType, operator))
|
||||
before := removalState(t, w, main.ID)
|
||||
|
||||
var locked []string
|
||||
_, err := entitlements.RemoveTier(w.Ctx, w.DB, entitlements.RemoveTierInput{
|
||||
LadderID: main.ID, ProductID: basic.ID, Disposition: "migrate", OperatorPersonID: operator.ID,
|
||||
Acts: entitlements.PlanActConfig{Entitlements: func(d entitlements.DBTX) entitlements.Querier {
|
||||
return poolLocks{Querier: countRefused{Querier: entitlements.New(d)}, locked: &locked}
|
||||
}},
|
||||
})
|
||||
var failed *entitlements.PlanActError
|
||||
if !errors.As(err, &failed) || failed.Step != entitlements.StepCountHolders {
|
||||
t.Fatalf("RemoveTier error = %v, want a *PlanActError at %q", err, entitlements.StepCountHolders)
|
||||
}
|
||||
if len(locked) != 0 {
|
||||
t.Errorf("the refused removal locked pools %v", locked)
|
||||
}
|
||||
if after := removalState(t, w, main.ID); !slices.Equal(after, before) {
|
||||
t.Errorf("the refused removal changed the ladder: %v, was %v", after, before)
|
||||
}
|
||||
}
|
||||
|
||||
// A removal of a tier the ladder no longer has, with no holder left, as a
|
||||
// second removal of one tier is, records no act and deletes nothing.
|
||||
func TestRemoveTier_OfATierThatIsGoneRecordsNothing(t *testing.T) {
|
||||
w := world.New(t, dbtest.Open(t))
|
||||
operator := w.Person("Operator")
|
||||
basic, plus, pro := w.Product("Basic"), w.Product("Plus"), w.Product("Pro")
|
||||
main := w.Ladder("Main", basic, plus, pro)
|
||||
in := entitlements.RemoveTierInput{LadderID: main.ID, ProductID: plus.ID, OperatorPersonID: operator.ID}
|
||||
|
||||
first, err := entitlements.RemoveTier(w.Ctx, w.DB, in)
|
||||
if err != nil || first.ActID == "" {
|
||||
t.Fatalf("the first removal = %+v, %v; want an act", first, err)
|
||||
}
|
||||
afterFirst := removalState(t, w, main.ID)
|
||||
second, err := entitlements.RemoveTier(w.Ctx, w.DB, in)
|
||||
if err != nil || second.ActID != "" {
|
||||
t.Fatalf("the second removal = %+v, %v; want no act", second, err)
|
||||
}
|
||||
if after := removalState(t, w, main.ID); !slices.Equal(after, afterFirst) {
|
||||
t.Errorf("the second removal changed the ladder: %v, was %v", after, afterFirst)
|
||||
}
|
||||
}
|
||||
@@ -188,6 +188,11 @@ type PendingTierRemoval struct {
|
||||
// sits beside the promoted-rank-0 note rather than replacing it, and
|
||||
// disables nothing under any disposition.
|
||||
RuleLessWarning template.HTML
|
||||
// Refusal is the sentence the act would refuse the removal with (the holder
|
||||
// limit, the last tier of a default ladder); the preview renders "Apply
|
||||
// change" disabled with it as the reason. Empty when the removal is not
|
||||
// refused.
|
||||
Refusal string
|
||||
// Form is the removal preview's commit form (operator.plan-ladder.
|
||||
// tier.remove.commit), the preview idiom's own render (design D10;
|
||||
// finding FA-13).
|
||||
@@ -877,24 +882,53 @@ func (h *OperatorPartialsHandler) tierDeleteRefusal(r *http.Request, ladderID, p
|
||||
return "Failed to remove tier. Reference: " + middleware.GetRequestID(r.Context()) + "."
|
||||
}
|
||||
|
||||
// removalPreviewFailure opens the sentence the tier card shows in place of the
|
||||
// removal preview when the tiers or the org types its guards need cannot be
|
||||
// read (copy 14); failureReferenceCtx ends it with the request's id.
|
||||
const removalPreviewFailure = "Failed to preview the removal"
|
||||
|
||||
// buildTierRemovalPreview classifies the live holders of (ladder, product)
|
||||
// for the removal preview: default-sourced positions (disposition required)
|
||||
// vs other-source positions (align-shrunk, named, never force-ended), plus
|
||||
// the promoted-rank-0 note when removing the top tier. Read-only. A (nil, "")
|
||||
// return means the tier has no live holders — callers fall back to the plain
|
||||
// modal-confirmed delete.
|
||||
// the promoted-rank-0 note when removing the top tier, and the reason the
|
||||
// removal would be refused (TierRemovalRefusal, the act's own guard), which
|
||||
// the preview states as the reason "Apply change" is disabled. Read-only. A
|
||||
// (nil, "") return means the tier has no live holders — callers fall back to
|
||||
// the plain modal-confirmed delete. A failed read is logged through the
|
||||
// request's logger and returns the sentence that stands in place of the
|
||||
// preview, with the request's id (copies 52 and 14); the preview never shows
|
||||
// a guard it could not evaluate.
|
||||
func (h *OperatorPartialsHandler) buildTierRemovalPreview(ctx context.Context, ladderID, productID string) (*PendingTierRemoval, string) {
|
||||
log := logging.FromContext(ctx)
|
||||
counts, err := h.EntitlementsQ.CountLiveTierHolders(ctx, entitlements.CountLiveTierHoldersParams{
|
||||
PlanLadderID: ladderID,
|
||||
ProductID: productID,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("failed to count tier holders for removal preview", slog.Any("error", err), slog.String("plan_ladder_id", ladderID))
|
||||
return nil, failureReferenceCtx(ctx, "Failed to load the tier's holders")
|
||||
}
|
||||
rows, err := h.EntitlementsQ.ListLiveTierHoldersByLadderProduct(ctx, entitlements.ListLiveTierHoldersByLadderProductParams{
|
||||
PlanLadderID: ladderID,
|
||||
ProductID: productID,
|
||||
})
|
||||
if err != nil {
|
||||
h.Logger.Error("failed to list tier holders for removal preview", slog.Any("error", err))
|
||||
return nil, "Failed to load the tier's holders. Details are in the server logs."
|
||||
log.Error("failed to list tier holders for removal preview", slog.Any("error", err), slog.String("plan_ladder_id", ladderID))
|
||||
return nil, failureReferenceCtx(ctx, "Failed to load the tier's holders")
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, ""
|
||||
}
|
||||
tiers, err := h.BillingQ.ListTiersByLadderWithProducts(ctx, ladderID)
|
||||
if err != nil {
|
||||
log.Error("failed to list tiers for removal preview", slog.Any("error", err), slog.String("plan_ladder_id", ladderID))
|
||||
return nil, failureReferenceCtx(ctx, removalPreviewFailure)
|
||||
}
|
||||
refs, err := h.orgTypesDefaultingToLadder(ctx, ladderID)
|
||||
if err != nil {
|
||||
log.Error("failed to list org types for removal preview", slog.Any("error", err), slog.String("plan_ladder_id", ladderID))
|
||||
return nil, failureReferenceCtx(ctx, removalPreviewFailure)
|
||||
}
|
||||
|
||||
pending := &PendingTierRemoval{ProductID: productID, ProductName: productID}
|
||||
if prod, err := h.BillingQ.GetProductByID(ctx, productID); err == nil {
|
||||
@@ -925,29 +959,49 @@ func (h *OperatorPartialsHandler) buildTierRemovalPreview(ctx context.Context, l
|
||||
}
|
||||
pending.NeedsDisposition = len(pending.DefaultSourced) > 0
|
||||
|
||||
// Promoted-rank-0 note: removing the top tier of a ladder makes the next
|
||||
// tier the resolution target for new-org provisioning.
|
||||
if tiers, err := h.BillingQ.ListTiersByLadderWithProducts(ctx, ladderID); err == nil && len(tiers) > 1 &&
|
||||
tiers[0].ProductID == productID && tiers[0].Rank == 0 {
|
||||
pending.PromotedName = tiers[1].ProductName
|
||||
pending.RuleLessWarning = h.ruleLessWarning(ctx, tiers[1].ProductID)
|
||||
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)
|
||||
}
|
||||
defaultingTypes := make([]string, len(refs))
|
||||
for i, ref := range refs {
|
||||
defaultingTypes[i] = ref.DisplayName
|
||||
}
|
||||
guardTiers := make([]billing.PlanLadderTier, len(tiers))
|
||||
for i, t := range tiers {
|
||||
guardTiers[i] = billing.PlanLadderTier{PlanLadderID: t.PlanLadderID, ProductID: t.ProductID, Rank: t.Rank}
|
||||
}
|
||||
if refusal := entitlements.TierRemovalRefusal(guardTiers, productID, defaultingTypes, counts.Holders, counts.DefaultSourced, counts.DefaultSourcedOnDefault, h.PlanActs.HolderLimit()); refusal != nil {
|
||||
pending.Refusal = refusal.Error()
|
||||
}
|
||||
|
||||
// Promoted-rank-0 note: removing the top tier of a ladder makes the next
|
||||
// tier the resolution target for new-org provisioning.
|
||||
if len(tiers) > 1 && tiers[0].ProductID == productID && tiers[0].Rank == 0 {
|
||||
pending.PromotedName = tiers[1].ProductName
|
||||
pending.RuleLessWarning = h.ruleLessWarning(ctx, tiers[1].ProductID)
|
||||
pending.PromotedForTypes = defaultingTypes
|
||||
}
|
||||
|
||||
pending.Form = h.removalPreviewForm(ladderID, pending, planLadderTierRemovalValues(pending.NeedsDisposition, ""), nil)
|
||||
return pending, ""
|
||||
}
|
||||
|
||||
// removalPreviewForm is the removal preview's commit form for the pending
|
||||
// removal, bound to values and errs. A removal the act would refuse (the holder
|
||||
// limit, the last tier of a default ladder) renders "Apply change" disabled
|
||||
// through the disabled-control part, with the refusal as its reason.
|
||||
func (h *OperatorPartialsHandler) removalPreviewForm(ladderID string, pending *PendingTierRemoval, 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 = planLadderTierRemovalPreview(ladderID, productID, ladderURL,
|
||||
planLadderTierRemovalValues(pending.NeedsDisposition, ""), nil,
|
||||
view := planLadderTierRemovalPreview(ladderID, pending.ProductID, ladderURL, values, errs,
|
||||
h.Templates.Fragment("operator_plan_ladder_tier_removal_message.html", pending))
|
||||
return pending, ""
|
||||
if pending.Refusal != "" {
|
||||
view.CommitState = &forms.DisabledControlView{
|
||||
ID: "apply-change-reason-removal",
|
||||
Label: "Apply change",
|
||||
Reason: pending.Refusal,
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
// renderPlanLadderTiersRemoval re-renders the ladder composite with a pending
|
||||
@@ -978,12 +1032,7 @@ func (h *OperatorPartialsHandler) renderPlanLadderTiersRemovalRefusal(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 = planLadderTierRemovalPreview(ladderID, pending.ProductID, ladderURL, values, errs,
|
||||
h.Templates.Fragment("operator_plan_ladder_tier_removal_message.html", pending))
|
||||
pending.Form = h.removalPreviewForm(ladderID, pending, values, errs)
|
||||
tiersData := h.loadPlanLadderTiersData(r, ladderID)
|
||||
tiersData.RemovalPending = pending
|
||||
h.Templates.Render(w, "operator_plan_ladder_detail.html", PlanLadderDetailData{
|
||||
@@ -1017,11 +1066,12 @@ func (h *OperatorPartialsHandler) PreviewPlanLadderTierRemoval(w http.ResponseWr
|
||||
// CommitPlanLadderTierRemoval handles
|
||||
// POST /partials/operator/plan-ladders/{ladderID}/tiers/{productID}/remove —
|
||||
// the commit half of removing a tier with live holders. The removal is
|
||||
// entitlements.RemoveTier, atomic by design: tier deletion, renumbering and
|
||||
// every holder reconciliation run in one transaction, so a failure rolls
|
||||
// back everything including the deletion. The handler reads the names the
|
||||
// removal's reason records, then renders the toast, the refusal that asks
|
||||
// for a disposition, or the failure's sentence.
|
||||
// entitlements.RemoveTier, atomic by design: the holder count, the guards, the
|
||||
// act's record, the tier deletion, the renumbering and every holder
|
||||
// reconciliation run in one transaction, so a failure rolls back everything
|
||||
// including the deletion. The handler parses the form, calls it and renders
|
||||
// the toast, the refusal that asks for a disposition, or the failure's
|
||||
// sentence; the names the toast and the act's record use are read inside.
|
||||
func (h *OperatorPartialsHandler) CommitPlanLadderTierRemoval(w http.ResponseWriter, r *http.Request) {
|
||||
ladderID := r.PathValue("ladderID")
|
||||
productID := r.PathValue("productID")
|
||||
@@ -1033,8 +1083,13 @@ func (h *OperatorPartialsHandler) CommitPlanLadderTierRemoval(w http.ResponseWri
|
||||
values, parseErrs := planLadderTierRemoveForm.Parse(r)
|
||||
disposition := values.String("default_disposition")
|
||||
if parseErrs.Has("default_disposition") {
|
||||
// A failed read is logged once, with the request id, where it is read.
|
||||
pending, errMsg := h.buildTierRemovalPreview(r.Context(), ladderID, productID)
|
||||
if errMsg != "" || pending == nil {
|
||||
if errMsg != "" {
|
||||
h.renderPlanLadderTiersPage(w, r, ladderID, "", errMsg)
|
||||
return
|
||||
}
|
||||
if pending == nil {
|
||||
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove the tier; nothing was changed.")
|
||||
return
|
||||
}
|
||||
@@ -1043,25 +1098,13 @@ func (h *OperatorPartialsHandler) CommitPlanLadderTierRemoval(w http.ResponseWri
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
productName := productID
|
||||
if prod, err := h.BillingQ.GetProductByID(ctx, productID); err == nil {
|
||||
productName = prod.Name
|
||||
}
|
||||
// The removal's reason is a write-time snapshot of the ladder's name as
|
||||
// it read at the moment of the removal. The ladder UUID stands in when
|
||||
// the row cannot be loaded.
|
||||
ladderLabel := ladderID
|
||||
if ladder, err := h.BillingQ.GetPlanLadderByID(ctx, ladderID); err == nil {
|
||||
ladderLabel = ladder.Name
|
||||
}
|
||||
res, err := entitlements.RemoveTier(ctx, h.Database, entitlements.RemoveTierInput{
|
||||
res, err := entitlements.RemoveTier(r.Context(), h.Database, entitlements.RemoveTierInput{
|
||||
LadderID: ladderID,
|
||||
ProductID: productID,
|
||||
ProductName: productName,
|
||||
LadderLabel: ladderLabel,
|
||||
Disposition: disposition,
|
||||
OperatorPersonID: session.PersonID,
|
||||
RequestID: middleware.GetRequestID(r.Context()),
|
||||
Acts: h.PlanActs,
|
||||
})
|
||||
var undecided *entitlements.DispositionRequiredError
|
||||
if errors.As(err, &undecided) {
|
||||
@@ -1069,7 +1112,7 @@ func (h *OperatorPartialsHandler) CommitPlanLadderTierRemoval(w http.ResponseWri
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
h.renderPlanLadderTiersPage(w, r, ladderID, "", h.tierRemovalFailure(err))
|
||||
h.renderPlanLadderTiersPage(w, r, ladderID, "", h.tierRemovalFailure(r, ladderID, productID, err))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1080,17 +1123,29 @@ func (h *OperatorPartialsHandler) CommitPlanLadderTierRemoval(w http.ResponseWri
|
||||
if res.Migrated > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d organization(s) migrated to the current default", res.Migrated))
|
||||
}
|
||||
msg := fmt.Sprintf("%s removed from the ladder: %s.", productName, strings.Join(parts, "; "))
|
||||
h.renderPlanLadderTiersPage(w, r, ladderID, msg, "")
|
||||
msg := res.ProductName + " removed from the ladder"
|
||||
if len(parts) > 0 {
|
||||
msg += ": " + strings.Join(parts, "; ")
|
||||
}
|
||||
h.renderPlanLadderTiersPage(w, r, ladderID, msg+".", "")
|
||||
}
|
||||
|
||||
// refuseUndecidedTierRemoval answers a removal that found holders through
|
||||
// an organization type's default and no disposition: the removal preview at
|
||||
// 422 with the refusal under the disposition field. The removal wrote
|
||||
// nothing; the tier stays.
|
||||
// nothing; the tier stays. A preview that cannot be rebuilt answers its own
|
||||
// read failure (logged once, with the request id, where it is read). A rebuilt
|
||||
// preview with no default-sourced holder, because they left between the
|
||||
// rollback and the rebuild, answers the generic failure and logs the gate, since
|
||||
// the preview has no radio to choose a disposition with.
|
||||
func (h *OperatorPartialsHandler) refuseUndecidedTierRemoval(w http.ResponseWriter, r *http.Request, ladderID, productID, disposition string, holders int) {
|
||||
pending, _ := h.buildTierRemovalPreview(r.Context(), ladderID, productID)
|
||||
if pending == nil {
|
||||
pending, errMsg := h.buildTierRemovalPreview(r.Context(), ladderID, productID)
|
||||
if errMsg != "" {
|
||||
h.renderPlanLadderTiersPage(w, r, ladderID, "", errMsg)
|
||||
return
|
||||
}
|
||||
if pending == nil || !pending.NeedsDisposition {
|
||||
logTierGateWithoutHolders(r, ladderID, productID)
|
||||
h.renderPlanLadderTiersPage(w, r, ladderID, "", "Failed to remove the tier; nothing was changed.")
|
||||
return
|
||||
}
|
||||
@@ -1101,46 +1156,59 @@ func (h *OperatorPartialsHandler) refuseUndecidedTierRemoval(w http.ResponseWrit
|
||||
planLadderTierRemovalValues(pending.NeedsDisposition, disposition), errs)
|
||||
}
|
||||
|
||||
// tierRemovalFailure logs a removal that did not commit as the step that
|
||||
// failed is logged and returns the sentence the operator reads for it. Three
|
||||
// steps log nothing: the pool locks, the second read of the holders and the
|
||||
// list of the remaining tiers.
|
||||
func (h *OperatorPartialsHandler) tierRemovalFailure(err error) string {
|
||||
const nothingChanged = "Failed to remove the tier; nothing was changed."
|
||||
// logTierGateWithoutHolders logs a removal that found default-sourced holders
|
||||
// and no disposition when the preview built for the answer finds none: they left
|
||||
// between the removal's rollback and the rebuild. The answer is the generic
|
||||
// failure and resubmitting succeeds. The line goes through the request's logger,
|
||||
// so it carries the request id.
|
||||
func logTierGateWithoutHolders(r *http.Request, ladderID, productID string) {
|
||||
logging.FromContext(r.Context()).Error("disposition gate found default-sourced holders that the rebuilt removal preview does not",
|
||||
slog.String("ladder_id", ladderID), slog.String("product_id", productID))
|
||||
}
|
||||
|
||||
// tierRemovalFailure is the sentence the operator reads for a removal that did
|
||||
// not commit. A refusal of the removal's guards (the holder limit, the last
|
||||
// tier of a default ladder) reads as its own sentence. Any other failure is
|
||||
// logged with the step that failed, through the request's logger so the line
|
||||
// carries the request id, and a lock wait or a deadlock reads as the
|
||||
// contention refusal; the steps that reconcile a holder read their sentence
|
||||
// with that request id, so an operator who cannot read the logs can hand it to
|
||||
// the person who can.
|
||||
func (h *OperatorPartialsHandler) tierRemovalFailure(r *http.Request, ladderID, productID string, err error) string {
|
||||
var limit *entitlements.HolderLimitError
|
||||
if errors.As(err, &limit) {
|
||||
return limit.Message
|
||||
}
|
||||
var lastTier *entitlements.LastTierError
|
||||
if errors.As(err, &lastTier) {
|
||||
return lastTier.Message
|
||||
}
|
||||
|
||||
step := entitlements.PlanActStep("")
|
||||
attrs := []any{slog.String("ladder_id", ladderID), slog.String("product_id", productID)}
|
||||
var failed *entitlements.PlanActError
|
||||
if !errors.As(err, &failed) {
|
||||
return nothingChanged
|
||||
if errors.As(err, &failed) {
|
||||
step = failed.Step
|
||||
if failed.PoolID != "" {
|
||||
attrs = append(attrs, slog.String("pool_id", failed.PoolID))
|
||||
}
|
||||
if failed.ProvisionID != "" {
|
||||
attrs = append(attrs, slog.String("provision_id", failed.ProvisionID))
|
||||
}
|
||||
}
|
||||
cause := failed.Err
|
||||
switch failed.Step {
|
||||
case entitlements.StepBegin:
|
||||
h.Logger.Error("failed to begin tier removal tx", slog.Any("error", cause))
|
||||
return tierRemovalRefusal(cause)
|
||||
case entitlements.StepReadHolders:
|
||||
h.Logger.Error("failed to list tier holders for removal", slog.Any("error", cause))
|
||||
case entitlements.StepLockPools:
|
||||
return tierRemovalRefusal(cause)
|
||||
case entitlements.StepDeleteTier:
|
||||
h.Logger.Error("failed to delete tier", slog.Any("error", cause))
|
||||
case entitlements.StepRenumber:
|
||||
h.Logger.Error("failed to renumber tiers after removal", slog.Any("error", cause))
|
||||
attrs = append(attrs, slog.String("step", string(step)), slog.Any("error", err))
|
||||
logging.FromContext(r.Context()).Error("tier removal failed", attrs...)
|
||||
switch step {
|
||||
case entitlements.StepEndIncumbents:
|
||||
h.Logger.Error("tier removal: migrate end failed", slog.String("pool_id", failed.PoolID), slog.Any("error", cause))
|
||||
return tierRemovalRefusalOr(cause, "Removal failed and nothing was changed: could not end a default-sourced position. Details are in the server logs.")
|
||||
return tierRemovalRefusalOr(r, err, "Removal failed and nothing was changed: could not end a default-sourced position")
|
||||
case entitlements.StepAlign:
|
||||
h.Logger.Error("tier removal: align failed", slog.String("provision_id", failed.ProvisionID), slog.Any("error", cause))
|
||||
return tierRemovalRefusalOr(cause, "Removal failed and nothing was changed: could not reconcile a holder's position. Details are in the server logs.")
|
||||
return tierRemovalRefusalOr(r, err, "Removal failed and nothing was changed: could not reconcile a holder's position")
|
||||
case entitlements.StepRestore:
|
||||
h.Logger.Error("tier removal: migrate restore failed", slog.String("pool_id", failed.PoolID), slog.Any("error", cause))
|
||||
return tierRemovalRefusalOr(cause, "Removal failed and nothing was changed: could not apply the current default. Details are in the server logs.")
|
||||
return tierRemovalRefusalOr(r, err, "Removal failed and nothing was changed: could not apply the current default")
|
||||
case entitlements.StepMaterialize:
|
||||
h.Logger.Error("tier removal: materialize failed", slog.String("pool_id", failed.PoolID), slog.Any("error", cause))
|
||||
return tierRemovalRefusalOr(cause, "Removal failed and nothing was changed: could not materialize entitlements. Details are in the server logs.")
|
||||
case entitlements.StepCommit:
|
||||
h.Logger.Error("failed to commit tier removal tx", slog.Any("error", cause))
|
||||
return tierRemovalRefusal(cause)
|
||||
return tierRemovalRefusalOr(r, err, "Removal failed and nothing was changed: could not materialize entitlements")
|
||||
}
|
||||
return nothingChanged
|
||||
return tierRemovalRefusal(err)
|
||||
}
|
||||
|
||||
func (h *OperatorPartialsHandler) renderPlanLaddersPage(w http.ResponseWriter, r *http.Request, success string, errMsg string) {
|
||||
@@ -1554,14 +1622,18 @@ func (h *OperatorPartialsHandler) availableTierProducts(ctx context.Context, lad
|
||||
// lock wait past the timeout or a broken deadlock is a change in progress
|
||||
// and retryable; anything else is the plain failure.
|
||||
func tierRemovalRefusal(err error) string {
|
||||
return tierRemovalRefusalOr(err, "Failed to remove the tier; nothing was changed.")
|
||||
if db.IsLockContention(err) {
|
||||
return entitlements.MessageInProgress
|
||||
}
|
||||
return "Failed to remove the tier; nothing was changed."
|
||||
}
|
||||
|
||||
// tierRemovalRefusalOr keeps the caller's message unless the error is lock
|
||||
// contention, which renders as the retryable refusal.
|
||||
func tierRemovalRefusalOr(err error, message string) string {
|
||||
// tierRemovalRefusalOr answers lock contention with the retryable refusal, and
|
||||
// any other failure with failure ended by the request's reference, so the
|
||||
// person who reads the logs finds every line of it.
|
||||
func tierRemovalRefusalOr(r *http.Request, err error, failure string) string {
|
||||
if db.IsLockContention(err) {
|
||||
return "Another entitlement set change is in progress. Try again."
|
||||
return entitlements.MessageInProgress
|
||||
}
|
||||
return message
|
||||
return failureReference(r, failure)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
package server
|
||||
|
||||
// The sentence a tier removal that did not commit gives the operator (openspec
|
||||
// change plan-act-obligations, task 8.2): the four steps that reconcile a
|
||||
// holder end with the request's reference, a lock wait or a deadlock reads as
|
||||
// the contention refusal, the guards read as their own sentences and log
|
||||
// nothing, and every other failure logs its step and answers that nothing was
|
||||
// changed. The logged line and the sentence name the same request.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/logging"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/middleware"
|
||||
)
|
||||
|
||||
func TestTierRemoval_FailureSentences(t *testing.T) {
|
||||
lockWait := &pgconn.PgError{Code: "55P03"}
|
||||
step := func(s entitlements.PlanActStep, cause error) error {
|
||||
return &entitlements.PlanActError{Step: s, PoolID: "pool-1", ProvisionID: "provision-1", Err: cause}
|
||||
}
|
||||
other := errors.New("driver text")
|
||||
const nothingChanged = "Failed to remove the tier; nothing was changed."
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
want func(id string) string
|
||||
logs bool
|
||||
}{
|
||||
{"ending a default-sourced position", step(entitlements.StepEndIncumbents, other),
|
||||
func(id string) string {
|
||||
return "Removal failed and nothing was changed: could not end a default-sourced position. Reference: " + id + "."
|
||||
}, true},
|
||||
{"aligning a holder's position", step(entitlements.StepAlign, other),
|
||||
func(id string) string {
|
||||
return "Removal failed and nothing was changed: could not reconcile a holder's position. Reference: " + id + "."
|
||||
}, true},
|
||||
{"restoring the current default", step(entitlements.StepRestore, other),
|
||||
func(id string) string {
|
||||
return "Removal failed and nothing was changed: could not apply the current default. Reference: " + id + "."
|
||||
}, true},
|
||||
{"materializing a pool", step(entitlements.StepMaterialize, other),
|
||||
func(id string) string {
|
||||
return "Removal failed and nothing was changed: could not materialize entitlements. Reference: " + id + "."
|
||||
}, true},
|
||||
{"a lock wait while ending a position", step(entitlements.StepEndIncumbents, lockWait),
|
||||
func(string) string { return entitlements.MessageInProgress }, true},
|
||||
{"a lock wait at begin", step(entitlements.StepBegin, lockWait),
|
||||
func(string) string { return entitlements.MessageInProgress }, true},
|
||||
{"deleting the tier", step(entitlements.StepDeleteTier, other),
|
||||
func(string) string { return nothingChanged }, true},
|
||||
{"an error that names no step", other,
|
||||
func(string) string { return nothingChanged }, true},
|
||||
{"the holder limit", &entitlements.HolderLimitError{Message: "limit sentence"},
|
||||
func(string) string { return "limit sentence" }, false},
|
||||
{"the last tier", &entitlements.LastTierError{Message: "last tier sentence"},
|
||||
func(string) string { return "last tier sentence" }, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, logs, id := answerTierRemovalFailure(t, tc.err)
|
||||
if want := tc.want(id); got != want {
|
||||
t.Errorf("sentence = %q, want %q", got, want)
|
||||
}
|
||||
if !tc.logs {
|
||||
if logs != "" {
|
||||
t.Errorf("a refusal logged: %s", logs)
|
||||
}
|
||||
return
|
||||
}
|
||||
wants := []string{"level=ERROR", "tier removal failed", "request_id=" + id, "ladder_id=ladder-1", "product_id=product-1"}
|
||||
var failed *entitlements.PlanActError
|
||||
if errors.As(tc.err, &failed) {
|
||||
stepAttr := string(failed.Step)
|
||||
if strings.ContainsAny(stepAttr, " =\"") {
|
||||
stepAttr = strconv.Quote(stepAttr) // the text handler quotes a value with a space
|
||||
}
|
||||
wants = append(wants, "step="+stepAttr, "pool_id=pool-1", "provision_id=provision-1")
|
||||
}
|
||||
for _, want := range wants {
|
||||
if !strings.Contains(logs, want) {
|
||||
t.Errorf("the log lacks %s: %s", want, logs)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// answerTierRemovalFailure sends err through tierRemovalFailure inside a
|
||||
// request, as the server runs one, and returns the sentence, what the request's
|
||||
// logger wrote and the request's id.
|
||||
func answerTierRemovalFailure(t *testing.T, err error) (sentence, logs, id string) {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
h := &OperatorPartialsHandler{}
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(t.Context())
|
||||
middleware.RequestID()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
scoped := slog.New(slog.NewTextHandler(&buf, nil)).With(slog.String("request_id", middleware.GetRequestID(r.Context())))
|
||||
sentence = h.tierRemovalFailure(r.WithContext(logging.WithContext(r.Context(), scoped)), "ladder-1", "product-1", err)
|
||||
})).ServeHTTP(rec, req)
|
||||
id = rec.Header().Get(middleware.RequestIDHeader)
|
||||
if id == "" {
|
||||
t.Fatalf("the request has no id")
|
||||
}
|
||||
return sentence, buf.String(), id
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
package server_test
|
||||
|
||||
// The tier removal's guards and its preview (openspec change
|
||||
// plan-act-obligations, task 8): the holder limit and the last tier of a
|
||||
// default ladder refuse the commit inside the act and disable "Apply change"
|
||||
// in the preview with the same sentence as the reason; a preview that cannot
|
||||
// read what a guard needs says so, with the request's id, in place of the
|
||||
// preview; and the answer of a removal with no holder left names no positions.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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/organization"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/htmltest"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/world"
|
||||
)
|
||||
|
||||
// The sentences the removal's guards give the fixtures below, as
|
||||
// entitlements.HolderLimitRefusal and LastTierRefusal build them.
|
||||
const (
|
||||
removalLimitSentence = "2 organizations hold this tier; a removal is refused above 1. " +
|
||||
"2 of them hold it through their org type's default; changing that default, or reordering this ladder, with Migrate moves them in the background."
|
||||
removalPreviewForm = `[data-form="operator.plan-ladder.tier.remove.commit"]`
|
||||
)
|
||||
|
||||
// holdersCountUnread, holdersListUnread and tiersWithProductsUnread are the
|
||||
// handler's own queriers with one read failing.
|
||||
type holdersCountUnread struct{ entitlements.Querier }
|
||||
|
||||
func (holdersCountUnread) CountLiveTierHolders(context.Context, entitlements.CountLiveTierHoldersParams) (entitlements.CountLiveTierHoldersRow, error) {
|
||||
return entitlements.CountLiveTierHoldersRow{}, errors.New("holder count unavailable")
|
||||
}
|
||||
|
||||
type holdersListUnread struct{ entitlements.Querier }
|
||||
|
||||
func (holdersListUnread) ListLiveTierHoldersByLadderProduct(context.Context, entitlements.ListLiveTierHoldersByLadderProductParams) ([]entitlements.ListLiveTierHoldersByLadderProductRow, error) {
|
||||
return nil, errors.New("holder list unavailable")
|
||||
}
|
||||
|
||||
// holdersListEmptied and holdersListUndefaulted are the handler's own querier
|
||||
// answering the preview's holder read as it would after the default-sourced
|
||||
// holders left: with no row, or with the rows as held from another source.
|
||||
type holdersListEmptied struct{ entitlements.Querier }
|
||||
|
||||
func (holdersListEmptied) ListLiveTierHoldersByLadderProduct(context.Context, entitlements.ListLiveTierHoldersByLadderProductParams) ([]entitlements.ListLiveTierHoldersByLadderProductRow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type holdersListUndefaulted struct{ entitlements.Querier }
|
||||
|
||||
func (q holdersListUndefaulted) ListLiveTierHoldersByLadderProduct(ctx context.Context, arg entitlements.ListLiveTierHoldersByLadderProductParams) ([]entitlements.ListLiveTierHoldersByLadderProductRow, error) {
|
||||
rows, err := q.Querier.ListLiveTierHoldersByLadderProduct(ctx, arg)
|
||||
for i := range rows {
|
||||
rows[i].DefaultSourced = false
|
||||
}
|
||||
return rows, err
|
||||
}
|
||||
|
||||
type tiersWithProductsUnread struct{ billing.Querier }
|
||||
|
||||
func (tiersWithProductsUnread) ListTiersByLadderWithProducts(context.Context, string) ([]billing.ListTiersByLadderWithProductsRow, error) {
|
||||
return nil, errors.New("tiers unavailable")
|
||||
}
|
||||
|
||||
// removalWorld is a ladder of tiers whose rank 0 two organizations of a
|
||||
// defaulting type hold through the floor.
|
||||
type removalWorld struct {
|
||||
w *world.World
|
||||
operator world.Person
|
||||
basic world.Product
|
||||
plus world.Product
|
||||
main world.Ladder
|
||||
// orgType is the display name of the type that defaults to the ladder.
|
||||
orgType string
|
||||
}
|
||||
|
||||
func newRemovalWorld(t *testing.T, twoTiers bool) removalWorld {
|
||||
t.Helper()
|
||||
w := world.New(t, dbtest.Open(t))
|
||||
r := removalWorld{w: w, operator: w.Person("Operator")}
|
||||
r.basic, r.plus = w.Product("Basic"), w.Product("Plus")
|
||||
r.main = w.Ladder("Main", r.basic)
|
||||
if twoTiers {
|
||||
w.AddTier(r.main, r.plus)
|
||||
}
|
||||
r.orgType = "rm " + w.Sfx
|
||||
orgType := w.OrgType("rm")
|
||||
w.DefaultLadder(orgType, r.main)
|
||||
for _, name := range []string{"First", "Second"} {
|
||||
w.Floor(w.Org(name, orgType, r.operator))
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// preview sends the removal preview of product through h, as the server sends
|
||||
// a request.
|
||||
func (r removalWorld) preview(h *otcHarness, product world.Product) *httptest.ResponseRecorder {
|
||||
return h.postPath(h.handler.PreviewPlanLadderTierRemoval, map[string]string{"ladderID": r.main.ID, "productID": product.ID}, nil)
|
||||
}
|
||||
|
||||
// commit sends the removal of product with the disposition chosen.
|
||||
func (r removalWorld) commit(h *otcHarness, product world.Product, disposition string) *httptest.ResponseRecorder {
|
||||
return h.postPath(h.handler.CommitPlanLadderTierRemoval, map[string]string{"ladderID": r.main.ID, "productID": product.ID},
|
||||
url.Values{"needs_disposition": {"true"}, "default_disposition": {disposition}})
|
||||
}
|
||||
|
||||
func (r removalWorld) tierExists(t *testing.T, product world.Product) bool {
|
||||
t.Helper()
|
||||
return trTierExists(t, r.w.DB, r.main.ID, product.ID)
|
||||
}
|
||||
|
||||
func (r removalWorld) acts(t *testing.T) int {
|
||||
t.Helper()
|
||||
return otcScalar(t, r.w.DB, `SELECT count(*) FROM core.plan_act_details WHERE plan_ladder_id = $1`, r.main.ID)
|
||||
}
|
||||
|
||||
// A preview of a removal the act would refuse for the holder limit renders
|
||||
// "Apply change" disabled, through the disabled-control part, with the limit's
|
||||
// sentence as its reason, and renders no submit button.
|
||||
func TestTierRemoval_PreviewDisablesApplyChangeAboveTheHolderLimit(t *testing.T) {
|
||||
r := newRemovalWorld(t, true)
|
||||
h := newOtcHarnessActs(t, r.w.DB, r.operator.ID, entitlements.PlanActConfig{HolderCap: 1})
|
||||
|
||||
rec := r.preview(h, r.basic)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
form := htmltest.Parse(t, rec.Body.String()).One(t, removalPreviewForm)
|
||||
form.None(t, `button[type="submit"]`)
|
||||
if got := form.One(t, "button[disabled]").Text(); got != "Apply change" {
|
||||
t.Errorf("the disabled control reads %q, want Apply change", got)
|
||||
}
|
||||
if got := form.One(t, "#apply-change-reason-removal").Text(); got != removalLimitSentence {
|
||||
t.Errorf("the reason reads %q, want %q", got, removalLimitSentence)
|
||||
}
|
||||
}
|
||||
|
||||
// A preview of the only tier of a default ladder renders the same control with
|
||||
// the last-tier sentence, the guard the commit applies under either
|
||||
// disposition.
|
||||
func TestTierRemoval_PreviewDisablesApplyChangeForTheLastTierOfADefaultLadder(t *testing.T) {
|
||||
r := newRemovalWorld(t, false)
|
||||
h := newOtcHarness(t, r.w.DB, r.operator.ID)
|
||||
|
||||
rec := r.preview(h, r.basic)
|
||||
|
||||
form := htmltest.Parse(t, rec.Body.String()).One(t, removalPreviewForm)
|
||||
form.None(t, `button[type="submit"]`)
|
||||
want := entitlements.LastTierRefusal([]billing.PlanLadderTier{{ProductID: r.basic.ID}}, r.basic.ID, []string{r.orgType})
|
||||
if got := form.One(t, "#apply-change-reason-removal").Text(); got != want || want == "" {
|
||||
t.Errorf("the reason reads %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A removal nothing refuses keeps its enabled "Apply change".
|
||||
func TestTierRemoval_PreviewKeepsApplyChangeWhenNothingRefuses(t *testing.T) {
|
||||
r := newRemovalWorld(t, true)
|
||||
h := newOtcHarness(t, r.w.DB, r.operator.ID)
|
||||
|
||||
rec := r.preview(h, r.basic)
|
||||
|
||||
form := htmltest.Parse(t, rec.Body.String()).One(t, removalPreviewForm)
|
||||
if got := form.One(t, `button[type="submit"]`).Text(); got != "Apply change" {
|
||||
t.Errorf("the commit reads %q, want Apply change", got)
|
||||
}
|
||||
form.None(t, "#apply-change-reason-removal")
|
||||
}
|
||||
|
||||
// A preview that cannot read what it needs shows the failure sentence with the
|
||||
// request's id in place of the preview, and the log line of the failed read
|
||||
// carries the same id. Nothing it could not evaluate is offered.
|
||||
func TestTierRemoval_PreviewShowsAFailedReadInPlaceOfThePreview(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
opts func(db *sql.DB) otcOptions
|
||||
sentence string
|
||||
driver string
|
||||
}{
|
||||
{"org types", func(db *sql.DB) otcOptions {
|
||||
return otcOptions{orgQ: orgTypesUnread{Querier: organization.New(db)}}
|
||||
}, "Failed to preview the removal. Reference: ", "org types unavailable"},
|
||||
{"tiers", func(db *sql.DB) otcOptions {
|
||||
return otcOptions{billingQ: tiersWithProductsUnread{Querier: billing.New(db)}}
|
||||
}, "Failed to preview the removal. Reference: ", "tiers unavailable"},
|
||||
{"holder count", func(db *sql.DB) otcOptions {
|
||||
return otcOptions{entitlementsQ: holdersCountUnread{Querier: entitlements.New(db)}}
|
||||
}, "Failed to load the tier's holders. Reference: ", "holder count unavailable"},
|
||||
{"holder list", func(db *sql.DB) otcOptions {
|
||||
return otcOptions{entitlementsQ: holdersListUnread{Querier: entitlements.New(db)}}
|
||||
}, "Failed to load the tier's holders. Reference: ", "holder list unavailable"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := newRemovalWorld(t, true)
|
||||
h := newOtcHarnessWith(t, r.w.DB, r.operator.ID, tc.opts(r.w.DB))
|
||||
|
||||
rec := r.preview(h, r.basic)
|
||||
|
||||
body := rec.Body.String()
|
||||
id := referenceIn(t, body)
|
||||
page := htmltest.Parse(t, body)
|
||||
if got := page.Text(); !strings.Contains(got, tc.sentence+id+".") {
|
||||
t.Errorf("the page lacks %q: %.500s", tc.sentence+id+".", got)
|
||||
}
|
||||
page.None(t, removalPreviewForm)
|
||||
logs := h.logs.String()
|
||||
for _, want := range []string{"level=ERROR", "request_id=" + id, tc.driver} {
|
||||
if !strings.Contains(logs, want) {
|
||||
t.Errorf("the log lacks %s: %s", want, logs)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The commit applies the same guards inside the act: above the limit, and the
|
||||
// last tier of a default ladder under keep as under migrate, it answers the
|
||||
// sentence the preview gave and changes nothing.
|
||||
func TestTierRemoval_CommitRefusesWhatThePreviewDisables(t *testing.T) {
|
||||
t.Run("holder limit", func(t *testing.T) {
|
||||
r := newRemovalWorld(t, true)
|
||||
h := newOtcHarnessActs(t, r.w.DB, r.operator.ID, entitlements.PlanActConfig{HolderCap: 1})
|
||||
|
||||
rec := r.commit(h, r.basic, "migrate")
|
||||
|
||||
if got := htmltest.Parse(t, rec.Body.String()).Text(); !strings.Contains(got, removalLimitSentence) {
|
||||
t.Errorf("the answer lacks %q: %.500s", removalLimitSentence, got)
|
||||
}
|
||||
if !r.tierExists(t, r.basic) || r.acts(t) != 0 {
|
||||
t.Errorf("the refused removal changed the ladder: tier present %v, %d acts", r.tierExists(t, r.basic), r.acts(t))
|
||||
}
|
||||
})
|
||||
for _, disposition := range []string{"keep", "migrate"} {
|
||||
t.Run("last tier under "+disposition, func(t *testing.T) {
|
||||
r := newRemovalWorld(t, false)
|
||||
h := newOtcHarness(t, r.w.DB, r.operator.ID)
|
||||
|
||||
rec := r.commit(h, r.basic, disposition)
|
||||
|
||||
if got := htmltest.Parse(t, rec.Body.String()).Text(); !strings.Contains(got, "This ladder's only remaining tier;") || !strings.Contains(got, "so the removal is refused.") {
|
||||
t.Errorf("the answer lacks the last-tier sentence: %.500s", got)
|
||||
}
|
||||
if !r.tierExists(t, r.basic) || r.acts(t) != 0 {
|
||||
t.Errorf("the refused removal changed the ladder: tier present %v, %d acts", r.tierExists(t, r.basic), r.acts(t))
|
||||
}
|
||||
if n := otcScalar(t, r.w.DB, `SELECT count(*) FROM core.pool_provision_ladders WHERE plan_ladder_id = $1 AND status = 'active'`, r.main.ID); n != 2 {
|
||||
t.Errorf("%d active positions after the refused removal, want 2", n)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A removal of a tier whose holders left between the preview and the commit
|
||||
// answers "{Product} removed from the ladder." with no clause counting what it
|
||||
// reconciled.
|
||||
func TestTierRemoval_WithNoHolderLeftAnswersWithoutACount(t *testing.T) {
|
||||
r := newRemovalWorld(t, true)
|
||||
h := newOtcHarness(t, r.w.DB, r.operator.ID)
|
||||
|
||||
rec := h.postPath(h.handler.CommitPlanLadderTierRemoval, map[string]string{"ladderID": r.main.ID, "productID": r.plus.ID}, nil)
|
||||
|
||||
if want := `"showSuccessToast":"` + r.plus.Name + ` removed from the ladder."`; !strings.Contains(rec.Header().Get("HX-Trigger"), want) {
|
||||
t.Errorf("the toast reads %q, want it to contain %q", rec.Header().Get("HX-Trigger"), want)
|
||||
}
|
||||
if r.tierExists(t, r.plus) {
|
||||
t.Errorf("the tier is still on the ladder")
|
||||
}
|
||||
}
|
||||
|
||||
// A migrate removal's floor grant names the act in the grants ledger: the
|
||||
// handler's answer and the grant agree on one act.
|
||||
func TestTierRemoval_MigrateFloorGrantNamesTheAct(t *testing.T) {
|
||||
r := newRemovalWorld(t, true)
|
||||
h := newOtcHarness(t, r.w.DB, r.operator.ID)
|
||||
|
||||
rec := r.commit(h, r.basic, "migrate")
|
||||
|
||||
if !strings.Contains(rec.Header().Get("HX-Trigger"), "showSuccessToast") {
|
||||
t.Fatalf("the removal failed: %s %.300s", rec.Header().Get("HX-Trigger"), rec.Body)
|
||||
}
|
||||
if n := otcScalar(t, r.w.DB, `SELECT count(*) FROM core.grants g JOIN core.plan_act_details d ON d.act_id = g.act_id
|
||||
WHERE d.plan_ladder_id = $1 AND d.cause = 'tier_removed' AND g.product_id = $2 AND g.grant_reason = 'default'`, r.main.ID, r.plus.ID); n != 2 {
|
||||
t.Errorf("%d floor grants of the promoted tier name the removal's act, want 2", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A removal refused for want of a disposition rebuilds the preview after the
|
||||
// act's rollback. When the rebuild cannot read what it needs, the answer is the
|
||||
// preview's own read failure with the request's id, logged once with the same
|
||||
// id, whichever path asked for the rebuild: a submitted disposition outside keep
|
||||
// and migrate, or none at all. The tier stays.
|
||||
func TestTierRemoval_ARebuiltPreviewThatCannotReadAnswersItsOwnFailure(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
form url.Values
|
||||
opts func(db *sql.DB) otcOptions
|
||||
sentence string
|
||||
driver string
|
||||
}{
|
||||
{"no disposition, holder list", nil, func(db *sql.DB) otcOptions {
|
||||
return otcOptions{entitlementsQ: holdersListUnread{Querier: entitlements.New(db)}}
|
||||
}, "Failed to load the tier's holders. Reference: ", "holder list unavailable"},
|
||||
{"no disposition, tiers", nil, func(db *sql.DB) otcOptions {
|
||||
return otcOptions{billingQ: tiersWithProductsUnread{Querier: billing.New(db)}}
|
||||
}, "Failed to preview the removal. Reference: ", "tiers unavailable"},
|
||||
{"refused disposition, holder list", url.Values{"needs_disposition": {"true"}, "default_disposition": {"discard"}}, func(db *sql.DB) otcOptions {
|
||||
return otcOptions{entitlementsQ: holdersListUnread{Querier: entitlements.New(db)}}
|
||||
}, "Failed to load the tier's holders. Reference: ", "holder list unavailable"},
|
||||
{"refused disposition, tiers", url.Values{"needs_disposition": {"true"}, "default_disposition": {"discard"}}, func(db *sql.DB) otcOptions {
|
||||
return otcOptions{billingQ: tiersWithProductsUnread{Querier: billing.New(db)}}
|
||||
}, "Failed to preview the removal. Reference: ", "tiers unavailable"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := newRemovalWorld(t, true)
|
||||
h := newOtcHarnessWith(t, r.w.DB, r.operator.ID, tc.opts(r.w.DB))
|
||||
|
||||
rec := h.postPath(h.handler.CommitPlanLadderTierRemoval, map[string]string{"ladderID": r.main.ID, "productID": r.basic.ID}, tc.form)
|
||||
|
||||
body := rec.Body.String()
|
||||
id := referenceIn(t, body)
|
||||
if got := htmltest.Parse(t, body).Text(); !strings.Contains(got, tc.sentence+id+".") {
|
||||
t.Errorf("the page lacks %q: %.500s", tc.sentence+id+".", got)
|
||||
}
|
||||
logs := h.logs.String()
|
||||
if n := strings.Count(logs, "for removal preview"); n != 1 || !strings.Contains(logs, tc.driver) {
|
||||
t.Errorf("the preview's failed read is logged %d times, want once with %q: %s", n, tc.driver, logs)
|
||||
}
|
||||
for _, want := range []string{"level=ERROR", "request_id=" + id} {
|
||||
if !strings.Contains(logs, want) {
|
||||
t.Errorf("the log lacks %s: %s", want, logs)
|
||||
}
|
||||
}
|
||||
if !r.tierExists(t, r.basic) || r.acts(t) != 0 {
|
||||
t.Errorf("the refused removal changed the ladder: tier present %v, %d acts", r.tierExists(t, r.basic), r.acts(t))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A removal that found default-sourced holders and no disposition, whose
|
||||
// rebuilt preview finds none because they left between the rollback and the
|
||||
// rebuild, answers the generic failure and logs the gate with the ladder, the
|
||||
// product and the request's id. The preview has no radio to choose with.
|
||||
func TestTierRemoval_AGateWithoutHoldersInTheRebuiltPreviewIsLogged(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
q func(entitlements.Querier) entitlements.Querier
|
||||
}{
|
||||
{"no holder", func(q entitlements.Querier) entitlements.Querier { return holdersListEmptied{Querier: q} }},
|
||||
{"no default-sourced holder", func(q entitlements.Querier) entitlements.Querier { return holdersListUndefaulted{Querier: q} }},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := newRemovalWorld(t, true)
|
||||
h := newOtcHarnessWith(t, r.w.DB, r.operator.ID, otcOptions{entitlementsQ: tc.q(entitlements.New(r.w.DB))})
|
||||
|
||||
rec := h.postPath(h.handler.CommitPlanLadderTierRemoval, map[string]string{"ladderID": r.main.ID, "productID": r.basic.ID}, nil)
|
||||
|
||||
const sentence = "Failed to remove the tier; nothing was changed."
|
||||
if got := htmltest.Parse(t, rec.Body.String()).Text(); !strings.Contains(got, sentence) {
|
||||
t.Errorf("the page lacks %q: %.500s", sentence, got)
|
||||
}
|
||||
id := rec.Header().Get("X-Request-ID")
|
||||
logs := h.logs.String()
|
||||
for _, want := range []string{"level=ERROR", "request_id=" + id, "ladder_id=" + r.main.ID, "product_id=" + r.basic.ID} {
|
||||
if id == "" || !strings.Contains(logs, want) {
|
||||
t.Errorf("the log lacks %q (request id %q): %s", want, id, logs)
|
||||
}
|
||||
}
|
||||
if n := strings.Count(logs, "level=ERROR"); n != 1 {
|
||||
t.Errorf("%d error lines, want the gate's alone: %s", n, logs)
|
||||
}
|
||||
if !r.tierExists(t, r.basic) || r.acts(t) != 0 {
|
||||
t.Errorf("the refused removal changed the ladder: tier present %v, %d acts", r.tierExists(t, r.basic), r.acts(t))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,8 @@ type actFixture struct {
|
||||
// refuse names the statements the handler's queriers and the act's own
|
||||
// refuse.
|
||||
refuse actRefusals
|
||||
// holderCap replaces the holder limit of a tier add when it is set.
|
||||
// holderCap replaces the holder limit of a tier add and a tier removal when
|
||||
// it is set.
|
||||
holderCap int64
|
||||
// limits are what a request spends on a plan act's listing and moves; the
|
||||
// zero value is production's. A test whose waits a lock queue decides sets
|
||||
@@ -797,6 +798,15 @@ var planActScenarios = []struct {
|
||||
f.w.DefaultLadder(u, broken)
|
||||
return f.act(actRemoveTier, actDisposition("migrate"), actOnTier(main, basic)...)
|
||||
}},
|
||||
{"remove-above-the-limit", 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)
|
||||
f.floored("First", t)
|
||||
f.floored("Second", t)
|
||||
f.holderCap = 1
|
||||
return f.act(actRemoveTier, actDisposition("migrate"), actOnTier(main, basic)...)
|
||||
}},
|
||||
{"remove-needs-disposition", func(f *actFixture) actGolden {
|
||||
basic, plus := f.product("Basic", 1), f.product("Plus", 2)
|
||||
main := f.ladder("Main", basic, plus)
|
||||
@@ -816,6 +826,13 @@ var planActScenarios = []struct {
|
||||
f.refuse.productName = true
|
||||
return f.act(actRemoveTier, actDisposition("keep"), actOnTier(main, basic)...)
|
||||
}},
|
||||
{"remove-org-types-unread", 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.actOrgTypes = true
|
||||
return f.act(actRemoveTier, actDisposition("migrate"), actOnTier(main, basic)...)
|
||||
}},
|
||||
{"remove-begin-fails", func(f *actFixture) actGolden {
|
||||
basic, plus := f.product("Basic", 1), f.product("Plus", 2)
|
||||
main := f.ladder("Main", basic, plus)
|
||||
|
||||
+8
-8
@@ -38,12 +38,12 @@
|
||||
"remove": {
|
||||
"answer": [
|
||||
"status 200",
|
||||
"Hx-Trigger showErrorToast: Another entitlement set change is in progress. Try again.",
|
||||
"[role=\"alert\"]: Another entitlement set change is in progress. Try again."
|
||||
"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 removal tx\" error=\"take materialization rendezvous: ERROR: canceling statement due to lock timeout (SQLSTATE 55P03)\""
|
||||
"level=ERROR msg=\"tier removal failed\" request_id=<uuid:4> ladder_id=<ladder:Main> product_id=<product:Basic> step=begin error=\"begin: take materialization rendezvous: ERROR: canceling statement due to lock timeout (SQLSTATE 55P03)\""
|
||||
]
|
||||
},
|
||||
"reorder": {
|
||||
@@ -54,7 +54,7 @@
|
||||
],
|
||||
"checked": [],
|
||||
"logs": [
|
||||
"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>"
|
||||
"level=WARN msg=\"reorder refused for a change in progress\" request_id=<uuid:5> 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:5> | <org:Holder> | <product:Basic> | default | none | none | | active | none"
|
||||
"<uuid:6> | <org:Holder> | <product:Basic> | default | none | none | | active | none"
|
||||
],
|
||||
"provisions": [
|
||||
"provision | pool | grant_id | status",
|
||||
"<uuid:6> | <pool:Holder> | <uuid:5> | active"
|
||||
"<uuid:7> | <pool:Holder> | <uuid:6> | active"
|
||||
],
|
||||
"positions": [
|
||||
"provision | ladder | product | status | ended",
|
||||
"<uuid:6> | <ladder:Main> | <product:Basic> | active | false"
|
||||
"<uuid:7> | <ladder:Main> | <product:Basic> | active | false"
|
||||
],
|
||||
"transitions": [
|
||||
"pool | provision | ladder | from_rank | to_rank | transition_type | actor_type | actor | reason",
|
||||
"<pool:Holder> | <uuid:6> | <ladder:Main> | - | 0 | initiate | system | none | test fixture"
|
||||
"<pool:Holder> | <uuid:7> | <ladder:Main> | - | 0 | initiate | system | none | test fixture"
|
||||
],
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
|
||||
Vendored
+5
-3
@@ -28,7 +28,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:Gold> | default | none | none | | active | none"
|
||||
"<uuid:2> | <org:Holder> | <product:Gold> | default | none | none | | active | <act:2>"
|
||||
],
|
||||
"provisions": [
|
||||
"provision | pool | grant_id | status",
|
||||
@@ -52,11 +52,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> | tier_removed | 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> | tier_removed | <ladder:Main> | Main | <product:Basic> | Basic | none | none | none | none | none | none | migrate | none"
|
||||
],
|
||||
"reached_org_types": [
|
||||
"act | org_type | org_type_name | last_org",
|
||||
|
||||
Vendored
+4
-2
@@ -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> | tier_removed | 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> | tier_removed | <ladder:Main> | Main | <product:Basic> | Basic | none | none | none | none | none | none | none | none"
|
||||
],
|
||||
"reached_org_types": [
|
||||
"act | org_type | org_type_name | last_org",
|
||||
|
||||
+8
-6
@@ -28,8 +28,8 @@
|
||||
"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:3> | <org:Holder> | <product:Plus> | legacy | <person:Operator> | <uuid:2> | Grandfathered: org-type default change (<orgtype:T>) | active | <act:1>"
|
||||
"<uuid:2> | <org:Holder> | <product:Plus> | default | none | none | | active | <act:1>",
|
||||
"<uuid:3> | <org:Holder> | <product:Plus> | legacy | <person:Operator> | <uuid:2> | Grandfathered: org-type default change (<orgtype:T>) | active | <act:2>"
|
||||
],
|
||||
"provisions": [
|
||||
"provision | pool | grant_id | status",
|
||||
@@ -57,22 +57,24 @@
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id",
|
||||
"<act:1> | org_type_default_changed | person | <person:Operator> | none | <uuid:7>"
|
||||
"<act:1> | tier_removed | person | <person:Operator> | none | <uuid:7>",
|
||||
"<act:2> | org_type_default_changed | person | <person:Operator> | none | <uuid:8>"
|
||||
],
|
||||
"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> | tier_removed | <ladder:Main> | Main | <product:Basic> | Basic | none | none | none | none | none | none | migrate | none",
|
||||
"<act:2> | org_type_default_changed | <ladder:Next> | Next | <product:Gold> | Gold | none | none | <orgtype:T> | T | <ladder:Main> | Main | grandfather | none"
|
||||
],
|
||||
"reached_org_types": [
|
||||
"act | org_type | org_type_name | last_org",
|
||||
"<act:1> | <orgtype:T> | T | <org:Holder>"
|
||||
"<act:2> | <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:1> | <orgtype:T> | <org:Holder> | <pool:Holder> | settled | grandfathered | 0 | none | none | none"
|
||||
"<act:2> | <orgtype:T> | <org:Holder> | <pool:Holder> | settled | grandfathered | 0 | none | none | none"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+8
-6
@@ -28,8 +28,8 @@
|
||||
"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:3> | <org:Holder> | <product:Plus> | legacy | <person:Operator> | <uuid:2> | Grandfathered: org-type default change (<orgtype:T>) | active | <act:1>"
|
||||
"<uuid:2> | <org:Holder> | <product:Plus> | default | none | none | | active | <act:1>",
|
||||
"<uuid:3> | <org:Holder> | <product:Plus> | legacy | <person:Operator> | <uuid:2> | Grandfathered: org-type default change (<orgtype:T>) | active | <act:2>"
|
||||
],
|
||||
"provisions": [
|
||||
"provision | pool | grant_id | status",
|
||||
@@ -57,22 +57,24 @@
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id",
|
||||
"<act:1> | org_type_default_changed | person | <person:Operator> | none | <uuid:7>"
|
||||
"<act:1> | tier_removed | person | <person:Operator> | none | <uuid:7>",
|
||||
"<act:2> | org_type_default_changed | person | <person:Operator> | none | <uuid:8>"
|
||||
],
|
||||
"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> | tier_removed | <ladder:Main> | Main | <product:Basic> | Basic | none | none | none | none | none | none | migrate | none",
|
||||
"<act:2> | org_type_default_changed | <ladder:Next> | Next | <product:Gold> | Gold | none | none | <orgtype:T> | T | <ladder:Main> | Main | grandfather | none"
|
||||
],
|
||||
"reached_org_types": [
|
||||
"act | org_type | org_type_name | last_org",
|
||||
"<act:1> | <orgtype:T> | T | <org:Holder>"
|
||||
"<act:2> | <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:1> | <orgtype:T> | <org:Holder> | <pool:Holder> | settled | grandfathered | 0 | none | none | none"
|
||||
"<act:2> | <orgtype:T> | <org:Holder> | <pool:Holder> | settled | grandfathered | 0 | none | none | none"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"answer": [
|
||||
"status 200",
|
||||
"Hx-Trigger showErrorToast: 2 organizations hold this tier; a removal is refused above 1. 2 of them hold it through their org type's default; changing that default, or reordering this ladder, with Migrate moves them in the background.",
|
||||
"[role=\"alert\"]: 2 organizations hold this tier; a removal is refused above 1. 2 of them hold it through their org type's default; changing that default, or reordering this ladder, with Migrate moves them in the background."
|
||||
],
|
||||
"checked": [],
|
||||
"logs": [],
|
||||
"statements": [
|
||||
"begin",
|
||||
"SET LOCAL lock_timeout = '5s'",
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
|
||||
"rollback",
|
||||
"begin isolation level repeatable read read only",
|
||||
"commit"
|
||||
],
|
||||
"rows": {
|
||||
"tiers": [
|
||||
"ladder | rank | product",
|
||||
"<ladder:Main> | 0 | <product:Basic>",
|
||||
"<ladder:Main> | 1 | <product:Plus>"
|
||||
],
|
||||
"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:First> | <product:Basic> | default | none | none | | active | none",
|
||||
"<uuid:2> | <org:Second> | <product:Basic> | default | none | none | | active | none"
|
||||
],
|
||||
"provisions": [
|
||||
"provision | pool | grant_id | status",
|
||||
"<uuid:3> | <pool:First> | <uuid:1> | active",
|
||||
"<uuid:4> | <pool:Second> | <uuid:2> | active"
|
||||
],
|
||||
"positions": [
|
||||
"provision | ladder | product | status | ended",
|
||||
"<uuid:3> | <ladder:Main> | <product:Basic> | active | false",
|
||||
"<uuid:4> | <ladder:Main> | <product:Basic> | active | false"
|
||||
],
|
||||
"transitions": [
|
||||
"pool | provision | ladder | from_rank | to_rank | transition_type | actor_type | actor | reason",
|
||||
"<pool:First> | <uuid:3> | <ladder:Main> | - | 0 | initiate | system | none | test fixture",
|
||||
"<pool:Second> | <uuid:4> | <ladder:Main> | - | 0 | initiate | system | none | test fixture"
|
||||
],
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
"<pool:First> | plan_golden_seats | 1",
|
||||
"<pool:Second> | plan_golden_seats | 1"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
],
|
||||
"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"
|
||||
],
|
||||
"reached_org_types": [
|
||||
"act | org_type | org_type_name | last_org"
|
||||
],
|
||||
"orgs_without_pool": [
|
||||
"act | org_type | org"
|
||||
],
|
||||
"obligations": [
|
||||
"act | org_type | org | pool | status | outcome | attempts | error_step | superseded_by | requeued_by"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
],
|
||||
"checked": [],
|
||||
"logs": [
|
||||
"level=ERROR msg=\"failed to begin tier removal tx\" error=\"sql: database is closed\"",
|
||||
"level=ERROR msg=\"tier removal failed\" request_id=<uuid:1> ladder_id=<ladder:Main> product_id=<product:Basic> step=begin 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": [],
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"SET LOCAL lock_timeout = '5s'",
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
|
||||
"LockResourcePool",
|
||||
"RecordPlanAct",
|
||||
"DeleteTier",
|
||||
"UpdateTierRank",
|
||||
"UpdateTierRank",
|
||||
@@ -53,10 +54,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> | tier_removed | 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> | tier_removed | <ladder:Main> | Main | <product:Basic> | Basic | none | none | none | none | none | none | keep | none"
|
||||
],
|
||||
"reached_org_types": [
|
||||
"act | org_type | org_type_name | last_org"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"answer": [
|
||||
"status 200",
|
||||
"Hx-Trigger showSuccessToast: Basic removed from the ladder: 1 position(s) on this ladder ended; delivery continues."
|
||||
"Hx-Trigger showErrorToast: This ladder's only remaining tier; T defaults new signups to it. Deleting it would leave those signups without a starting plan, so the removal is refused.",
|
||||
"[role=\"alert\"]: This ladder's only remaining tier; T defaults new signups to it. Deleting it would leave those signups without a starting plan, so the removal is refused."
|
||||
],
|
||||
"checked": [],
|
||||
"logs": [],
|
||||
@@ -9,20 +10,14 @@
|
||||
"begin",
|
||||
"SET LOCAL lock_timeout = '5s'",
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
|
||||
"LockResourcePool",
|
||||
"DeleteTier",
|
||||
"SELECT plan_ladder_id, action FROM core.align_conferral_shape($1, $2, $3, $4, $5)",
|
||||
"SELECT core.assert_rendezvous(false)",
|
||||
"DeleteContributionsByProvisionID",
|
||||
"CreateNumericEntitlementContribution",
|
||||
"UpdateNumericEntitlementLimit",
|
||||
"commit",
|
||||
"rollback",
|
||||
"begin isolation level repeatable read read only",
|
||||
"commit"
|
||||
],
|
||||
"rows": {
|
||||
"tiers": [
|
||||
"ladder | rank | product"
|
||||
"ladder | rank | product",
|
||||
"<ladder:Main> | 0 | <product:Basic>"
|
||||
],
|
||||
"defaults": [
|
||||
"org_type | default_ladder",
|
||||
@@ -38,12 +33,11 @@
|
||||
],
|
||||
"positions": [
|
||||
"provision | ladder | product | status | ended",
|
||||
"<uuid:2> | <ladder:Main> | <product:Basic> | ended | true"
|
||||
"<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:2> | <ladder:Main> | - | 0 | initiate | system | none | test fixture",
|
||||
"<pool:Holder> | <uuid:2> | <ladder:Main> | - | - | end | operator | <person:Operator> | tier removal (Basic from Main)"
|
||||
"<pool:Holder> | <uuid:2> | <ladder:Main> | - | 0 | initiate | system | none | test fixture"
|
||||
],
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
{
|
||||
"answer": [
|
||||
"status 200",
|
||||
"Hx-Trigger showErrorToast: Removal failed and nothing was changed: could not apply the current default. Details are in the server logs.",
|
||||
"[role=\"alert\"]: Removal failed and nothing was changed: could not apply the current default. Details are in the server logs."
|
||||
"Hx-Trigger showErrorToast: This ladder's only remaining tier; T defaults new signups to it. Deleting it would leave those signups without a starting plan, so the removal is refused.",
|
||||
"[role=\"alert\"]: This ladder's only remaining tier; T defaults new signups to it. Deleting it would leave those signups without a starting plan, so the removal is refused."
|
||||
],
|
||||
"checked": [],
|
||||
"logs": [
|
||||
"level=ERROR msg=\"tier removal: migrate restore failed\" pool_id=<pool:Holder> error=\"reapply-defaults: resolve rank-0 tier of ladder <ladder:Main>: sql: no rows in result set\""
|
||||
],
|
||||
"logs": [],
|
||||
"statements": [
|
||||
"begin",
|
||||
"SET LOCAL lock_timeout = '5s'",
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
|
||||
"LockResourcePool",
|
||||
"DeleteTier",
|
||||
"SELECT core.end_conferral($1, $2, $3, $4, $5, $6, $7, $8)",
|
||||
"rollback",
|
||||
"begin isolation level repeatable read read only",
|
||||
"commit"
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"SET LOCAL lock_timeout = '5s'",
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
|
||||
"LockResourcePool",
|
||||
"RecordPlanAct",
|
||||
"DeleteTier",
|
||||
"UpdateTierRank",
|
||||
"UpdateTierRank",
|
||||
@@ -59,10 +60,12 @@
|
||||
"<pool:Holder> | plan_golden_seats | 11"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
"act | cause | actor_type | actor | note | request_id",
|
||||
"<act:1> | tier_removed | 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> | tier_removed | <ladder:Main> | Main | <product:Basic> | Basic | none | none | none | none | none | none | migrate | none"
|
||||
],
|
||||
"reached_org_types": [
|
||||
"act | org_type | org_type_name | last_org"
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"SET LOCAL lock_timeout = '5s'",
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
|
||||
"LockResourcePool",
|
||||
"RecordPlanAct",
|
||||
"DeleteTier",
|
||||
"UpdateTierRank",
|
||||
"UpdateTierRank",
|
||||
@@ -40,7 +41,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",
|
||||
@@ -63,10 +64,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> | tier_removed | 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> | tier_removed | <ladder:Main> | Main | <product:Basic> | Basic | none | none | none | none | none | none | migrate | none"
|
||||
],
|
||||
"reached_org_types": [
|
||||
"act | org_type | org_type_name | last_org"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"answer": [
|
||||
"status 200",
|
||||
"Hx-Trigger showSuccessToast: Plus removed from the ladder: ."
|
||||
"Hx-Trigger showSuccessToast: Plus removed from the ladder."
|
||||
],
|
||||
"checked": [],
|
||||
"logs": [],
|
||||
@@ -9,6 +9,7 @@
|
||||
"begin",
|
||||
"SET LOCAL lock_timeout = '5s'",
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
|
||||
"RecordPlanAct",
|
||||
"DeleteTier",
|
||||
"UpdateTierRank",
|
||||
"UpdateTierRank",
|
||||
@@ -41,10 +42,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> | tier_removed | 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> | tier_removed | <ladder:Main> | Main | <product:Plus> | Plus | none | none | none | none | none | none | none | none"
|
||||
],
|
||||
"reached_org_types": [
|
||||
"act | org_type | org_type_name | last_org"
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"answer": [
|
||||
"status 200",
|
||||
"Hx-Trigger showErrorToast: Failed to remove the tier; nothing was changed.",
|
||||
"[role=\"alert\"]: Failed to remove the tier; nothing was changed."
|
||||
],
|
||||
"checked": [],
|
||||
"logs": [
|
||||
"level=ERROR msg=\"tier removal failed\" request_id=<uuid:1> ladder_id=<ladder:Main> product_id=<product:Basic> step=\"read the organization types\" error=\"read the organization types: read failed\""
|
||||
],
|
||||
"statements": [
|
||||
"begin",
|
||||
"SET LOCAL lock_timeout = '5s'",
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
|
||||
"rollback",
|
||||
"begin isolation level repeatable read read only",
|
||||
"commit"
|
||||
],
|
||||
"rows": {
|
||||
"tiers": [
|
||||
"ladder | rank | product",
|
||||
"<ladder:Main> | 0 | <product:Basic>",
|
||||
"<ladder:Main> | 1 | <product:Plus>"
|
||||
],
|
||||
"defaults": [
|
||||
"org_type | default_ladder",
|
||||
"<orgtype:T> | <ladder:Main>"
|
||||
],
|
||||
"grants": [
|
||||
"grant_id | org | product | grant_reason | granted_by | extends | description | status | act",
|
||||
"<uuid:2> | <org:Holder> | <product:Basic> | default | none | none | | active | none"
|
||||
],
|
||||
"provisions": [
|
||||
"provision | pool | grant_id | status",
|
||||
"<uuid:3> | <pool:Holder> | <uuid:2> | active"
|
||||
],
|
||||
"positions": [
|
||||
"provision | ladder | product | status | ended",
|
||||
"<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: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_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"
|
||||
],
|
||||
"reached_org_types": [
|
||||
"act | org_type | org_type_name | last_org"
|
||||
],
|
||||
"orgs_without_pool": [
|
||||
"act | org_type | org"
|
||||
],
|
||||
"obligations": [
|
||||
"act | org_type | org | pool | status | outcome | attempts | error_step | superseded_by | requeued_by"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
"SET LOCAL lock_timeout = '5s'",
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
|
||||
"LockResourcePool",
|
||||
"RecordPlanAct",
|
||||
"DeleteTier",
|
||||
"UpdateTierRank",
|
||||
"UpdateTierRank",
|
||||
@@ -53,10 +54,12 @@
|
||||
"<pool:Granted> | plan_golden_seats | 2"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
"act | cause | actor_type | actor | note | request_id",
|
||||
"<act:1> | tier_removed | 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> | tier_removed | <ladder:Main> | Main | <product:Plus> | Plus | none | none | none | none | none | none | none | none"
|
||||
],
|
||||
"reached_org_types": [
|
||||
"act | org_type | org_type_name | last_org"
|
||||
|
||||
@@ -1,31 +1,26 @@
|
||||
{
|
||||
"answer": [
|
||||
"status 200",
|
||||
"Hx-Trigger showSuccessToast: <product:Basic> removed from the ladder: 1 position(s) on this ladder ended; delivery continues."
|
||||
"Hx-Trigger showErrorToast: Failed to remove the tier; nothing was changed.",
|
||||
"[role=\"alert\"]: Failed to remove the tier; nothing was changed."
|
||||
],
|
||||
"checked": [],
|
||||
"logs": [],
|
||||
"logs": [
|
||||
"level=ERROR msg=\"tier removal failed\" request_id=<uuid:1> ladder_id=<ladder:Main> product_id=<product:Basic> step=\"read the ladder's and the product's names\" error=\"read the ladder's and the product's names: read failed\""
|
||||
],
|
||||
"statements": [
|
||||
"begin",
|
||||
"SET LOCAL lock_timeout = '5s'",
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
|
||||
"LockResourcePool",
|
||||
"DeleteTier",
|
||||
"UpdateTierRank",
|
||||
"UpdateTierRank",
|
||||
"SELECT plan_ladder_id, action FROM core.align_conferral_shape($1, $2, $3, $4, $5)",
|
||||
"SELECT core.assert_rendezvous(false)",
|
||||
"DeleteContributionsByProvisionID",
|
||||
"CreateNumericEntitlementContribution",
|
||||
"UpdateNumericEntitlementLimit",
|
||||
"commit",
|
||||
"rollback",
|
||||
"begin isolation level repeatable read read only",
|
||||
"commit"
|
||||
],
|
||||
"rows": {
|
||||
"tiers": [
|
||||
"ladder | rank | product",
|
||||
"<ladder:Main> | 0 | <product:Plus>"
|
||||
"<ladder:Main> | 0 | <product:Basic>",
|
||||
"<ladder:Main> | 1 | <product:Plus>"
|
||||
],
|
||||
"defaults": [
|
||||
"org_type | default_ladder",
|
||||
@@ -33,20 +28,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> | ended | true"
|
||||
"<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:2> | <ladder:Main> | - | - | end | operator | <person:Operator> | tier removal (<product:Basic> from Main)"
|
||||
"<pool:Holder> | <uuid:3> | <ladder:Main> | - | 0 | initiate | system | none | test fixture"
|
||||
],
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"answer": [
|
||||
"status 200",
|
||||
"Hx-Trigger showErrorToast: Removal failed and nothing was changed: could not apply the current default. Details are in the server logs.",
|
||||
"[role=\"alert\"]: Removal failed and nothing was changed: could not apply the current default. Details are in the server logs."
|
||||
"Hx-Trigger showErrorToast: Removal failed and nothing was changed: could not apply the current default. Reference: <uuid:1>.",
|
||||
"[role=\"alert\"]: Removal failed and nothing was changed: could not apply the current default. Reference: <uuid:1>."
|
||||
],
|
||||
"checked": [],
|
||||
"logs": [
|
||||
"level=ERROR msg=\"tier removal: migrate restore failed\" pool_id=<pool:Second> 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=\"tier removal failed\" request_id=<uuid:1> ladder_id=<ladder:Main> product_id=<product:Basic> pool_id=<pool:Second> step=\"restore the organization type's default\" error=\"restore the organization type's default: reapply-defaults: confer default grant: confer grant: ERROR: confer: product <product:NoSet> has no entitlement set and confers nothing (SQLSTATE P0001)\""
|
||||
],
|
||||
"statements": [
|
||||
"begin",
|
||||
@@ -14,6 +14,7 @@
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
|
||||
"LockResourcePool",
|
||||
"LockResourcePool",
|
||||
"RecordPlanAct",
|
||||
"DeleteTier",
|
||||
"UpdateTierRank",
|
||||
"UpdateTierRank",
|
||||
@@ -49,23 +50,23 @@
|
||||
],
|
||||
"grants": [
|
||||
"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:2> | <org:First> | <product:Basic> | default | none | none | | active | none",
|
||||
"<uuid:3> | <org:Second> | <product:Basic> | default | none | none | | active | none"
|
||||
],
|
||||
"provisions": [
|
||||
"provision | pool | grant_id | status",
|
||||
"<uuid:3> | <pool:First> | <uuid:1> | active",
|
||||
"<uuid:4> | <pool:Second> | <uuid:2> | active"
|
||||
"<uuid:4> | <pool:First> | <uuid:2> | active",
|
||||
"<uuid:5> | <pool:Second> | <uuid:3> | active"
|
||||
],
|
||||
"positions": [
|
||||
"provision | ladder | product | status | ended",
|
||||
"<uuid:3> | <ladder:Main> | <product:Basic> | active | false",
|
||||
"<uuid:4> | <ladder:Main> | <product:Basic> | active | false"
|
||||
"<uuid:4> | <ladder:Main> | <product:Basic> | active | false",
|
||||
"<uuid:5> | <ladder:Main> | <product:Basic> | active | false"
|
||||
],
|
||||
"transitions": [
|
||||
"pool | provision | ladder | from_rank | to_rank | transition_type | actor_type | actor | reason",
|
||||
"<pool:First> | <uuid:3> | <ladder:Main> | - | 0 | initiate | system | none | test fixture",
|
||||
"<pool:Second> | <uuid:4> | <ladder:Main> | - | 0 | initiate | system | none | test fixture"
|
||||
"<pool:First> | <uuid:4> | <ladder:Main> | - | 0 | initiate | system | none | test fixture",
|
||||
"<pool:Second> | <uuid:5> | <ladder:Main> | - | 0 | initiate | system | none | test fixture"
|
||||
],
|
||||
"entitlements": [
|
||||
"pool | resource_key | resource_limit",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
|
||||
"LockResourcePool",
|
||||
"LockResourcePool",
|
||||
"RecordPlanAct",
|
||||
"DeleteTier",
|
||||
"UpdateTierRank",
|
||||
"UpdateTierRank",
|
||||
@@ -48,7 +49,7 @@
|
||||
"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:Granted> | <product:Basic> | complimentary | <person:Operator> | none | | active | none",
|
||||
"<uuid:3> | <org:Holder> | <product:Plus> | default | none | none | | active | none"
|
||||
"<uuid:3> | <org:Holder> | <product:Plus> | default | none | none | | active | <act:1>"
|
||||
],
|
||||
"provisions": [
|
||||
"provision | pool | grant_id | status",
|
||||
@@ -76,10 +77,12 @@
|
||||
"<pool:Granted> | plan_golden_seats | 1"
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id"
|
||||
"act | cause | actor_type | actor | note | request_id",
|
||||
"<act:1> | tier_removed | person | <person:Operator> | none | <uuid:7>"
|
||||
],
|
||||
"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> | tier_removed | <ladder:Main> | Main | <product:Basic> | Basic | none | none | none | none | none | none | migrate | none"
|
||||
],
|
||||
"reached_org_types": [
|
||||
"act | org_type | org_type_name | last_org"
|
||||
|
||||
+4
-2
@@ -59,11 +59,13 @@
|
||||
],
|
||||
"acts": [
|
||||
"act | cause | actor_type | actor | note | request_id",
|
||||
"<act:1> | org_type_default_changed | person | <person:Operator> | none | <uuid:7>"
|
||||
"<act:1> | org_type_default_changed | person | <person:Operator> | none | <uuid:7>",
|
||||
"<act:2> | tier_removed | person | <person:Operator> | none | <uuid:8>"
|
||||
],
|
||||
"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> | tier_removed | <ladder:Main> | Main | <product:Plus> | Plus | none | none | none | none | none | none | none | none"
|
||||
],
|
||||
"reached_org_types": [
|
||||
"act | org_type | org_type_name | last_org",
|
||||
|
||||
@@ -659,6 +659,8 @@ Each table lists what is committed, what the operator sees and how the act is ru
|
||||
| Last tier of a ladder an org type defaults to, either disposition | nothing | the existing last-tier refusal; the preview shows it with "Apply change" disabled | add another tier first |
|
||||
| Holder read, pool locks, second read | nothing | the contention refusal or "Failed to remove the tier; nothing was changed."; now logged | resubmit |
|
||||
| Default-sourced holders, no disposition | nothing | 422: "Choose whether to keep or migrate the N organization(s) ..." | choose |
|
||||
| The 422 preview cannot be rebuilt after the rollback | nothing | the preview's own read failure: "Failed to load the tier's holders. Reference: {request id}." (copy 52) or copy 14; logged with the request id | resubmit |
|
||||
| The gate was true and the rebuilt preview finds no default-sourced holder (they left between the rollback and the rebuild) | nothing | "Failed to remove the tier; nothing was changed."; an Error line with the ladder, the product and the request id | resubmit |
|
||||
| Recording the act, delete, renumber | nothing | "Failed to remove the tier; nothing was changed."; logged | resubmit |
|
||||
| A pool's end, alignment, restoration or materialization | nothing | "Removal failed and nothing was changed: could not ... Reference: {request id}." for the step (copies 53 to 56), or the contention refusal | once the cause is fixed; one broken organization blocks the whole removal |
|
||||
| Commit | nothing | the contention refusal or "Failed to remove the tier; nothing was changed."; logged | resubmit |
|
||||
@@ -800,9 +802,9 @@ Every sentence a person reads that this change adds, changes, reuses or removes.
|
||||
| 11 | "The move could not be completed." | an unmoved organization's reason (`begin`, `lock_pool`, `read_obligation`, `read_default`, `read_positions`, `settle`, `commit`) | |
|
||||
| 12 | "Failed to retry. Reference: {request id}." | the card or the ladder page, as an error, when Retry fails for a reason other than lock contention | |
|
||||
| 13 | "Pending plan changes could not be read. Reference: {request id}." | the card or the ladder page, in place of the lines, when they cannot be read | narrowed: a failed read of the toast's counts leaves those counts out of the toast and does not show this sentence |
|
||||
| 14 | "Failed to preview the removal. Reference: {request id}." | the tier card, in place of the removal preview, when the org types its last-tier guard needs cannot be read | |
|
||||
| 14 | "Failed to preview the removal. Reference: {request id}." | the tier card, in place of the removal preview, when the org types its last-tier guard needs, or the ladder's tiers, cannot be read; and the 422 preview a refused commit rebuilds, on the same failure | "Where" extended with task 8 |
|
||||
| 15 | "N organizations hold this tier; a removal is refused above C." | the removal's commit refusal; the removal preview's disabled "Apply change" reason | |
|
||||
| 16 | "M of them hold it through their org type's default; changing that default with Migrate moves them in the background." When the tier is rank 0 of a ladder their type defaults to: "M of them hold it through their org type's default; changing that default, or reordering this ladder, with Migrate moves them in the background." | after 15, when more than half of the holders are default-sourced | |
|
||||
| 16 | "M of them hold it through their org type's default; changing that default with Migrate moves them in the background." When the tier is rank 0 of a ladder that has another tier, and every one of the M holders' org types defaults to that ladder: "M of them hold it through their org type's default; changing that default, or reordering this ladder, with Migrate moves them in the background." | after 15, when more than half of the holders are default-sourced | |
|
||||
| 17 | "N organizations hold this product; adding it as a tier is refused above C." | the tier-add form's product field error | |
|
||||
| 18 | "M of them hold it through their org type's default; changing that default with Migrate moves them in the background." | after 17, when more than half of the holders hold the product through a default-sourced position on a ladder; M counts only those holders, not default-sourced provisions a removal left off every ladder | |
|
||||
| 19 | summary part "N not yet reached" | the default change's toast ("Default change committed for {type}: ...") and the reorder's ("{Product} is now the default plan for new {types} organizations: ..."); counts pending obligations and organizations not yet listed, including an organization whose move the request has just tried and failed | "Where" states that it counts an organization the request tried and failed |
|
||||
@@ -836,7 +838,7 @@ Every sentence a person reads that this change adds, changes, reuses or removes.
|
||||
| 49 | disclosure label "show all {N}" | "show all {N}" while the list holds every name; "show 50 of {N}" when the cap of 50 shortens it; N is the class's exact count | the default change's and the reorder's preview lists of outgoing-default and other-source organizations | new: with the cap, "show all {N}" would be false |
|
||||
| 50 | "Failed to add tier; see server logs." | "Failed to add tier. Reference: {request id}." | the tier add on every failure path that is not a refusal: begin, the product read (other than no rows; today "Product not found."), the holder count, the pool locks, the act, the collision read, a collision the collision read did not find, the alignment, materialization and commit | approved |
|
||||
| 51 | "Failed to remove tier; see server logs." | "Failed to remove tier. Reference: {request id}." | the tier delete, on every failure path that is not a refusal, including the read of the org types | approved |
|
||||
| 52 | "Failed to load the tier's holders. Details are in the server logs." | "Failed to load the tier's holders. Reference: {request id}." | the removal preview, in place of the preview, when the holders cannot be listed | approved |
|
||||
| 52 | "Failed to load the tier's holders. Details are in the server logs." | "Failed to load the tier's holders. Reference: {request id}." | the removal preview, in place of the preview, when the holders cannot be listed, and the 422 preview a refused commit rebuilds, on the same failure | approved |
|
||||
| 53 | "Removal failed and nothing was changed: could not end a default-sourced position. Details are in the server logs." | "Removal failed and nothing was changed: could not end a default-sourced position. Reference: {request id}." | the removal, when ending a default-sourced position fails | approved |
|
||||
| 54 | "Removal failed and nothing was changed: could not reconcile a holder's position. Details are in the server logs." | "Removal failed and nothing was changed: could not reconcile a holder's position. Reference: {request id}." | the removal, when aligning a holder fails | approved |
|
||||
| 55 | "Removal failed and nothing was changed: could not apply the current default. Details are in the server logs." | "Removal failed and nothing was changed: could not apply the current default. Reference: {request id}." | the removal, when restoring the current default fails | approved |
|
||||
|
||||
@@ -85,12 +85,12 @@ Each numbered section below is one commit, and its items are that commit's parts
|
||||
|
||||
## 8. The tier removal on the runner
|
||||
|
||||
- [ ] 8.1 Rewrite `RemoveTier` on the runner: count the holders and refuse above `planActHolderCap` through `HolderLimitRefusal` (with its rank-0 variant when the tier is rank 0 of a ladder the holders' type defaults to); refuse the last tier of a default ladder through `LastTierRefusal` under either disposition, inside the transaction; read the product and ladder names inside, refusing when they cannot be read; record the act (`tier_removed`, the disposition); then the existing reconciliation, with the act id on every grant the migrate floor creates.
|
||||
- [ ] 8.2 `CommitPlanLadderTierRemoval` only parses, calls and renders, logs every failing step through the request's logger, `tierRemovalFailure` and `tierRemovalRefusalOr` take the request, and the four sentences "Removal failed and nothing was changed: could not ... Details are in the server logs." (`internal/server/operator_plan_ladders.go:1098-1107`) end "Reference: {request id}." (copies 53 to 56), answers "{Product} removed from the ladder." when nothing was left to reconcile, and answers a lock wait or deadlock with the new refusal (`tierRemovalRefusalOr`).
|
||||
- [ ] 8.3 The removal preview reads the holder counts and the org types with reads that fail visibly, calls the same two functions, and renders "Apply change" disabled through the disabled-control part with the sentence as its reason; when the org types cannot be read it renders "Failed to preview the removal. Reference: {request id}." in place of the preview, logged through the request's logger; "Failed to load the tier's holders. Details are in the server logs." (`internal/server/operator_plan_ladders.go:864`) takes the reference form (copy 52). Render tests for both reasons and the read failure.
|
||||
- [ ] 8.4 Tests: the last-tier refusal under keep and under migrate; the limit refusal; the toast with no holders left; a migrate floor grant's `act_id`; `TestTierRemoval_*` for the guards that moved inside.
|
||||
- [ ] 8.5 Goldens: `remove-begin-fails.json`, `remove-second-pool-fails.json` (every failing step logged with `request_id`; the alerts read the reference form of copy 55), `remove-keep-rank-0.json`, `remove-migrate-pool-held-elsewhere.json`, `remove-migrate-rank-0.json` (the floor grant's `act_id`), `remove-needs-disposition.json`, `remove-other-source.json`, `remove-refused-disposition.json`, `remove-two-pools.json` (the act row), `remove-last-tier-keep.json` and `remove-last-tier-migrate.json` (refused with the last-tier sentence; its failure alerts read the reference form of copy 55), `remove-no-holders-left.json` ("{Product} removed from the ladder."), `remove-product-name-unread.json` (the act's billing querier refuses the name inside the transaction: refused, nothing changed), the four files under `removal-against-default/` (the removal's side of the rebuilt queues-first cases), `tier-change-during-conferral/a-tier-removal.json` (the removal's act rows, on top of task 6's change), and `contention.json` (the removal answers the new refusal). New: `remove-above-the-limit.json`.
|
||||
- [ ] 8.6 Docs: the removal's holder count, last-tier guard and ascending locks in the "Tier add, tier removal, default change and reorder" section of `docs/database-locks.md`, and the removal's holder limit and last-tier refusal in `docs/plan-management.md`.
|
||||
- [x] 8.1 Rewrite `RemoveTier` on the runner: count the holders and refuse above `planActHolderCap` through `HolderLimitRefusal` (with its rank-0 variant when the tier is rank 0 of a ladder that has another tier and every default-sourced holder's type defaults to it); refuse the last tier of a default ladder through `LastTierRefusal` under either disposition, inside the transaction; read the product and ladder names inside, refusing when they cannot be read; record the act (`tier_removed`, the disposition); then the existing reconciliation, with the act id on every grant the migrate floor creates.
|
||||
- [x] 8.2 `CommitPlanLadderTierRemoval` only parses, calls and renders, logs every failing step through the request's logger, `tierRemovalFailure` and `tierRemovalRefusalOr` take the request, and the four sentences "Removal failed and nothing was changed: could not ... Details are in the server logs." (`internal/server/operator_plan_ladders.go:1098-1107`) end "Reference: {request id}." (copies 53 to 56), answers "{Product} removed from the ladder." when nothing was left to reconcile, and answers a lock wait or deadlock with the new refusal (`tierRemovalRefusalOr`).
|
||||
- [x] 8.3 The removal preview reads the holder counts and the org types with reads that fail visibly, calls the same two functions, and renders "Apply change" disabled through the disabled-control part with the sentence as its reason; when the org types cannot be read it renders "Failed to preview the removal. Reference: {request id}." in place of the preview, logged through the request's logger; "Failed to load the tier's holders. Details are in the server logs." (`internal/server/operator_plan_ladders.go:864`) takes the reference form (copy 52). Render tests for both reasons and the read failure.
|
||||
- [x] 8.4 Tests: the last-tier refusal under keep and under migrate; the limit refusal; the toast with no holders left; a migrate floor grant's `act_id`; `TestTierRemoval_*` for the guards that moved inside.
|
||||
- [x] 8.5 Goldens: `remove-begin-fails.json`, `remove-second-pool-fails.json` (every failing step logged with `request_id`; the alerts read the reference form of copy 55), `remove-keep-rank-0.json`, `remove-migrate-pool-held-elsewhere.json`, `remove-migrate-rank-0.json` (the floor grant's `act_id`), `remove-other-source.json`, `remove-two-pools.json` (the act row), `remove-last-tier-keep.json` and `remove-last-tier-migrate.json` (refused with the last-tier sentence before any pool step), `remove-no-holders-left.json` ("{Product} removed from the ladder."), `remove-product-name-unread.json` (the act's billing querier refuses the name inside the transaction: refused, nothing changed), the four files under `removal-against-default/` (the removal's side of the rebuilt queues-first cases), `tier-change-during-conferral/a-tier-removal.json` (the removal's act rows, on top of task 6's change), and `contention.json` (the removal answers the new refusal). New: `remove-above-the-limit.json` and `remove-org-types-unread.json` (the act's querier refuses `ListOrgTypes` inside the transaction: the generic failure, the step logged with its request id, nothing changed). `remove-needs-disposition.json` and `remove-refused-disposition.json` do not move: the gate runs before the record and writes nothing.
|
||||
- [x] 8.6 Docs: the removal's holder count, last-tier guard and ascending locks in the "Tier add, tier removal, default change and reorder" section of `docs/database-locks.md`, and the removal's holder limit and last-tier refusal in `docs/plan-management.md`.
|
||||
|
||||
## 9. Docs that describe the new structures
|
||||
|
||||
|
||||
Reference in New Issue
Block a user