Files
cgalo5758 3727ff31d8 Add entitlement set rule change ledger and preview flow
Add an append-only ledger of entitlement set rule changes with per-pool
effect rows, a preview-and-commit rule change flow, and an automatic
drain that settles deferred recomputations. Rules gain a tier reduction
policy, resource keys declare over-limit behavior, and the materializer
now lowers limits when a rule stops applying.
Add entitlement set rule change ledger and preview flow

Add an append-only ledger of entitlement set rule changes with a
preview-and-commit operator flow. Rule writes now go through an enclosed
`core.commit_rule_change` function that files an act row and one
obligation per carrying pool, with a drain workflow settling deferred
recomputations. The preview dry-runs the materializer with a rule
overlay and renders per-pool buckets, reduction-policy disclosures, and
provider over-limit consequences. Materializing transactions take a
shared advisory rendezvous that rule changes hold exclusively, enforced
by a possession assertion. Add History and Entitlement changes surfaces,
a rule-less warning on five product-selection surfaces, and a
`tier_reduction_policy` column that gates FedWiki parking.
2026-09-15 03:53:28 -05:00

93 lines
4.0 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package entitlements
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/jackc/pgx/v5/pgconn"
)
// The materialization rendezvous (Decision 144): one transaction-scoped
// advisory lock that every transaction materializing a pool holds in shared
// mode and every transaction changing a rule holds in exclusive mode, taken
// as the transaction's first statement so no holder ever waits on something
// a would-be holder already holds. The key is hashtextextended of this name.
const rendezvousName = "entitlement_materialization"
// rendezvousLockTimeout bounds every wait on the rendezvous; a wait past it
// surfaces as SQLSTATE 55P03 and renders as the refusal copy.
const rendezvousLockTimeout = "5s"
// rendezvousMissingMessage is the message core.assert_rendezvous raises when
// the calling transaction holds no rendezvous.
const rendezvousMissingMessage = "materialization_rendezvous_missing"
// BeginMaterializing opens a transaction that may materialize pools: it sets
// the lock timeout and takes the shared rendezvous before anything else. A
// BeginTx in a file that also materializes, confers or reapplies defaults is
// the defect this helper exists to make greppable. opts may be nil.
func BeginMaterializing(ctx context.Context, db *sql.DB, opts *sql.TxOptions) (*sql.Tx, error) {
return beginWithRendezvous(ctx, db, opts, "pg_advisory_xact_lock_shared")
}
// BeginRuleChange opens a transaction that changes a rule: it sets the lock
// timeout and takes the exclusive rendezvous before anything else, so every
// materializing transaction either finished before the change or starts
// after it.
func BeginRuleChange(ctx context.Context, db *sql.DB) (*sql.Tx, error) {
return beginWithRendezvous(ctx, db, nil, "pg_advisory_xact_lock")
}
func beginWithRendezvous(ctx context.Context, db *sql.DB, opts *sql.TxOptions, lockFn string) (*sql.Tx, error) {
if opts != nil && opts.Isolation != sql.LevelDefault && opts.Isolation != sql.LevelReadCommitted {
// Under snapshot isolation the statement that waits for the rendezvous
// takes the snapshot before the wait ends, so a materializer queued
// behind a rule commit would read the rules as they were before it.
return nil, fmt.Errorf("materializing transactions run at READ COMMITTED, not %v", opts.Isolation)
}
tx, err := db.BeginTx(ctx, opts)
if err != nil {
return nil, err
}
if _, err := tx.ExecContext(ctx, "SET LOCAL lock_timeout = '"+rendezvousLockTimeout+"'"); err != nil {
_ = tx.Rollback()
return nil, fmt.Errorf("set lock_timeout: %w", err)
}
if _, err := tx.ExecContext(ctx, "SELECT "+lockFn+"(hashtextextended($1, 0))", rendezvousName); err != nil {
_ = tx.Rollback()
return nil, fmt.Errorf("take materialization rendezvous: %w", err)
}
return tx, nil
}
// assertRendezvous is the possession check at the top of the apply path:
// one SELECT on the caller's own transaction that raises when the calling
// backend holds no granted rendezvous (Decision 144, doc-47 §4.3).
func assertRendezvous(ctx context.Context, db DBTX, poolID string) error {
if _, err := db.ExecContext(ctx, "SELECT core.assert_rendezvous(false)"); err != nil {
return fmt.Errorf("materialize pool %s: %w", poolID, err)
}
return nil
}
// IsRendezvousMissing reports whether err is the assertion's refusal: the
// transaction materialized without holding the rendezvous.
func IsRendezvousMissing(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Message == rendezvousMissingMessage
}
// IsLockContention reports whether err is a lock wait that ran past the
// timeout (SQLSTATE 55P03), a deadlock the server broke (40P01) or a
// serialization failure (40001); each is retryable and renders as the one
// refusal copy for a change in progress.
func IsLockContention(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && (pgErr.Code == "55P03" || pgErr.Code == "40P01" || pgErr.Code == "40001")
}