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.
93 lines
4.0 KiB
Go
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")
|
|
}
|