carlos / platform Public

WIP: walk lease and backend fence, quantity send lease and key fence, attestation path A parked

A checkpoint of the work since the round six brief. Not gated, not reviewed.
Billing stays off, the quantity worker stays off (CONSOLE_BILLING_PRORATION
unset), and nothing here was sent to a live provider except read-only AWS
calls.

Object store:
- One walk at a time per bucket name: a lease object taken by compare-and-swap
  (walklock.go), held for the backend's signature window plus a skew allowance.
- A backend fence (fence.go): every request of a walk is signed with the
  lease's instant, so AWS refuses a request sent after the window whenever a
  paused process sends it. Checked read-only against S3 and IAM
  (fence_aws_test.go, skipped unless CARLOS_FENCE_AWS_BUCKET is set): a request
  stamped 13 minutes old is accepted, one stamped 16 minutes old is refused.
- Teardown is a write-ahead record on the store record and the ledger; a
  refused grant undoes its block; a fresh mint refuses an existing bucket and
  a name another record holds; DestroyedAt lets a retained teardown finish.
- A randomized invariant simulation (walk_sim_test.go).

Quantity worker:
- A send lease: sending an operation and retiring it exclude each other.
- A key fence: an operation nobody can show ran is acknowledged only after its
  idempotency key is consumed at the provider with a metadata-only request, and
  a retirement is decided on a provider read taken after that.
- A key is never reused; an unverified addition always carries its hold.
- A randomized invariant simulation with a held request and a moving clock.

Replication attestation path A (scope, replicator probe, agent switch) is
removed from this snapshot and kept on feat/carloku-scoped-attestation-20261001.
It cannot prove an exclusive writer. Replication-write completeness remains a
launch blocker. It is not waived and not scoped out.

Known failures and open points at this commit:
- internal/objstore: TestBucketNameInvariantsUnderRandomWalks fails at seed 778
  with CARLOS_SIM_SEEDS=3000 (the Mint invariant). It passes at the default 400
  seeds. Under investigation; not yet known whether the fault is in the walk or
  in the simulation's bookkeeping.
- The quantity key fence rests on Stripe's idempotency semantics as documented.
  The tests use a fake provider that models them. That is not proof. It has not
  been run against Stripe (test mode is not authorized), a consumed key is not a
  permanent fencing token (the provider forgets keys), and the worker must stay
  off until it is verified in a Stripe sandbox.
- The invoice engine's lease has no provider-side fence beyond identical
  replays under the same key; a request first arriving after the provider has
  forgotten the key is detected and held, not prevented.
- The full gate has not been run on this commit. Run here: go build, go vet and
  tests of internal/objstore, internal/billingstripe, internal/ops and the
  billing tests of internal/console, all passing at default seeds.
  internal/host was not run.
- Round six dispositions, handoffs and the requirements audit still describe
  058ea506.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev c2c02f6850e19929e12fb428c063ff788244703c parent 058ea50
24 files changed, +2754 −1365
  • internal/billingstripe/client.go +52 −0
  • internal/billingstripe/client_test.go +53 −0
  • internal/console/billingquantity.go +298 −33
  • internal/console/billingquantity_sim_test.go +345 −0
  • internal/console/billingquantity_test.go +576 −33
  • internal/console/billingservice.go +10 −6
  • internal/host/agent.go +0 −6
  • internal/host/durability.go +0 −9
  • internal/host/durabilityreplicator.go +0 −255
  • internal/host/durabilityreplicator_test.go +0 −214
  • internal/host/durabilityscope.go +0 −269
  • internal/host/durabilityscope_test.go +0 −365
  • internal/objstore/aws.go +12 −2
  • internal/objstore/fence.go +76 −0
  • internal/objstore/fence_aws_test.go +91 −0
  • internal/objstore/memprovisioner.go +34 −0
  • internal/objstore/objstore.go +8 −3
  • internal/objstore/ownership.go +30 −12
  • internal/objstore/ownership_test.go +393 −13
  • internal/objstore/walk.go +201 −33
  • internal/objstore/walk_sim_test.go +366 −0
  • internal/objstore/walklock.go +209 −0
  • internal/ops/litestream.go +0 −60
  • internal/ops/litestream_generated_test.go +0 −52
diff --git a/internal/billingstripe/client.go b/internal/billingstripe/client.go
index eed378538..c239834ef 100644
--- a/internal/billingstripe/client.go
+++ b/internal/billingstripe/client.go
@@ -666,6 +666,58 @@ func (c *Client) UpdateQuantityWith(ctx context.Context, itemID string, quantity
return &item, nil
}
+// FenceQuantityKey makes the provider refuse any later quantity update sent
+// under idempotencyKey, without changing anything a customer is billed for.
+//
+// The provider remembers an idempotency key with the parameters it was
+// first used with, and refuses the key with any other parameters. This
+// sends a metadata-only update of the item under the key. One of three
+// things is then true, and each means a quantity update still on its way
+// under that key can no longer take effect:
+//
+// - the update ran (2xx): the key now belongs to this harmless request,
+// and the quantity update will be refused as a parameter mismatch;
+// - the provider refused it as a parameter mismatch (idempotency_error):
+// the key already belongs to the quantity update, which has run, and a
+// second copy of it is answered from memory and runs nothing;
+// - the item no longer exists (resource_missing): nothing can update it.
+//
+// fenced is false with no error when a request under the key is executing
+// at this moment (idempotency_key_in_use); the caller asks again later.
+//
+// The provider's memory of a key is not permanent (Stripe documents at
+// least 24 hours). The fence holds for that long from this call.
+func (c *Client) FenceQuantityKey(ctx context.Context, itemID string, generation int64, idempotencyKey string) (fenced bool, err error) {
+ if !validID(itemID) {
+ return false, &Error{Code: CodeInvalidID}
+ }
+ if generation < 0 {
+ return false, &Error{Code: CodeInvalidParams}
+ }
+ form := url.Values{}
+ form.Set("metadata[carloku_quantity_fence]", strconv.FormatInt(generation, 10))
+ var item SubscriptionItem
+ err = c.do(ctx, http.MethodPost, "/v1/subscription_items/"+itemID, form, idempotencyKey, &item)
+ if err == nil {
+ if item.ID != itemID {
+ return false, &Error{Code: CodeDecodeError}
+ }
+ return true, nil
+ }
+ var refused *Error
+ if errors.As(err, &refused) && refused.Code == CodeAPIError {
+ switch {
+ case refused.HTTPStatus == http.StatusBadRequest && refused.StripeType == "idempotency_error":
+ return true, nil
+ case refused.HTTPStatus == http.StatusNotFound && refused.StripeCode == "resource_missing":
+ return true, nil
+ case refused.HTTPStatus == http.StatusConflict:
+ return false, nil
+ }
+ }
+ return false, err
+}
+
type subscriptionListResponse struct {
Data []Subscription `json:"data"`
HasMore bool `json:"has_more"`
diff --git a/internal/billingstripe/client_test.go b/internal/billingstripe/client_test.go
index 02cca1619..c414e13ca 100644
--- a/internal/billingstripe/client_test.go
+++ b/internal/billingstripe/client_test.go
@@ -879,3 +879,56 @@ func TestDo_PinnedAPIVersionOnEveryRequest(t *testing.T) {
}
}
}
+
+// The fence request carries the operation's key and nothing that is billed:
+// no quantity, no proration. What the provider answers decides whether the
+// key is held.
+func TestFenceQuantityKey(t *testing.T) {
+ cases := []struct {
+ name string
+ status int
+ body string
+ wantFenced bool
+ wantErr bool
+ }{
+ {"the update ran: the key is now this request's", 200, `{"id":"si_1","quantity":10,"price":{"id":"price_1"}}`, true, false},
+ {"refused as a parameter mismatch: the key is the quantity update's", 400, `{"error":{"type":"idempotency_error","message":"Keys for idempotent requests can only be used with the same parameters they were first used with."}}`, true, false},
+ {"the item is gone: nothing can update it", 404, `{"error":{"type":"invalid_request_error","code":"resource_missing","message":"No such subscription item"}}`, true, false},
+ {"a request under the key is executing: ask again", 409, `{"error":{"type":"idempotency_error","code":"idempotency_key_in_use"}}`, false, false},
+ {"a server error holds nothing", 500, `{"error":{"type":"api_error"}}`, false, true},
+ {"another refusal holds nothing", 400, `{"error":{"type":"invalid_request_error","code":"parameter_missing"}}`, false, true},
+ {"a 404 that is not a missing item holds nothing", 404, `{"error":{"type":"invalid_request_error"}}`, false, true},
+ {"rate limited holds nothing", 429, `{"error":{"type":"rate_limit_error"}}`, false, true},
+ {"an answer for another item holds nothing", 200, `{"id":"si_other","quantity":10,"price":{"id":"price_1"}}`, false, true},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ var form map[string][]string
+ var key, path string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _ = r.ParseForm()
+ form, key, path = r.PostForm, r.Header.Get("Idempotency-Key"), r.URL.Path
+ w.WriteHeader(tc.status)
+ _, _ = w.Write([]byte(tc.body))
+ }))
+ defer srv.Close()
+ fenced, err := testClient(t, srv.URL).FenceQuantityKey(context.Background(), "si_1", 7, "op-key")
+ if fenced != tc.wantFenced || (err != nil) != tc.wantErr {
+ t.Fatalf("fenced=%v err=%v, want fenced=%v err=%v", fenced, err, tc.wantFenced, tc.wantErr)
+ }
+ if path != "/v1/subscription_items/si_1" || key != "op-key" {
+ t.Fatalf("sent to %q under key %q", path, key)
+ }
+ if len(form) != 1 || len(form["metadata[carloku_quantity_fence]"]) != 1 || form["metadata[carloku_quantity_fence]"][0] != "7" {
+ t.Fatalf("the fence request carries more than its marker: %v", form)
+ }
+ })
+ }
+ c := testClient(t, "http://127.0.0.1:1")
+ if _, err := c.FenceQuantityKey(context.Background(), "../x", 1, "k"); err == nil {
+ t.Fatal("an invalid item id was accepted")
+ }
+ if _, err := c.FenceQuantityKey(context.Background(), "si_1", 1, ""); err == nil {
+ t.Fatal("a fence with no key was accepted")
+ }
+}
diff --git a/internal/console/billingquantity.go b/internal/console/billingquantity.go
index e8cf1c806..c6ecce2e6 100644
--- a/internal/console/billingquantity.go
+++ b/internal/console/billingquantity.go
@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"fmt"
+ "net/http"
"strconv"
"time"
@@ -81,6 +82,10 @@ const (
// Filed when additions from a period that has since renewed are closed
// without a charge: a person should know seats went unbilled.
billingQuantityUnchargedReason = "billing_quantity_uncharged_period"
+ // Filed when the provider refuses the request that fences an
+ // operation's key. The operation cannot be acknowledged without the
+ // fence, so the account's quantity work waits on a person.
+ billingQuantityFenceReason = "billing_quantity_fence_refused"
)
// prorationBehavior maps the configured policy to the provider's behaviour
@@ -203,7 +208,11 @@ func (s *Store) closeEndedQuantityAdditions(accountID int64, now time.Time) (ove
return false, err
}
if over {
- if _, err := tx.Exec(`UPDATE billing_quantity_operations SET acked_at=? WHERE account_id=? AND acked_at=0`, max(now.Unix(), 1), accountID); err != nil {
+ // Not while a sender holds its lease: it is acknowledged on a later
+ // pass, once no request for it can still be on its way.
+ // Nor before its key is fenced, unless the provider confirmed it:
+ // processQuantityAdditions fences it, and the next pass lands here.
+ if _, err := tx.Exec(`UPDATE billing_quantity_operations SET acked_at=? WHERE account_id=? AND acked_at=0 AND send_lease_until<=? AND `+quantityOperationSettled, max(now.Unix(), 1), accountID, now.Unix()); err != nil {
return false, err
}
}
@@ -241,8 +250,20 @@ func (s *Store) markQuantityAdditionsProcessed(accountID int64, subscription str
if ackKey == "" {
return nil
}
- _, err := tx.Exec(`UPDATE billing_quantity_operations SET acked_at=? WHERE account_id=? AND idempotency_key=? AND acked_at=0`, max(now.Unix(), 1), accountID, ackKey)
- return err
+ if _, err := tx.Exec(`UPDATE billing_quantity_operations SET acked_at=? WHERE account_id=? AND idempotency_key=? AND acked_at=0 AND `+quantityOperationSettled, max(now.Unix(), 1), accountID, ackKey); err != nil {
+ return err
+ }
+ // Still outstanding: it is neither confirmed nor fenced, and is not
+ // acknowledged. Nothing is marked either; the transaction is
+ // abandoned and the next pass fences it first.
+ var outstanding bool
+ if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM billing_quantity_operations WHERE account_id=? AND idempotency_key=? AND acked_at=0)`, accountID, ackKey).Scan(&outstanding); err != nil {
+ return err
+ }
+ if outstanding {
+ return errBillingPending
+ }
+ return nil
}
if maxGeneration <= 0 {
// Nothing was pending when the step was decided.
@@ -280,6 +301,23 @@ func (s *Store) markQuantityAdditionsProcessed(accountID int64, subscription str
return err
}
}
+ // An addition recorded against an operation nobody can show ran is
+ // never cleared without the hold that says so. The hold is filed here,
+ // in the transaction that clears the obligation, and not left to
+ // whichever reconcile happened to see the quantity change: the quantity
+ // can reach the target by a route no reconcile ties to this operation.
+ if outcome == quantityOutcomeUnverified && ackKey != "" {
+ var created int64
+ switch err := tx.QueryRow(`SELECT created_at FROM billing_quantity_operations WHERE account_id=? AND idempotency_key=?`, accountID, ackKey).Scan(&created); {
+ case errors.Is(err, sql.ErrNoRows):
+ case err != nil:
+ return err
+ default:
+ if err := fileQuantityDateReviewTx(tx, accountID, subscription, created, now); err != nil {
+ return err
+ }
+ }
+ }
if err := ack(); err != nil {
return err
}
@@ -312,6 +350,15 @@ type quantityOperation struct {
// the provider carries was put there by this operation, at CreatedAt.
// Without it a matching quantity is only a matching quantity.
ConfirmedAt int64
+ // SendLeaseUntil is the unix second until which some process holds the
+ // right to send this operation's request (claimQuantitySend). While it
+ // has not passed, nobody may retire or replace the operation.
+ SendLeaseUntil int64
+ // FencedAt is set once the provider holds this operation's idempotency
+ // key in a way that stops any request under it from changing the
+ // quantity from now on (fenceQuantityOperation). An operation nobody
+ // can show ran is acknowledged only when it is set.
+ FencedAt int64
// Revision is billing_accounts.revision as read when the target was
// computed: any change to the billing row since (plan, band, terms,
// status, period, quantity) is a different world.
@@ -323,6 +370,10 @@ type quantityOperation struct {
// may be gone. A variable so a test can shorten it.
var quantityOperationSendWindow = 2 * time.Minute
+// quantityClock is the worker's clock. A variable so a test can move a
+// lease past its end without waiting for it.
+var quantityClock = time.Now
+
// Past this age an unacknowledged operation is never re-sent: the provider
// has forgotten its idempotency key, so a send would be a new request, not
// a replay.
@@ -343,14 +394,20 @@ func (s *Store) migrateBillingQuantityOperations() error {
if err := s.addColumnIfMissing("billing_quantity_operations", "revision", `INTEGER NOT NULL DEFAULT 0`); err != nil {
return err
}
- return s.addColumnIfMissing("billing_quantity_operations", "confirmed_at", `INTEGER NOT NULL DEFAULT 0`)
+ if err := s.addColumnIfMissing("billing_quantity_operations", "confirmed_at", `INTEGER NOT NULL DEFAULT 0`); err != nil {
+ return err
+ }
+ if err := s.addColumnIfMissing("billing_quantity_operations", "send_lease_until", `INTEGER NOT NULL DEFAULT 0`); err != nil {
+ return err
+ }
+ return s.addColumnIfMissing("billing_quantity_operations", "fenced_at", `INTEGER NOT NULL DEFAULT 0`)
}
func (s *Store) quantityOperationFor(accountID int64) (quantityOperation, bool, error) {
var op quantityOperation
- err := s.db.QueryRow(`SELECT account_id,subscription,period_start,period_end,item,from_quantity,target,generation,behavior,idempotency_key,created_at,acked_at,revision,confirmed_at
+ err := s.db.QueryRow(`SELECT account_id,subscription,period_start,period_end,item,from_quantity,target,generation,behavior,idempotency_key,created_at,acked_at,revision,confirmed_at,send_lease_until,fenced_at
FROM billing_quantity_operations WHERE account_id=?`, accountID).
- Scan(&op.AccountID, &op.Subscription, &op.PeriodStart, &op.PeriodEnd, &op.Item, &op.FromQuantity, &op.Target, &op.Generation, &op.Behavior, &op.Key, &op.CreatedAt, &op.AckedAt, &op.Revision, &op.ConfirmedAt)
+ Scan(&op.AccountID, &op.Subscription, &op.PeriodStart, &op.PeriodEnd, &op.Item, &op.FromQuantity, &op.Target, &op.Generation, &op.Behavior, &op.Key, &op.CreatedAt, &op.AckedAt, &op.Revision, &op.ConfirmedAt, &op.SendLeaseUntil, &op.FencedAt)
if errors.Is(err, sql.ErrNoRows) {
return op, false, nil
}
@@ -361,7 +418,8 @@ func (s *Store) quantityOperationFor(accountID int64) (quantityOperation, bool,
// if and only if everything op was computed from is still true. In one
// transaction, under the membership write lock, it requires that:
//
-// - no operation is outstanding for the account;
+// - no operation is outstanding for the account, and op's key is not
+// the key of the account's previous operation;
// - the billing row is the very revision op was read from (which covers
// every column at once: plan, band, terms, customer), and is still the
// subscription, period, billed quantity and active status op names;
@@ -391,6 +449,18 @@ func (s *Store) beginQuantityOperation(op quantityOperation) (started bool, err
if outstanding {
return false, nil
}
+ // A key is never used for a second operation. The account's previous
+ // operation may have been fenced, and then the provider holds its key
+ // for a request that is not this one. A key repeats only when the same
+ // target is recomputed in the same second; the next pass is a later
+ // second and a different key.
+ var reused bool
+ if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM billing_quantity_operations WHERE idempotency_key=?)`, op.Key).Scan(&reused); err != nil {
+ return false, err
+ }
+ if reused {
+ return false, nil
+ }
state, err := scanBilling(tx.QueryRow(`SELECT `+billingColumns+` FROM billing_accounts WHERE account_id=?`, op.AccountID), op.AccountID)
if err != nil {
return false, err
@@ -427,7 +497,7 @@ func (s *Store) beginQuantityOperation(op quantityOperation) (started bool, err
VALUES(?,?,?,?,?,?,?,?,?,?,?,0,?)
ON CONFLICT(account_id) DO UPDATE SET subscription=excluded.subscription,period_start=excluded.period_start,period_end=excluded.period_end,item=excluded.item,
from_quantity=excluded.from_quantity,target=excluded.target,generation=excluded.generation,behavior=excluded.behavior,
- idempotency_key=excluded.idempotency_key,created_at=excluded.created_at,acked_at=0,revision=excluded.revision,confirmed_at=0`,
+ idempotency_key=excluded.idempotency_key,created_at=excluded.created_at,acked_at=0,revision=excluded.revision,confirmed_at=0,send_lease_until=0,fenced_at=0`,
op.AccountID, op.Subscription, op.PeriodStart, op.PeriodEnd, op.Item, op.FromQuantity, op.Target, op.Generation, op.Behavior, op.Key, op.CreatedAt, op.Revision); err != nil {
return false, err
}
@@ -441,11 +511,112 @@ func (s *Store) confirmQuantityOperation(accountID int64, key string, now time.T
return err
}
-func (s *Store) ackQuantityOperation(accountID int64, key string, now time.Time) error {
- _, err := s.db.Exec(`UPDATE billing_quantity_operations SET acked_at=? WHERE account_id=? AND idempotency_key=? AND acked_at=0`, max(now.Unix(), 1), accountID, key)
+// The send lease: sending an operation's request and retiring the
+// operation are mutually exclusive across processes.
+//
+// Why it exists. An operation is retired when it is known not to have run
+// and is no longer wanted as it stands, and once it is retired a
+// replacement may be recorded and completed. A process that had already
+// decided to send the old operation, and was between that decision and the
+// request, would then send it for the first time over the replacement: a
+// lower target, under a key the provider had never seen. Re-reading the
+// row before sending only narrows that window. Each review round found
+// another way into it.
+//
+// The invariant. No request for an operation is sent outside a send lease
+// on it, and an operation is retired only when no lease on it is live.
+// Both are single conditional writes on the operation's row. The sender's
+// request carries a deadline that falls billingClockTolerance before the
+// lease ends, so two processes whose clocks differ by less than that
+// cannot disagree about whether the lease has run out.
+const quantitySendLease = 2 * billingClockTolerance
+
+// claimQuantitySend takes (or extends) the send lease on the account's
+// outstanding operation key. ok is false when that operation is no longer
+// outstanding (it was retired or completed), or its key has been fenced at
+// the provider: it must not be sent.
+func (s *Store) claimQuantitySend(accountID int64, key string, now time.Time) (until time.Time, ok bool, err error) {
+ until = now.Add(quantitySendLease)
+ res, err := s.db.Exec(`UPDATE billing_quantity_operations SET send_lease_until=? WHERE account_id=? AND idempotency_key=? AND acked_at=0 AND fenced_at=0`, until.Unix(), accountID, key)
+ if err != nil {
+ return time.Time{}, false, err
+ }
+ n, err := res.RowsAffected()
+ return until, n == 1, err
+}
+
+// releaseQuantitySend gives the lease up once the request has returned,
+// whatever it returned.
+func (s *Store) releaseQuantitySend(accountID int64, key string) error {
+ _, err := s.db.Exec(`UPDATE billing_quantity_operations SET send_lease_until=0 WHERE account_id=? AND idempotency_key=?`, accountID, key)
+ return err
+}
+
+// retireQuantityOperation acknowledges an operation that did not run and is
+// no longer wanted, unless a send lease on it is live. retired false means
+// some process may be sending it: it is left outstanding and looked at
+// again next pass.
+func (s *Store) retireQuantityOperation(accountID int64, key string, now time.Time) (retired bool, err error) {
+ res, err := s.db.Exec(`UPDATE billing_quantity_operations SET acked_at=? WHERE account_id=? AND idempotency_key=? AND acked_at=0 AND send_lease_until<=? AND `+quantityOperationSettled, max(now.Unix(), 1), accountID, key, now.Unix())
+ if err != nil {
+ return false, err
+ }
+ n, err := res.RowsAffected()
+ return n == 1, err
+}
+
+// The fence: a timed lease does not stop a request that is already on its
+// way.
+//
+// The send lease orders processes that are running. It cannot stop a
+// request a sender issued inside its lease and that arrives after the lease
+// has run out: the sender was paused between its last check and the wire,
+// or the request sat in a proxy. If the operation was retired and replaced
+// in the meantime, that request would run for the first time over the
+// replacement, under a key the provider had never seen, and lower the
+// quantity.
+//
+// What stops it is the provider. Before an operation that no one can show
+// ran is acknowledged in any way, its idempotency key is consumed at the
+// provider (billingstripe.FenceQuantityKey). From then on the provider
+// itself refuses the late request, or answers it from memory without
+// running it.
+//
+// The invariant, enforced by every statement that acknowledges an
+// operation (quantityOperationSettled): an operation is acknowledged only
+// when the provider answered its own request with success (confirmed_at),
+// or its key is fenced (fenced_at). A decision to retire is made on a
+// provider read taken after the fence, never before it, so a request that
+// ran just before the fence is seen and recorded, not retired as unsent.
+//
+// Its limit: the provider remembers a key for a bounded time (Stripe
+// documents at least 24 hours). A request delayed longer than that after
+// the fence is outside what a key can stop. What remains then is the
+// request's own date, which the provider refuses outside the period it was
+// computed for, and the mismatch review, which holds the account when the
+// provider's quantity is neither what the console bills nor what it wants.
+const quantityOperationSettled = `(confirmed_at<>0 OR fenced_at<>0)`
+
+// markQuantityFenced records that the provider holds the operation's key.
+func (s *Store) markQuantityFenced(accountID int64, key string, now time.Time) error {
+ _, err := s.db.Exec(`UPDATE billing_quantity_operations SET fenced_at=? WHERE account_id=? AND idempotency_key=? AND acked_at=0 AND fenced_at=0`, max(now.Unix(), 1), accountID, key)
return err
}
+// fileQuantityDateReviewTx files the dating hold for an operation, unless
+// it is already open.
+func fileQuantityDateReviewTx(tx *sql.Tx, accountID int64, subscription string, created int64, now time.Time) error {
+ reason := billingQuantityDateReviewReason(created)
+ var open bool
+ if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM billing_reviews WHERE account_id=? AND subscription=? AND reason=? AND resolved_at=0)`, accountID, subscription, reason).Scan(&open); err != nil {
+ return err
+ }
+ if open {
+ return nil
+ }
+ return fileBillingReviewTx(tx, accountID, subscription, reason, now)
+}
+
// markOperationLanded records an operation whose target the provider
// carries in a period that has since renewed: the additions it covered, in
// the period it was for, are marked with outcome and the operation's own
@@ -459,14 +630,26 @@ func (s *Store) markOperationLanded(op quantityOperation, outcome, reference str
if _, err := lockMembershipWritesTx(tx); err != nil {
return err
}
+ // Not while a sender holds its lease (it may be about to confirm).
+ acked, err := tx.Exec(`UPDATE billing_quantity_operations SET acked_at=? WHERE account_id=? AND idempotency_key=? AND acked_at=0 AND send_lease_until<=? AND `+quantityOperationSettled, max(now.Unix(), 1), op.AccountID, op.Key, now.Unix())
+ if err != nil {
+ return err
+ }
+ if n, err := acked.RowsAffected(); err != nil {
+ return err
+ } else if n == 0 {
+ return errBillingPending
+ }
res, err := tx.Exec(`UPDATE billing_quantity_events SET processed_at=max(?,occurred_at_ns/1000000000+1), processing_outcome=?, processing_reference=?
WHERE account_id=? AND change=? AND processed_at=0 AND subscription=? AND period_start=? AND generation<=?`,
now.Unix(), outcome, reference, op.AccountID, quantityEventAddition, op.Subscription, op.PeriodStart, op.Generation)
if err != nil {
return err
}
- if _, err := tx.Exec(`UPDATE billing_quantity_operations SET acked_at=? WHERE account_id=? AND idempotency_key=? AND acked_at=0`, max(now.Unix(), 1), op.AccountID, op.Key); err != nil {
- return err
+ if outcome == quantityOutcomeUnverified {
+ if err := fileQuantityDateReviewTx(tx, op.AccountID, op.Subscription, op.CreatedAt, now); err != nil {
+ return err
+ }
}
if n, err := res.RowsAffected(); err != nil {
return err
@@ -533,9 +716,18 @@ func (s *Server) processQuantityAdditions(ctx context.Context, accountID int64,
// Additions nothing can charge any more are closed first, with no
// provider call; so is an operation of a subscription that is over.
over, err := s.store.closeEndedQuantityAdditions(accountID, now)
- if err != nil || over {
+ if err != nil {
return err
}
+ if over {
+ // An operation it could not acknowledge is one nobody can show
+ // ran. Its key is fenced now; the next pass acknowledges it.
+ op, ok, err := s.store.quantityOperationFor(accountID)
+ if err != nil || !ok || op.AckedAt != 0 {
+ return err
+ }
+ return s.fenceQuantityOperation(ctx, op, now)
+ }
step, err := s.raiseQuantity(ctx, acct, behavior, now)
if err != nil || step.done {
return err
@@ -607,7 +799,7 @@ func (s *Server) raiseQuantity(ctx context.Context, acct *Account, behavior stri
if state.Status != "active" {
return quantityStep{done: true}, nil
}
- read := time.Now()
+ read := quantityClock()
item, ok, err := s.quantityProviderItem(ctx, acct, state)
if err != nil || !ok {
return quantityStep{done: !ok}, err
@@ -636,7 +828,7 @@ func (s *Server) raiseQuantity(ctx context.Context, acct *Account, behavior stri
// refuse on every replay. At or past the period end nothing is
// recorded at all.
// And never before the newest addition it charges for.
- created := max(time.Now().Unix(), state.PeriodStart, pending.NewestAt)
+ created := max(quantityClock().Unix(), state.PeriodStart, pending.NewestAt)
if created >= state.PeriodEnd {
return quantityStep{done: true}, nil
}
@@ -689,30 +881,41 @@ func (s *Server) sendQuantityOperation(ctx context.Context, state billingState,
if hook := b.quantityBeforeSend; hook != nil {
hook()
}
- if time.Since(read) >= quantityOperationSendWindow || (op.PeriodEnd > 0 && time.Now().Unix() >= op.PeriodEnd) {
+ if quantityClock().Sub(read) >= quantityOperationSendWindow || (op.PeriodEnd > 0 && quantityClock().Unix() >= op.PeriodEnd) {
return quantityStep{done: true}, nil
}
- // The operation must still be the account's outstanding one. Another
- // process may have retired it while this one was between its read and
- // here, and a retired operation's request, never having executed, is
- // not protected by its idempotency key: sent now it would run for the
- // first time, over whatever has been done since.
- if cur, ok, err := s.store.quantityOperationFor(op.AccountID); err != nil {
+ // The send lease (claimQuantitySend). It is taken by one conditional
+ // write that succeeds only while the operation is still outstanding, and
+ // while it is live nobody can retire or replace the operation. The
+ // request's deadline falls before the lease ends.
+ claimed := quantityClock()
+ until, ok, err := s.store.claimQuantitySend(op.AccountID, op.Key, claimed)
+ if err != nil {
return quantityStep{}, err
- } else if !ok || cur.Key != op.Key || cur.AckedAt != 0 {
+ }
+ if !ok {
return quantityStep{done: true}, nil
}
- updated, err := b.client.UpdateQuantityWith(ctx, op.Item, op.Target, op.Behavior, time.Unix(op.CreatedAt, 0), op.Key)
+ sendCtx, cancel := context.WithTimeout(ctx, until.Add(-billingClockTolerance).Sub(claimed))
+ updated, err := b.client.UpdateQuantityWith(sendCtx, op.Item, op.Target, op.Behavior, time.Unix(op.CreatedAt, 0), op.Key)
+ cancel()
if err != nil {
+ // The request has returned: this process is no longer sending.
+ if rerr := s.store.releaseQuantitySend(op.AccountID, op.Key); rerr != nil {
+ return quantityStep{}, errors.Join(err, rerr)
+ }
var refused *billingstripe.Error
if errors.As(err, &refused) && refused.RefusedParam("proration_date") {
// The provider refused the request outright over its date: it
// did not execute, and the same date will be refused on every
// replay (the period moved, or a clock disagreed). The
- // operation is retired now, not replayed until it expires. The
- // additions stay pending and the next pass computes afresh.
- if aerr := s.store.ackQuantityOperation(op.AccountID, op.Key, time.Now()); aerr != nil {
- return quantityStep{}, errors.Join(err, aerr)
+ // operation is not replayed until it expires: its key is
+ // fenced now, and the next pass, reading the provider after
+ // the fence, retires it. The additions stay pending and are
+ // computed afresh after that.
+ op.SendLeaseUntil = 0
+ if ferr := s.fenceQuantityOperation(ctx, op, quantityClock()); ferr != nil {
+ return quantityStep{}, errors.Join(err, ferr)
}
}
return quantityStep{}, err
@@ -724,7 +927,10 @@ func (s *Server) sendQuantityOperation(ctx context.Context, state billingState,
// what lets the reconcile date the assignment at the operation's
// instant, so it is recorded before anything else can observe the new
// quantity through this process.
- if err := s.store.confirmQuantityOperation(op.AccountID, op.Key, time.Now()); err != nil {
+ if err := s.store.confirmQuantityOperation(op.AccountID, op.Key, quantityClock()); err != nil {
+ return quantityStep{}, err
+ }
+ if err := s.store.releaseQuantitySend(op.AccountID, op.Key); err != nil {
return quantityStep{}, err
}
if hook := b.quantityAfterSend; hook != nil {
@@ -740,14 +946,24 @@ func (s *Server) sendQuantityOperation(ctx context.Context, state billingState,
// re-sends one the provider has forgotten the key of.
func (s *Server) resolveQuantityOperation(ctx context.Context, acct *Account, state billingState, op quantityOperation, now time.Time) (quantityStep, error) {
abandon := func() (quantityStep, error) {
- // Provably not executed, and no longer wanted as it stands: the
- // additions stay pending and the next pass starts afresh.
- return quantityStep{done: true}, s.store.ackQuantityOperation(acct.ID, op.Key, now)
+ // Not executed as far as the provider shows, and no longer wanted
+ // as it stands. It is retired only on a provider read taken after
+ // its key was fenced (or its request confirmed): until then a
+ // request for it may still be on its way, and what this pass read
+ // is not the last word. So an unfenced operation is fenced here
+ // and decided next pass, from a fresh read. While some process
+ // holds the right to send it, neither happens. The additions stay
+ // pending and are computed afresh once it is retired.
+ if !op.settled() {
+ return quantityStep{done: true}, s.fenceQuantityOperation(ctx, op, now)
+ }
+ _, err := s.store.retireQuantityOperation(acct.ID, op.Key, quantityClock())
+ return quantityStep{done: true}, err
}
if op.Subscription != state.Subscription {
return abandon()
}
- read := time.Now()
+ read := quantityClock()
item, ok, err := s.quantityProviderItem(ctx, acct, state)
if err != nil {
return quantityStep{}, err
@@ -771,6 +987,9 @@ func (s *Server) resolveQuantityOperation(ctx context.Context, acct *Account, st
// outcome says exactly that: landed, unverified. Otherwise it never
// ran and is closed.
if item.Quantity == op.Target {
+ if !op.settled() {
+ return quantityStep{done: true}, s.fenceQuantityOperation(ctx, op, now)
+ }
return quantityStep{done: true}, s.store.markOperationLanded(op, quantityOutcomeUnverified, quantityReference(op.Item, op.Target, op.Behavior), now)
}
return abandon()
@@ -787,6 +1006,16 @@ func (s *Server) resolveQuantityOperation(ctx context.Context, acct *Account, st
outcome := quantityOutcomeUnverified
if op.ConfirmedAt != 0 {
outcome = quantityOutcomeFor(op.Behavior)
+ } else if op.SendLeaseUntil > read.Unix() {
+ // A sender holds the lease: its request may have just been
+ // answered and the answer not yet written down. Nothing is
+ // concluded until it has finished.
+ return quantityStep{done: true}, nil
+ } else if op.FencedAt == 0 {
+ // Nobody can show this operation ran, and a request for it
+ // may still be on its way. Its key is fenced first; the next
+ // pass records it from a read taken after the fence.
+ return quantityStep{done: true}, s.fenceQuantityOperation(ctx, op, now)
}
return quantityStep{state: state, generation: op.Generation, behavior: op.Behavior, key: op.Key, outcome: outcome,
reference: quantityReference(op.Item, op.Target, op.Behavior)}, nil
@@ -805,10 +1034,46 @@ func (s *Server) resolveQuantityOperation(ctx context.Context, acct *Account, st
// read above shows it did not land, and no sender can still be
// inside its send window, so it is closed and never re-sent.
return abandon()
+ case op.FencedAt != 0:
+ // Fenced, and the provider (read after the fence) shows it did not
+ // run. It can never be sent again; it is retired.
+ return abandon()
}
return s.sendQuantityOperation(ctx, state, op, read)
}
+// settled reports whether the operation may be acknowledged: the provider
+// confirmed its request, or holds its key fenced.
+func (op quantityOperation) settled() bool { return op.ConfirmedAt != 0 || op.FencedAt != 0 }
+
+// fenceQuantityOperation consumes op's idempotency key at the provider (see
+// quantityOperationSettled) and records that it did. It does nothing while
+// a sender holds the lease: that sender is running, and will finish or let
+// the lease run out. A refusal the provider will repeat files a review,
+// once, because the operation cannot be acknowledged without the fence.
+func (s *Server) fenceQuantityOperation(ctx context.Context, op quantityOperation, now time.Time) error {
+ if op.settled() || op.SendLeaseUntil > quantityClock().Unix() {
+ return nil
+ }
+ if hook := s.billing.quantityBeforeFence; hook != nil {
+ hook()
+ }
+ fenced, err := s.billing.client.FenceQuantityKey(ctx, op.Item, op.Generation, op.Key)
+ if err != nil {
+ var refused *billingstripe.Error
+ if errors.As(err, &refused) && refused.Code == billingstripe.CodeAPIError && refused.HTTPStatus >= 400 && refused.HTTPStatus < 500 && refused.HTTPStatus != http.StatusTooManyRequests {
+ if rerr := s.store.fileBillingReviewOnce(op.AccountID, op.Subscription, billingQuantityFenceReason, now); rerr != nil {
+ return errors.Join(err, rerr)
+ }
+ }
+ return err
+ }
+ if !fenced {
+ return nil
+ }
+ return s.store.markQuantityFenced(op.AccountID, op.Key, quantityClock())
+}
+
// quantityMismatch files the review for a provider quantity that is
// neither what the console bills nor what it wants. It is filed once and
// left alone while open, so its occurrence count does not move under the
diff --git a/internal/console/billingquantity_sim_test.go b/internal/console/billingquantity_sim_test.go
new file mode 100644
index 000000000..71aa5738c
--- /dev/null
+++ b/internal/console/billingquantity_sim_test.go
@@ -0,0 +1,345 @@
+package console
+
+import (
+ "context"
+ "fmt"
+ "math/rand"
+ "os"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/plans"
+)
+
+// The invariants of the team quantity worker, checked under random
+// interleavings of two console processes, webhook reconciles, member
+// changes and provider failures. The other tests each pin one schedule a
+// reviewer found. This states what must hold on every schedule.
+//
+// Monotone. No request the worker sends lowers the provider's quantity.
+// Leased. Every request is issued while its operation is outstanding
+// and a send lease on it is live.
+// Fenced. A request that arrives after its sender's lease has run out
+// (the sender was paused, or the request was held on its way)
+// and after its operation was acknowledged without ever having
+// run, does not run. The console cannot stop such a request:
+// the provider must, and the fake provider here refuses only
+// what the real one documents it refuses (a key replayed with
+// other parameters).
+// Held. An addition recorded as landed and unverified has an open
+// dating review.
+// Readable. The assignment history always prices: its instants are in
+// revision order.
+// Settles. With no more failures and no lease live, a few passes leave
+// no addition pending, the billed quantity equal to the
+// provider's, and at least the member count.
+func TestQuantityInvariantsUnderRandomSchedules(t *testing.T) {
+ seeds := 60
+ if testing.Short() {
+ seeds = 12
+ }
+ if n, err := strconv.Atoi(os.Getenv("CARLOS_SIM_SEEDS")); err == nil && n > 0 {
+ seeds = n
+ }
+ for seed := 0; seed < seeds; seed++ {
+ if trace, problem := simulateQuantity(t, int64(seed)); problem != "" {
+ t.Fatalf("seed %d: %s\n%s", seed, problem, strings.Join(trace, "\n"))
+ }
+ }
+}
+
+func simulateQuantity(t *testing.T, seed int64) (trace []string, problem string) {
+ t.Helper()
+ ctx := context.Background()
+ rng := rand.New(rand.NewSource(seed))
+ // The fixture's period began thirty hours ago, so an operation can age
+ // past the replay limit inside it.
+ prevAge := quantityFixtureAge
+ quantityFixtureAge = 30 * time.Hour
+ fx, q, _ := newQuantityFixture(t, billingProrationImmediate)
+ quantityFixtureAge = prevAge
+ // One clock for everything, which a pause moves forward.
+ var elapsed time.Duration
+ prevClock := quantityClock
+ quantityClock = func() time.Time { return time.Now().Add(elapsed) }
+ defer func() { quantityClock = prevClock }()
+ clock := func() time.Time { return quantityClock().UTC() }
+ procB := &Server{cfg: fx.srv.cfg, store: fx.srv.store, billing: &billingRuntime{locks: newStoreWalkLocks(), client: fx.srv.billing.client, cfg: fx.srv.billing.cfg}}
+ logf := func(format string, args ...any) { trace = append(trace, fmt.Sprintf(format, args...)) }
+ fail := func(format string, args ...any) {
+ if problem == "" {
+ problem = fmt.Sprintf(format, args...)
+ }
+ }
+ members := 10
+ // publish stands in for the entitlement publisher, which a member
+ // change waits on after the billed quantity moves.
+ publish := func() {
+ row, err := scanEntitlementOutbox(fx.st.db.QueryRow(`SELECT `+entitlementOutboxColumns+` FROM entitlement_outbox WHERE account_id=?`, OpsAccountID))
+ if err == nil {
+ _ = fx.st.markEntitlementPublished(OpsAccountID, row.Revision, row.Digest, clock())
+ }
+ }
+ addMember := func() {
+ publish()
+ members++
+ // Recorded two seconds ago, so an operation dated no earlier than
+ // its newest addition is not dated ahead of this test's clock (an
+ // operation dated ahead is left alone until that instant, which
+ // TestQuantityFutureDatedOperationIsNotRetiredUnderItsSender pins).
+ if err := fx.st.AddMember(OpsAccountID, fmt.Sprintf("sim%d-%d@example.test", seed, members), RoleMember, clock().Add(-2*time.Second)); err != nil {
+ // Held by the membership gate (the entitlement has not caught
+ // up): not added, and not this test's subject.
+ members--
+ logf(" member not added: %v", err)
+ return
+ }
+ logf(" member added (%d)", members)
+ }
+ depth := 0
+ // running is the process whose pass is in progress. Whatever happens
+ // in the middle of it happens in the OTHER process: a process does not
+ // interleave with itself, it waits on its own account lock.
+ running := fx.srv
+ other := func() *Server {
+ if running == fx.srv {
+ return procB
+ }
+ return fx.srv
+ }
+ // between runs something else in the middle of the worker: another
+ // process's pass, a webhook, a new member. One level deep.
+ between := func(where string) {
+ if depth > 0 || rng.Intn(3) != 0 {
+ return
+ }
+ depth++
+ defer func() { depth-- }()
+ switch rng.Intn(3) {
+ case 0:
+ logf(" -- at %s: the other process runs a pass", where)
+ if err := other().billingQuantityPass(ctx, clock()); err != nil {
+ logf(" %v", err)
+ }
+ case 1:
+ logf(" -- at %s: webhook in the other process", where)
+ if err := other().reconcileBillingSubscription(ctx, "sub_team", fmt.Sprintf("evt_sim_%d_%d", seed, len(trace)), clock()); err != nil {
+ logf(" %v", err)
+ }
+ case 2:
+ logf(" -- at %s:", where)
+ addMember()
+ }
+ }
+ for _, rt := range []*billingRuntime{fx.srv.billing, procB.billing} {
+ rt.quantityBeforeBegin = func() { between("before begin") }
+ rt.quantityBeforeSend = func() { between("before send") }
+ rt.quantityAfterSend = func() { between("after send") }
+ }
+ // Leased is checked where the request is issued. Then, sometimes, the
+ // request is held on its way for longer than its lease (once in a run
+ // for most of a day, which takes its operation past the replay limit)
+ // while the other process works. A request whose operation was
+ // acknowledged in that time without having run must never run.
+ mustNeverRun := map[string]bool{}
+ longPause := false
+ ran := func(key string) bool {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+ for _, p := range q.posts {
+ if p["key"] == key {
+ return true
+ }
+ }
+ return false
+ }
+ q.duringPost = func() {
+ q.mu.Lock()
+ key := q.arriving
+ q.mu.Unlock()
+ op, ok, err := fx.st.quantityOperationFor(OpsAccountID)
+ if err != nil || !ok || op.AckedAt != 0 || op.Key != key {
+ fail("Leased: a request was issued for %q with no such outstanding operation (%+v %v %v)", key, op, ok, err)
+ return
+ }
+ if op.SendLeaseUntil <= quantityClock().Unix() {
+ fail("Leased: a request was issued with no live send lease (%+v)", op)
+ }
+ if depth > 0 || rng.Intn(3) != 0 {
+ between("request in flight")
+ return
+ }
+ depth++
+ hold := quantitySendLease + time.Minute
+ if !longPause && rng.Intn(2) == 0 {
+ longPause, hold = true, 23*time.Hour
+ }
+ elapsed += hold
+ logf(" -- the request is held on its way for %v; the other process works", hold)
+ for i, n := 0, 1+rng.Intn(4); i < n; i++ {
+ switch rng.Intn(4) {
+ case 0:
+ if members < 16 {
+ addMember()
+ }
+ case 1:
+ if err := other().reconcileBillingSubscription(ctx, "sub_team", fmt.Sprintf("evt_sim_%d_%d", seed, len(trace)), clock()); err != nil {
+ logf(" webhook: %v", err)
+ }
+ default:
+ publish()
+ if err := other().billingQuantityPass(ctx, clock()); err != nil {
+ logf(" pass: %v", err)
+ }
+ }
+ }
+ depth--
+ if now, ok, _ := fx.st.quantityOperationFor(OpsAccountID); ok && (now.Key != key || (now.AckedAt != 0 && now.ConfirmedAt == 0)) && !ran(key) {
+ logf(" its operation was acknowledged unsent; the request must not run now")
+ mustNeverRun[key] = true
+ }
+ }
+ check := func() {
+ for key := range mustNeverRun {
+ if ran(key) {
+ fail("Fenced: the request for %q ran after its operation was acknowledged as never having run: %+v", key, q.posts)
+ }
+ }
+ if q.lowered {
+ fail("Monotone: a request lowered the provider's quantity: %+v", q.posts)
+ }
+ events, err := fx.st.billingQuantityEvents(OpsAccountID)
+ if err != nil {
+ fail("events: %v", err)
+ return
+ }
+ for _, e := range events {
+ if e.ProcessingOutcome != quantityOutcomeUnverified {
+ continue
+ }
+ var open int
+ if err := fx.st.db.QueryRow(`SELECT COUNT(*) FROM billing_reviews WHERE account_id=? AND reason LIKE 'billing_quantity_date_unproven:%' AND resolved_at=0`, OpsAccountID).Scan(&open); err != nil || open == 0 {
+ fail("Held: an addition is recorded as landed_unverified with no open dating review (%v)", err)
+ }
+ }
+ history, err := fx.st.billingInvoiceHistory(OpsAccountID)
+ if err != nil {
+ fail("history: %v", err)
+ return
+ }
+ var hist []plans.Assignment
+ for _, h := range history {
+ hist = append(hist, h.Assignment)
+ }
+ for i := 1; i < len(hist); i++ {
+ if hist[i].EffectiveAt.Before(hist[i-1].EffectiveAt) {
+ fail("Readable: revision %d takes effect before revision %d", hist[i].Revision, hist[i-1].Revision)
+ }
+ }
+ }
+ for step := 0; step < 14 && problem == ""; step++ {
+ // Passes are periodic: each step is a later second.
+ elapsed += time.Second
+ q.mu.Lock()
+ q.failPost, q.failAfter = false, false
+ switch rng.Intn(6) {
+ case 0:
+ q.failPost = true
+ logf(" next request fails before it runs")
+ case 1:
+ q.failAfter = true
+ logf(" next request runs and its answer is lost")
+ }
+ q.mu.Unlock()
+ switch rng.Intn(4) {
+ case 0:
+ if members < 16 {
+ addMember()
+ }
+ case 1:
+ logf("webhook")
+ if err := fx.srv.reconcileBillingSubscription(ctx, "sub_team", fmt.Sprintf("evt_sim_%d_%d", seed, len(trace)), clock()); err != nil {
+ logf(" %v", err)
+ }
+ default:
+ running = fx.srv
+ if rng.Intn(2) == 0 {
+ running = procB
+ }
+ logf("pass (%s)", map[bool]string{true: "A", false: "B"}[running == fx.srv])
+ if err := running.billingQuantityPass(ctx, clock()); err != nil {
+ logf(" %v", err)
+ }
+ }
+ check()
+ }
+ if problem != "" {
+ return trace, problem
+ }
+ // Settle: no failures, nothing interleaved.
+ q.mu.Lock()
+ q.failPost, q.failAfter = false, false
+ q.mu.Unlock()
+ for _, rt := range []*billingRuntime{fx.srv.billing, procB.billing} {
+ rt.quantityBeforeBegin, rt.quantityBeforeSend, rt.quantityAfterSend = nil, nil, nil
+ }
+ q.duringPost = func() {
+ if op, ok, _ := fx.st.quantityOperationFor(OpsAccountID); !ok || op.AckedAt != 0 || op.SendLeaseUntil <= quantityClock().Unix() {
+ fail("Leased: a settling request arrived without its lease (%+v)", op)
+ }
+ }
+ logf("-- settle --")
+ running = fx.srv
+ for i := 0; i < 7; i++ {
+ elapsed += time.Second
+ publish()
+ if err := fx.srv.billingQuantityPass(ctx, clock()); err != nil {
+ logf(" %v", err)
+ }
+ }
+ check()
+ if problem != "" {
+ return trace, problem
+ }
+ if pending := pendingAdditions(t, fx); len(pending) != 0 {
+ state, _ := fx.st.billingAccount(OpsAccountID)
+ op, _, _ := fx.st.quantityOperationFor(OpsAccountID)
+ var reviews []string
+ rows, _ := fx.st.db.Query(`SELECT reason||' open='||(resolved_at=0) FROM billing_reviews WHERE account_id=?`, OpsAccountID)
+ for rows != nil && rows.Next() {
+ var r string
+ _ = rows.Scan(&r)
+ reviews = append(reviews, r)
+ }
+ if rows != nil {
+ rows.Close()
+ }
+ q.mu.Lock()
+ atProvider := q.quantity
+ q.mu.Unlock()
+ // Held by a review a person must resolve is a settled state: the
+ // additions are owed, and the account says why they wait.
+ for _, r := range reviews {
+ if strings.HasSuffix(r, "open=1") {
+ return trace, ""
+ }
+ }
+ return trace, fmt.Sprintf("Settles: %d addition(s) still pending with no review; billed %d (status %s, period %d), provider %d, members %d, operation %+v, reviews %v", len(pending), state.Quantity, state.Status, state.PeriodStart, atProvider, members, op, reviews)
+ }
+ state, err := fx.st.billingAccount(OpsAccountID)
+ if err != nil {
+ return trace, err.Error()
+ }
+ q.mu.Lock()
+ atProvider := q.quantity
+ q.mu.Unlock()
+ if state.Quantity != atProvider || state.Quantity < int64(members) {
+ return trace, fmt.Sprintf("Settles: billed %d, provider %d, members %d", state.Quantity, atProvider, members)
+ }
+ if op, ok, _ := fx.st.quantityOperationFor(OpsAccountID); ok && op.AckedAt == 0 {
+ return trace, fmt.Sprintf("Settles: an operation is still outstanding: %+v", op)
+ }
+ return trace, ""
+}
diff --git a/internal/console/billingquantity_test.go b/internal/console/billingquantity_test.go
index 8edd687f2..4de18695c 100644
--- a/internal/console/billingquantity_test.go
+++ b/internal/console/billingquantity_test.go
@@ -32,14 +32,37 @@ type quantityStripe struct {
failAfter bool // accept the POST, then answer 500: a lost response
// byKey is the provider's idempotency memory: a key it has seen is
// answered with what it answered then, and changes nothing.
- byKey map[string]int64
- params map[string]string // key -> the parameters it was first sent with
- replays int
- lowered bool // the quantity ever went down
- refused int // requests refused for a proration date outside the period
+ byKey map[string]int64
+ params map[string]string // key -> the parameters it was first sent with
+ replays int
+ lowered bool // the quantity ever went down
+ refused int // requests refused for a proration date outside the period
+ fences []string // keys taken by a metadata-only update
+ mismatched []string // keys refused for arriving with other parameters
+ // duringPost runs when an update has arrived at the provider and has
+ // not yet taken effect: the request is in flight.
+ duringPost func()
+ // refuseFence makes the provider refuse a fence request as invalid.
+ refuseFence bool
+ // arriving is the key of the request the hooks are being run for.
+ arriving string
+ // duringFence is the same for a fence request.
+ duringFence func()
}
func (q *quantityStripe) serve(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPost {
+ _ = r.ParseForm()
+ q.mu.Lock()
+ q.arriving = r.Header.Get("Idempotency-Key")
+ q.mu.Unlock()
+ if hook := q.duringPost; hook != nil && r.PostForm.Get("quantity") != "" {
+ hook()
+ }
+ if hook := q.duringFence; hook != nil && r.PostForm.Get("quantity") == "" {
+ hook()
+ }
+ }
q.mu.Lock()
defer q.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
@@ -74,6 +97,7 @@ func (q *quantityStripe) serve(w http.ResponseWriter, r *http.Request) {
if prev, seen := q.byKey[key]; seen {
// A key replayed with different parameters is refused.
if q.params[key] != r.PostForm.Encode() {
+ q.mismatched = append(q.mismatched, key)
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":{"type":"idempotency_error","message":"Keys for idempotent requests can only be used with the same parameters"}}`))
return
@@ -85,6 +109,19 @@ func (q *quantityStripe) serve(w http.ResponseWriter, r *http.Request) {
if q.byKey == nil {
q.byKey, q.params = map[string]int64{}, map[string]string{}
}
+ if r.PostForm.Get("quantity") == "" && q.refuseFence {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte(`{"error":{"type":"invalid_request_error","message":"refused"}}`))
+ return
+ }
+ if r.PostForm.Get("quantity") == "" {
+ // A metadata-only update: the fence. It takes the key and
+ // changes nothing that is billed.
+ q.byKey[key], q.params[key] = q.quantity, r.PostForm.Encode()
+ q.fences = append(q.fences, key)
+ _ = json.NewEncoder(w).Encode(item())
+ return
+ }
q.byKey[key], q.params[key] = n, r.PostForm.Encode()
q.posts = append(q.posts, map[string]string{"quantity": r.PostForm.Get("quantity"), "proration_behavior": r.PostForm.Get("proration_behavior"), "proration_date": r.PostForm.Get("proration_date"), "key": key})
if n < q.quantity {
@@ -102,6 +139,26 @@ func (q *quantityStripe) serve(w http.ResponseWriter, r *http.Request) {
}
}
+// fencePass runs the pass that fences an operation nobody can show ran, and
+// checks that it did exactly that: the key is fenced, and the operation is
+// still outstanding for the next pass to settle from a fresh read.
+func fencePass(t *testing.T, fx *consoleFixture, q *quantityStripe) {
+ t.Helper()
+ before, _, _ := fx.st.quantityOperationFor(OpsAccountID)
+ if err := fx.srv.billingQuantityPass(context.Background(), time.Now().UTC()); err != nil {
+ t.Fatal(err)
+ }
+ after, ok, err := fx.st.quantityOperationFor(OpsAccountID)
+ if err != nil || !ok || after.Key != before.Key || after.AckedAt != 0 || after.FencedAt == 0 {
+ t.Fatalf("the pass did not fence the operation and leave it for the next: %+v %v %v", after, ok, err)
+ }
+ q.mu.Lock()
+ defer q.mu.Unlock()
+ if _, held := q.byKey[after.Key]; !held {
+ t.Fatalf("fenced in the store, but the provider does not hold the key %q", after.Key)
+ }
+}
+
// quantityFixtureAge is how long before the test the fixture's subscription
// period began. A test that needs an older period sets it and restores it.
var quantityFixtureAge = time.Hour
@@ -285,11 +342,10 @@ func TestQuantityWorkerChargesTheTransientSeat(t *testing.T) {
}
}
-// The update landed but its response was lost. While the operation is
-// young another process could still be mid-flight with it, so nothing is
-// concluded. After that, the next pass reads the provider, sees the target
-// there, sends nothing and finishes. It cannot know whether its lost
-// request or someone's hand set that quantity, so the additions are
+// The update landed but its response was lost. The sender has returned, so
+// nobody holds the right to send. The next pass reads the provider, sees
+// the target there, sends nothing and finishes. It cannot know whether its
+// lost request or someone's hand set that quantity, so the additions are
// recorded as landed and unverified, the assignment is dated when it was
// observed, and a review holds the account until a person has looked.
func TestQuantityWorkerRecoversALostResponse(t *testing.T) {
@@ -305,14 +361,17 @@ func TestQuantityWorkerRecoversALostResponse(t *testing.T) {
if len(pendingAdditions(t, fx)) != 1 || !quantityHeld(t, fx) {
t.Fatal("an unconfirmed update was marked processed")
}
- // At once: the operation is young and unconfirmed. Nothing is recorded.
- if err := fx.srv.billingQuantityPass(ctx, time.Now().UTC()); !errors.Is(err, errBillingPending) {
- t.Fatalf("a pass over a young unconfirmed operation: %v, want pending", err)
- }
- if state, _ := fx.st.billingAccount(OpsAccountID); state.Quantity != 10 || len(pendingAdditions(t, fx)) != 1 {
- t.Fatalf("a young unconfirmed operation was settled: quantity %d", state.Quantity)
+ if op, _, _ := fx.st.quantityOperationFor(OpsAccountID); op.SendLeaseUntil != 0 || op.ConfirmedAt != 0 {
+ t.Fatalf("after a failed request the operation still carries a lease or a confirmation: %+v", op)
}
+ // The next pass comes a while later. It fences the key (the provider
+ // already holds it, for the request that ran), and the one after
+ // records what the provider shows.
op := ageQuantityOperation(t, fx, 3*time.Minute)
+ fencePass(t, fx, q)
+ if len(q.fences) != 0 || len(q.mismatched) != 1 {
+ t.Fatalf("the key the request ran under was taken again: fences %v mismatched %v", q.fences, q.mismatched)
+ }
if err := fx.srv.billingQuantityPass(ctx, time.Now().UTC()); err != nil {
t.Fatal(err)
}
@@ -792,9 +851,11 @@ func TestQuantityWorkerCannotChargeAcrossARenewal(t *testing.T) {
t.Fatalf("refused=%d posts=%+v quantity=%d: an old period's seat was charged into the new one", q.refused, q.posts, q.quantity)
}
// A request refused over its date did not execute and never will with
- // that date: the operation is retired at once, not replayed for a day.
- if op, ok, _ := fx.st.quantityOperationFor(OpsAccountID); !ok || op.AckedAt == 0 {
- t.Fatalf("the refused operation is still outstanding: %+v", op)
+ // that date. Its key is fenced at once, so it is never replayed and no
+ // copy of it still on its way can run; it is retired once the console
+ // has reconciled the renewal and read the provider again.
+ if op, ok, _ := fx.st.quantityOperationFor(OpsAccountID); !ok || op.AckedAt != 0 || op.FencedAt == 0 || len(q.fences) != 1 || q.fences[0] != op.Key {
+ t.Fatalf("the refused operation was not fenced at once: %+v fences %v", op, q.fences)
}
fx.srv.billing.quantityBeforeSend = nil
// Until the console has reconciled the renewal, nothing more is sent.
@@ -1049,6 +1110,7 @@ func TestQuantityCoincidentalChangeIsNotBackdated(t *testing.T) {
if err != nil || rev.ResolvedAt != 0 || rev.Occurrences != 1 {
t.Fatalf("no open review for the unproven charge date: %+v %v", rev, err)
}
+ fencePass(t, fx, q)
if err := fx.srv.billingQuantityPass(context.Background(), time.Now().UTC()); err != nil {
t.Fatal(err)
}
@@ -1058,23 +1120,199 @@ func TestQuantityCoincidentalChangeIsNotBackdated(t *testing.T) {
assertLastAddition(t, fx, quantityOutcomeUnverified, "si_team:quantity=11:always_invoice")
}
-// A webhook that observes the target while the operation is young and
-// unconfirmed records nothing: the sender may be between the provider's
-// answer and writing it down.
-func TestQuantityWebhookOverAYoungUnconfirmedOperationWaits(t *testing.T) {
+// While a process holds an operation's send lease, nobody concludes
+// anything about it. A webhook that observes the target records nothing; a
+// worker in another process neither retires it nor marks it. Once the
+// lease is gone, the same observations settle it.
+func TestQuantityNothingIsConcludedWhileASenderHoldsTheLease(t *testing.T) {
fx, q, now := newQuantityFixture(t, billingProrationImmediate)
+ ctx := context.Background()
if err := fx.st.AddMember(OpsAccountID, "eleventh@example.test", RoleMember, now.Add(time.Minute)); err != nil {
t.Fatal(err)
}
- q.failAfter = true
- if err := fx.srv.billingQuantityPass(context.Background(), time.Now().UTC()); err == nil {
- t.Fatal("a lost response must surface as an error")
+ procB := &Server{cfg: fx.srv.cfg, store: fx.srv.store, billing: &billingRuntime{locks: newStoreWalkLocks(), client: fx.srv.billing.client, cfg: fx.srv.billing.cfg}}
+ seen := false
+ q.duringPost = func() {
+ if seen {
+ return
+ }
+ seen = true
+ // The request is at the provider and has not taken effect. The
+ // sender holds the lease.
+ op, ok, _ := fx.st.quantityOperationFor(OpsAccountID)
+ if !ok || op.SendLeaseUntil <= time.Now().Unix() {
+ t.Errorf("no live send lease while the request is in flight: %+v", op)
+ }
+ // Another process tries to retire it, as the replay limit or a
+ // moved period would have it do.
+ if retired, err := fx.st.retireQuantityOperation(OpsAccountID, op.Key, time.Now()); err != nil || retired {
+ t.Errorf("an operation was retired under its sender: retired=%v err=%v", retired, err)
+ }
+ // A whole pass of another process may finish this same operation
+ // under its own key (the provider runs a key once). It may not
+ // retire it unrun or put another in its place.
+ if err := procB.billingQuantityPass(ctx, time.Now().UTC()); err != nil && !errors.Is(err, errBillingPending) {
+ t.Errorf("process B: %v", err)
+ }
+ after, _, _ := fx.st.quantityOperationFor(OpsAccountID)
+ q.mu.Lock()
+ atProvider := q.quantity
+ q.mu.Unlock()
+ if after.Key != op.Key || (after.AckedAt != 0 && atProvider != op.Target) {
+ t.Errorf("the operation was retired unrun or replaced while its request was in flight: %+v (provider at %d)", after, atProvider)
+ }
}
- if err := fx.srv.reconcileBillingSubscription(context.Background(), "sub_team", "evt_quantity_young", time.Now().UTC()); !errors.Is(err, errBillingPending) {
- t.Fatalf("webhook over a young unconfirmed operation: %v, want pending", err)
+ if err := fx.srv.billingQuantityPass(ctx, time.Now().UTC()); err != nil {
+ t.Fatal(err)
}
- if state, _ := fx.st.billingAccount(OpsAccountID); state.Quantity != 10 {
- t.Fatalf("billed quantity %d: the change was recorded", state.Quantity)
+ if !seen || len(q.posts) != 1 || q.lowered {
+ t.Fatalf("seen=%v posts=%+v lowered=%v: the one operation ran once", seen, q.posts, q.lowered)
+ }
+ assertLastAddition(t, fx, quantityOutcomeCharged, "si_team:quantity=11:always_invoice")
+ if op, _, _ := fx.st.quantityOperationFor(OpsAccountID); op.AckedAt == 0 || op.ConfirmedAt == 0 {
+ t.Fatalf("the operation is not finished and confirmed: %+v", op)
+ }
+}
+
+// A webhook that observes the target while a sender holds the lease
+// records nothing, and the worker in another process concludes nothing.
+// When the lease is gone the same observation settles.
+func TestQuantityReconcileWaitsWhileASenderHoldsTheLease(t *testing.T) {
+ fx, q, op := agedQuantityFixture(t)
+ ctx := context.Background()
+ q.mu.Lock()
+ q.quantity = 11
+ q.mu.Unlock()
+ if _, ok, err := fx.st.claimQuantitySend(OpsAccountID, op.Key, time.Now()); err != nil || !ok {
+ t.Fatalf("claim: %v %v", ok, err)
+ }
+ if err := fx.srv.reconcileBillingSubscription(ctx, "sub_team", "evt_quantity_in_flight", time.Now().UTC()); !errors.Is(err, errBillingPending) {
+ t.Fatalf("a reconcile while a sender holds the lease: %v, want pending", err)
+ }
+ if err := fx.srv.billingQuantityPass(ctx, time.Now().UTC()); err != nil {
+ t.Fatal(err)
+ }
+ if state, _ := fx.st.billingAccount(OpsAccountID); state.Quantity != 10 || len(pendingAdditions(t, fx)) != 1 {
+ t.Fatalf("something was concluded under a live lease: quantity %d", state.Quantity)
+ }
+ if cur, _, _ := fx.st.quantityOperationFor(OpsAccountID); cur.AckedAt != 0 {
+ t.Fatalf("the operation was acknowledged under a live lease: %+v", cur)
+ }
+ if len(q.fences)+len(q.mismatched) != 0 {
+ t.Fatalf("the key was fenced under a live lease: %v %v", q.fences, q.mismatched)
+ }
+ if err := fx.st.releaseQuantitySend(OpsAccountID, op.Key); err != nil {
+ t.Fatal(err)
+ }
+ fencePass(t, fx, q)
+ if err := fx.srv.billingQuantityPass(ctx, time.Now().UTC()); err != nil {
+ t.Fatal(err)
+ }
+ assertLastAddition(t, fx, quantityOutcomeUnverified, "si_team:quantity=11:always_invoice")
+}
+
+// The schedule of review round six. A recovery worker reads an operation
+// just inside the replay limit and goes to send it. Before its request
+// takes effect the operation crosses the limit, and another worker, seeing
+// the provider still at the old quantity, would retire it. It must not:
+// the first worker holds the lease. Without the lease the operation is
+// retired, a replacement for a higher target completes, and the first
+// worker's request then lowers the quantity.
+func TestQuantityOperationIsNotRetiredAcrossTheReplayLimitUnderItsSender(t *testing.T) {
+ fx, q, op := agedQuantityFixture(t)
+ ctx := context.Background()
+ // Just inside the limit when the recovery worker reads it.
+ inside := time.Now().Add(-quantityOperationReplayLimit + 30*time.Second).Unix()
+ if _, err := fx.st.db.Exec(`UPDATE billing_quantity_operations SET created_at=? WHERE account_id=?`, inside, OpsAccountID); err != nil {
+ t.Fatal(err)
+ }
+ procB := &Server{cfg: fx.srv.cfg, store: fx.srv.store, billing: &billingRuntime{locks: newStoreWalkLocks(), client: fx.srv.billing.client, cfg: fx.srv.billing.cfg}}
+ seen := false
+ q.duringPost = func() {
+ if seen {
+ return
+ }
+ seen = true
+ // The operation is now past the limit, and another member joins.
+ if _, err := fx.st.db.Exec(`UPDATE billing_quantity_operations SET created_at=? WHERE account_id=?`, time.Now().Add(-quantityOperationReplayLimit-time.Minute).Unix(), OpsAccountID); err != nil {
+ t.Error(err)
+ }
+ if err := fx.st.AddMember(OpsAccountID, "twelfth@example.test", RoleMember, time.Now().UTC()); err != nil {
+ t.Error(err)
+ }
+ for i := 0; i < 2; i++ {
+ if err := procB.billingQuantityPass(ctx, time.Now().UTC()); err != nil && !errors.Is(err, errBillingPending) {
+ t.Errorf("process B pass %d: %v", i, err)
+ }
+ }
+ after, _, _ := fx.st.quantityOperationFor(OpsAccountID)
+ if after.Key != op.Key || after.AckedAt != 0 {
+ t.Errorf("the operation was retired or replaced under its sender: %+v", after)
+ }
+ // Nor is its key taken at the provider while its sender holds the
+ // lease: that would refuse the sender's own request.
+ q.mu.Lock()
+ if len(q.fences)+len(q.mismatched) != 0 || after.FencedAt != 0 {
+ t.Errorf("the operation was fenced under its sender: fences %v mismatched %v %+v", q.fences, q.mismatched, after)
+ }
+ q.mu.Unlock()
+ }
+ // The recovery worker's own request carries the original date, which
+ // the test's provider accepts.
+ if _, err := fx.st.db.Exec(`UPDATE billing_quantity_operations SET created_at=? WHERE account_id=?`, inside, OpsAccountID); err != nil {
+ t.Fatal(err)
+ }
+ _ = fx.srv.billingQuantityPass(ctx, time.Now().UTC())
+ q.duringPost = nil
+ if !seen {
+ t.Fatal("the request never reached the provider")
+ }
+ for i := 0; i < 3; i++ {
+ _ = fx.srv.billingQuantityPass(ctx, time.Now().UTC())
+ }
+ if q.lowered {
+ t.Fatalf("the provider's quantity was lowered: posts %+v", q.posts)
+ }
+ for i := 1; i < len(q.posts); i++ {
+ prev, _ := strconv.Atoi(q.posts[i-1]["quantity"])
+ cur, _ := strconv.Atoi(q.posts[i]["quantity"])
+ if cur < prev {
+ t.Fatalf("requests went 11 after 12: %+v", q.posts)
+ }
+ }
+}
+
+// The two-webhook schedule of review round six. An unconfirmed operation
+// (10 to 11) never executes. Someone sets the provider to 12, then to 11.
+// Neither webhook is the operation's own transition, so neither reconcile
+// files a dating hold. The worker then finds the target and records the
+// additions as landed and unverified. That record must carry its hold with
+// it: an addition is never cleared as unverified without a review.
+func TestQuantityUnverifiedAdditionIsNeverClearedWithoutAHold(t *testing.T) {
+ fx, q, op := agedQuantityFixture(t)
+ ctx := context.Background()
+ for i, quantity := range []int64{12, 11} {
+ q.mu.Lock()
+ q.quantity = quantity
+ q.mu.Unlock()
+ if err := fx.srv.reconcileBillingSubscription(ctx, "sub_team", fmt.Sprintf("evt_by_hand_%d", i), time.Now().UTC()); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if rev, err := fx.st.billingReview(OpsAccountID, "sub_team", billingQuantityDateReviewReason(op.CreatedAt)); err == nil && rev.Occurrences > 0 {
+ t.Fatalf("fixture: a reconcile already filed the hold: %+v", rev)
+ }
+ fencePass(t, fx, q)
+ if err := fx.srv.billingQuantityPass(ctx, time.Now().UTC()); err != nil {
+ t.Fatal(err)
+ }
+ if len(q.posts) != 0 {
+ t.Fatalf("the worker sent: %+v", q.posts)
+ }
+ assertLastAddition(t, fx, quantityOutcomeUnverified, "si_team:quantity=11:always_invoice")
+ rev, err := fx.st.billingReview(OpsAccountID, "sub_team", billingQuantityDateReviewReason(op.CreatedAt))
+ if err != nil || rev.ResolvedAt != 0 || rev.Occurrences != 1 {
+ t.Fatalf("the addition was cleared as unverified with no open hold: %+v %v", rev, err)
}
}
@@ -1352,11 +1590,14 @@ func TestQuantityWorkerClosesAnOperationOfAnEndedSubscription(t *testing.T) {
t.Fatal(err)
}
gets := q.gets
+ // The only thing the provider is asked is to fence the key of the
+ // operation nobody can show ran. Then it is acknowledged.
+ fencePass(t, fx, q)
if err := fx.srv.billingQuantityPass(ctx, time.Now().UTC()); err != nil {
t.Fatal(err)
}
- if q.gets != gets || len(q.posts) != 0 {
- t.Fatalf("an ended subscription reached the provider: gets %d -> %d posts=%v", gets, q.gets, q.posts)
+ if q.gets != gets || len(q.posts) != 0 || len(q.fences) != 1 {
+ t.Fatalf("an ended subscription reached the provider: gets %d -> %d posts=%v fences=%v", gets, q.gets, q.posts, q.fences)
}
if op, ok, _ := fx.st.quantityOperationFor(OpsAccountID); !ok || op.AckedAt == 0 {
t.Fatalf("operation left outstanding: %+v", op)
@@ -1386,6 +1627,12 @@ func TestQuantityWorkerNeverReplaysAForgottenKey(t *testing.T) {
if _, err := fx.st.db.Exec(`UPDATE billing_quantity_operations SET created_at=?, idempotency_key='carloku-qty-aged' WHERE account_id=?`, aged, OpsAccountID); err != nil {
t.Fatal(err)
}
+ // Fenced first: the provider has forgotten the key, so without the
+ // fence a copy of the old request still on its way would run as new.
+ fencePass(t, fx, q)
+ if len(q.fences) != 1 || q.fences[0] != "carloku-qty-aged" {
+ t.Fatalf("the expired operation's key was not taken at the provider: %v", q.fences)
+ }
if err := fx.srv.billingQuantityPass(ctx, time.Now().UTC()); err != nil {
t.Fatal(err)
}
@@ -1453,7 +1700,7 @@ func TestQuantityWorkerFinishesAnOutstandingOperation(t *testing.T) {
if op, ok, _ := fx.st.quantityOperationFor(OpsAccountID); !ok || op.AckedAt != 0 || op.Target != 11 || op.ConfirmedAt != 0 {
t.Fatalf("operation not left outstanding and unconfirmed: %+v %v", op, ok)
}
- ageQuantityOperation(t, fx, 3*time.Minute)
+ fencePass(t, fx, q)
if err := fx.srv.billingQuantityPass(ctx, time.Now().UTC()); err != nil {
t.Fatal(err)
}
@@ -1515,3 +1762,299 @@ func TestQuantityProcessingGuardIsReplacedWhenItsBodyChanges(t *testing.T) {
}
st.Close()
}
+
+// A timed lease alone does not fence a request that is already on its way.
+//
+// A sender takes the send lease and issues its request. The request does
+// not arrive: the sender was paused between its last check and the wire, or
+// the request sat in a proxy. Three hours pass. The lease has long run out
+// and the operation has crossed the replay limit, so another process
+// retires it, records a replacement for a higher quantity, and that lands.
+// Then the old request arrives, under a key the provider never saw before
+// the retirement.
+//
+// Nothing in the console can stop it now. The provider does: the retiring
+// process took the key at the provider before it retired the operation, so
+// the late request is refused and the quantity is not lowered.
+func TestQuantityPausedSenderIsRefusedByTheProviderAfterItsOperationIsRetired(t *testing.T) {
+ fx, q, op := agedQuantityFixture(t)
+ ctx := context.Background()
+ var pause time.Duration
+ prev := quantityClock
+ quantityClock = func() time.Time { return time.Now().Add(pause) }
+ t.Cleanup(func() { quantityClock = prev })
+ procB := &Server{cfg: fx.srv.cfg, store: fx.srv.store, billing: &billingRuntime{locks: newStoreWalkLocks(), client: fx.srv.billing.client, cfg: fx.srv.billing.cfg}}
+ arrivedLate := false
+ q.duringPost = func() {
+ if arrivedLate {
+ return
+ }
+ arrivedLate = true
+ leased, _, _ := fx.st.quantityOperationFor(OpsAccountID)
+ if leased.Key != op.Key || leased.SendLeaseUntil <= quantityClock().Unix() {
+ t.Errorf("fixture: the sender did not hold the lease when it sent: %+v", leased)
+ }
+ // The request is on its way, and stays there for three hours.
+ pause = 3 * time.Hour
+ if leased.SendLeaseUntil > quantityClock().Unix() {
+ t.Errorf("fixture: the lease outlived the pause: %+v", leased)
+ }
+ if err := fx.st.AddMember(OpsAccountID, "twelfth@example.test", RoleMember, quantityClock().UTC().Add(-time.Second)); err != nil {
+ t.Error(err)
+ }
+ for i := 0; i < 4; i++ {
+ if err := procB.billingQuantityPass(ctx, quantityClock().UTC()); err != nil && !errors.Is(err, errBillingPending) {
+ t.Errorf("process B pass %d: %v", i, err)
+ }
+ }
+ after, _, _ := fx.st.quantityOperationFor(OpsAccountID)
+ q.mu.Lock()
+ defer q.mu.Unlock()
+ if after.Key == op.Key || after.Target != 12 || q.quantity != 12 {
+ t.Errorf("fixture: process B did not retire the old operation and land its replacement: %+v, provider at %d", after, q.quantity)
+ }
+ if len(q.fences) != 1 || q.fences[0] != op.Key {
+ t.Errorf("the old operation was retired without its key being taken at the provider: fences %v", q.fences)
+ }
+ }
+ // The sender: the operation is twenty hours old, inside the replay
+ // limit, and the provider shows it has not run. It sends.
+ err := fx.srv.billingQuantityPass(ctx, quantityClock().UTC())
+ q.duringPost = nil
+ if !arrivedLate {
+ t.Fatal("the request never reached the provider")
+ }
+ if err == nil {
+ t.Fatal("the late request's refusal must surface in its sender")
+ }
+ q.mu.Lock()
+ quantity, lowered, posts, mismatched := q.quantity, q.lowered, append([]map[string]string(nil), q.posts...), append([]string(nil), q.mismatched...)
+ q.mu.Unlock()
+ if lowered || quantity != 12 {
+ t.Fatalf("the late request ran over the replacement: provider at %d, posts %+v", quantity, posts)
+ }
+ for _, p := range posts {
+ if p["key"] == op.Key {
+ t.Fatalf("the retired operation's request executed: %+v", posts)
+ }
+ }
+ if len(mismatched) != 1 || mismatched[0] != op.Key {
+ t.Fatalf("the provider did not refuse the late request by its key: %v", mismatched)
+ }
+ for i := 0; i < 3; i++ {
+ if err := fx.srv.billingQuantityPass(ctx, quantityClock().UTC()); err != nil && !errors.Is(err, errBillingPending) {
+ t.Fatal(err)
+ }
+ }
+ if state, _ := fx.st.billingAccount(OpsAccountID); state.Quantity != 12 {
+ t.Fatalf("billed quantity %d, want 12", state.Quantity)
+ }
+ if cur, _, _ := fx.st.quantityOperationFor(OpsAccountID); cur.AckedAt == 0 || cur.Key == op.Key {
+ t.Fatalf("the replacement was not completed: %+v", cur)
+ }
+}
+
+// Every statement that acknowledges an operation refuses one that is
+// neither confirmed by the provider nor fenced there. This is the store's
+// half of the fence: no code path can retire, land or close an operation
+// nobody can show ran while a request for it could still take effect.
+func TestQuantityOperationIsNeverAcknowledgedUnfenced(t *testing.T) {
+ for _, how := range []string{"retire", "landed", "mark", "ended"} {
+ t.Run(how, func(t *testing.T) {
+ fx, _, op := agedQuantityFixture(t)
+ now := time.Now().UTC()
+ state, _ := fx.st.billingAccount(OpsAccountID)
+ acknowledge := func() {
+ switch how {
+ case "retire":
+ if retired, err := fx.st.retireQuantityOperation(OpsAccountID, op.Key, now); err != nil {
+ t.Fatal(err)
+ } else if retired != (op.FencedAt != 0) {
+ t.Fatalf("retired=%v with fenced_at=%d", retired, op.FencedAt)
+ }
+ case "landed":
+ err := fx.st.markOperationLanded(op, quantityOutcomeUnverified, "ref", now)
+ if (err == nil) != (op.FencedAt != 0) {
+ t.Fatalf("markOperationLanded err=%v with fenced_at=%d", err, op.FencedAt)
+ }
+ case "mark":
+ err := fx.st.markQuantityAdditionsProcessed(OpsAccountID, state.Subscription, state.PeriodStart, 0, quantityOutcomeIncluded, "ref", op.Key, now)
+ if (err == nil) != (op.FencedAt != 0) {
+ t.Fatalf("markQuantityAdditionsProcessed err=%v with fenced_at=%d", err, op.FencedAt)
+ }
+ case "ended":
+ if _, err := fx.st.db.Exec(`UPDATE billing_accounts SET status='canceled' WHERE account_id=?`, OpsAccountID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.closeEndedQuantityAdditions(OpsAccountID, now); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ acknowledge()
+ if cur, _, _ := fx.st.quantityOperationFor(OpsAccountID); cur.AckedAt != 0 {
+ t.Fatalf("acknowledged with neither a confirmation nor a fence: %+v", cur)
+ }
+ if _, ok, err := fx.st.claimQuantitySend(OpsAccountID, op.Key, now.Add(-time.Hour)); err != nil || !ok {
+ t.Fatalf("an unfenced operation could not be claimed: %v %v", ok, err)
+ }
+ if err := fx.st.markQuantityFenced(OpsAccountID, op.Key, now); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok, err := fx.st.claimQuantitySend(OpsAccountID, op.Key, now); err != nil || ok {
+ t.Fatalf("a fenced operation was claimed for sending: %v %v", ok, err)
+ }
+ op, _, _ = fx.st.quantityOperationFor(OpsAccountID)
+ acknowledge()
+ if cur, _, _ := fx.st.quantityOperationFor(OpsAccountID); cur.AckedAt == 0 {
+ t.Fatalf("a fenced operation could not be acknowledged: %+v", cur)
+ }
+ })
+ }
+}
+
+// A decision to retire is made on a provider read taken after the fence,
+// never on one taken before it. Here the worker reads the provider, finds
+// an aged operation has not run, and goes to retire it. Between that read
+// and the fence, the operation's own request (held on its way until now)
+// arrives and runs. The fence then finds the key already taken. The
+// operation ran: it must be recorded as landed, with the hold that says
+// its date is unproven, and not retired as never sent.
+func TestQuantityRequestThatRanJustBeforeTheFenceIsRecordedNotRetired(t *testing.T) {
+ fx, q, op := agedQuantityFixture(t)
+ ctx := context.Background()
+ if _, err := fx.st.db.Exec(`UPDATE billing_quantity_operations SET created_at=? WHERE account_id=?`, time.Now().Add(-quantityOperationReplayLimit-time.Minute).Unix(), OpsAccountID); err != nil {
+ t.Fatal(err)
+ }
+ op, _, _ = fx.st.quantityOperationFor(OpsAccountID)
+ ranLate := false
+ fx.srv.billing.quantityBeforeFence = func() {
+ if ranLate {
+ return
+ }
+ ranLate = true
+ if _, err := fx.srv.billing.client.UpdateQuantityWith(ctx, op.Item, op.Target, op.Behavior, time.Unix(op.CreatedAt, 0), op.Key); err != nil {
+ t.Errorf("the held request: %v", err)
+ }
+ }
+ if err := fx.srv.billingQuantityPass(ctx, time.Now().UTC()); err != nil {
+ t.Fatal(err)
+ }
+ if !ranLate {
+ t.Fatal("the worker never went to fence the aged operation")
+ }
+ cur, _, _ := fx.st.quantityOperationFor(OpsAccountID)
+ if cur.AckedAt != 0 || cur.FencedAt == 0 {
+ t.Fatalf("the operation was retired on the read taken before the fence: %+v", cur)
+ }
+ if len(q.fences) != 0 || len(q.mismatched) != 1 {
+ t.Fatalf("fences %v mismatched %v: the key was the request's, the fence must have found it taken", q.fences, q.mismatched)
+ }
+ for i := 0; i < 2; i++ {
+ if err := fx.srv.billingQuantityPass(ctx, time.Now().UTC()); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if len(q.posts) != 1 || q.posts[0]["key"] != op.Key {
+ t.Fatalf("posts %+v: want only the operation's own request", q.posts)
+ }
+ assertLastAddition(t, fx, quantityOutcomeUnverified, "si_team:quantity=11:always_invoice")
+ rev, err := fx.st.billingReview(OpsAccountID, "sub_team", billingQuantityDateReviewReason(op.CreatedAt))
+ if err != nil || rev.ResolvedAt != 0 {
+ t.Fatalf("no open hold for the charge whose date is unproven: %+v %v", rev, err)
+ }
+ if state, _ := fx.st.billingAccount(OpsAccountID); state.Quantity != 11 {
+ t.Fatalf("billed quantity %d, want 11", state.Quantity)
+ }
+}
+
+// A fence the provider refuses in a way it will repeat cannot be waited
+// out. The operation stays outstanding (it is never acknowledged unfenced)
+// and a review is filed, once, so a person sees why the account's quantity
+// work has stopped.
+func TestQuantityRefusedFenceFilesAReviewAndAcknowledgesNothing(t *testing.T) {
+ fx, q, op := agedQuantityFixture(t)
+ ctx := context.Background()
+ if _, err := fx.st.db.Exec(`UPDATE billing_quantity_operations SET created_at=? WHERE account_id=?`, time.Now().Add(-quantityOperationReplayLimit-time.Minute).Unix(), OpsAccountID); err != nil {
+ t.Fatal(err)
+ }
+ q.mu.Lock()
+ q.refuseFence = true
+ q.mu.Unlock()
+ for i := 0; i < 3; i++ {
+ if err := fx.srv.billingQuantityPass(ctx, time.Now().UTC()); err == nil {
+ t.Fatal("a refused fence must surface")
+ }
+ }
+ if cur, _, _ := fx.st.quantityOperationFor(OpsAccountID); cur.Key != op.Key || cur.AckedAt != 0 || cur.FencedAt != 0 {
+ t.Fatalf("acknowledged or marked fenced over a refused fence: %+v", cur)
+ }
+ rev, err := fx.st.billingReview(OpsAccountID, "sub_team", billingQuantityFenceReason)
+ if err != nil || rev.ResolvedAt != 0 || rev.Occurrences != 1 {
+ t.Fatalf("review for the refused fence: %+v %v", rev, err)
+ }
+ if len(q.posts) != 0 {
+ t.Fatalf("sent: %+v", q.posts)
+ }
+}
+
+// A fenced operation the provider shows did not run is retired by the next
+// pass, whatever its age, and is never sent: its key is no longer its own.
+// The addition it covered is still owed, and a fresh operation charges it.
+func TestQuantityFencedOperationIsRetiredAndReplacedNeverSent(t *testing.T) {
+ fx, q, op := agedQuantityFixture(t)
+ ctx := context.Background()
+ // The fixture ages the operation's date and not its key, which would
+ // otherwise be the key a fresh operation computes in this same second.
+ if _, err := fx.st.db.Exec(`UPDATE billing_quantity_operations SET idempotency_key='carloku-qty-aged' WHERE account_id=?`, OpsAccountID); err != nil {
+ t.Fatal(err)
+ }
+ op.Key = "carloku-qty-aged"
+ if err := fx.st.markQuantityFenced(OpsAccountID, op.Key, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.srv.billingQuantityPass(ctx, time.Now().UTC()); err != nil {
+ t.Fatal(err)
+ }
+ if cur, _, _ := fx.st.quantityOperationFor(OpsAccountID); cur.Key != op.Key || cur.AckedAt == 0 {
+ t.Fatalf("the fenced operation was not retired: %+v", cur)
+ }
+ if len(q.posts) != 0 {
+ t.Fatalf("the fenced operation was sent: %+v", q.posts)
+ }
+ if len(pendingAdditions(t, fx)) != 1 {
+ t.Fatal("the addition the retired operation covered is no longer owed")
+ }
+ if err := fx.srv.billingQuantityPass(ctx, time.Now().UTC()); err != nil {
+ t.Fatal(err)
+ }
+ if len(q.posts) != 1 || q.posts[0]["key"] == op.Key || q.posts[0]["quantity"] != "11" {
+ t.Fatalf("posts %+v: want one fresh request for 11 under a fresh key", q.posts)
+ }
+ if state, _ := fx.st.billingAccount(OpsAccountID); state.Quantity != 11 || len(pendingAdditions(t, fx)) != 0 {
+ t.Fatalf("not charged afresh: quantity %d", state.Quantity)
+ }
+}
+
+// A key is never used for a second operation: the previous one may have
+// been fenced, and the provider then holds that key for another request.
+func TestBeginQuantityOperationNeverReusesAKey(t *testing.T) {
+ fx, _, op := agedQuantityFixture(t)
+ if err := fx.st.markQuantityFenced(OpsAccountID, op.Key, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if retired, err := fx.st.retireQuantityOperation(OpsAccountID, op.Key, time.Now()); err != nil || !retired {
+ t.Fatalf("retire: %v %v", retired, err)
+ }
+ state, _ := fx.st.billingAccount(OpsAccountID)
+ again := op
+ again.AckedAt, again.FencedAt, again.Revision = 0, 0, state.Revision
+ again.CreatedAt = time.Now().Unix()
+ if started, err := fx.st.beginQuantityOperation(again); err != nil || started {
+ t.Fatalf("an operation was recorded under its predecessor's key: started=%v err=%v", started, err)
+ }
+ again.Key = op.Key + "-next"
+ if started, err := fx.st.beginQuantityOperation(again); err != nil || !started {
+ t.Fatalf("a fresh key was refused: started=%v err=%v", started, err)
+ }
+}
diff --git a/internal/console/billingservice.go b/internal/console/billingservice.go
index 6b5770a81..14bc6a5f7 100644
--- a/internal/console/billingservice.go
+++ b/internal/console/billingservice.go
@@ -50,6 +50,9 @@ type billingRuntime struct {
// quantityAfterSend runs after the provider accepted an update and the
// operation was confirmed, before the worker's own reconcile.
quantityAfterSend func()
+ // quantityBeforeFence runs before an operation's key is fenced at the
+ // provider. Test seam only.
+ quantityBeforeFence func()
}
var (
@@ -504,14 +507,15 @@ func (s *Server) reconcileBillingSubscription(ctx context.Context, id, eventID s
// success answer to that operation (ConfirmedAt) shows the
// operation is what changed it. Without that answer:
//
- // - a young operation may be mid-flight in another process,
- // its answer not yet recorded. Nothing is recorded now; the
- // event is retried and the worker's next pass settles it;
- // - an older one is ambiguous for good (a lost response, or
- // someone set the same number by hand). The change is
+ // - while a process holds the operation's send lease, its
+ // request may be in flight or its answer not yet recorded.
+ // Nothing is recorded now; the event is retried and the
+ // worker's next pass settles it;
+ // - with no lease live it is ambiguous for good (a lost
+ // response, or someone set the same number by hand). The change is
// recorded when observed, and the store files a review,
// because the instant the provider charged from is unknown.
- if op.ConfirmedAt == 0 && time.Now().Unix()-op.CreatedAt < int64(quantityOperationSendWindow.Seconds()) {
+ if op.ConfirmedAt == 0 && op.SendLeaseUntil > quantityClock().Unix() {
return errBillingPending
}
quantityStart, quantityKey, quantityConfirmed = time.Unix(op.CreatedAt, 0).UTC(), op.Key, op.ConfirmedAt != 0
diff --git a/internal/host/agent.go b/internal/host/agent.go
index cad1267f8..6b1bebf83 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -253,12 +253,6 @@ func RunAgent(args []string) {
if durabilityErr != nil {
log.Printf("agent: durability: %v", durabilityErr)
}
- // The scoped replication attestation is off unless this box's
- // environment asks for it by name. Unset, the ledger attests nothing.
- if scopedAttestationEnabled(os.Getenv) {
- durabilityPub.enableScopedAttestation(newReplicatorProbe(), reg.List, dd)
- log.Printf("agent: durability: scoped attestation enabled (%s=scoped)", durabilityAttestEnv)
- }
go durabilityPub.loop()
// Fleet box identity (customer-fleets plan, Task 6 fix round — controller
diff --git a/internal/host/durability.go b/internal/host/durability.go
index 90355bb53..8d3ca3657 100644
--- a/internal/host/durability.go
+++ b/internal/host/durability.go
@@ -42,11 +42,6 @@ type durabilityPublisher struct {
stopped atomic.Bool
bindingFailed atomic.Bool
registered bool
- // attest, when set (enableScopedAttestation), observes the box and
- // applies the scoped attestation. It runs before every seal. Nil is
- // the default and the production state: nothing is attested.
- attest func(context.Context)
- scopeVerifiedAt time.Time
}
func newDurabilityPublisher(dd, box string, now func() time.Time) (*durabilityPublisher, error) {
@@ -156,10 +151,6 @@ func (p *durabilityPublisher) publish(ctx context.Context, store release.Store)
if p.stopped.Load() {
return errors.New("durability_publisher_stopped")
}
- // The claim is checked before anything is sealed under it.
- if p.attest != nil {
- p.attest(ctx)
- }
if err := p.ledger.Seal(); err != nil {
return err
}
diff --git a/internal/host/durabilityreplicator.go b/internal/host/durabilityreplicator.go
deleted file mode 100644
index 456079619..000000000
--- a/internal/host/durabilityreplicator.go
+++ /dev/null
@@ -1,255 +0,0 @@
-package host
-
-import (
- "context"
- "errors"
- "fmt"
- "github.com/carlosframework/platform/internal/registry"
- "github.com/carlosframework/platform/internal/usage"
- "os"
- "os/exec"
- "path/filepath"
- "strconv"
- "strings"
- "time"
-
- "github.com/carlosframework/platform/internal/ops"
-)
-
-// The reader of the external replicator's state, for durabilityScope.
-// Like the scope, NOTHING IN THE AGENT CALLS THIS YET.
-
-const (
- externalReplicatorUnit = "carlos-litestream.service"
- externalReplicatorConfig = "/etc/litestream.yml"
-)
-
-// replicatorProbe reads what the box can observe about the box-wide
-// litestream unit. Its three functions are seams; newReplicatorProbe wires
-// the real ones.
-type replicatorProbe struct {
- configPath string
- unit string
- readFile func(string) ([]byte, error)
- modTime func(string) (time.Time, error)
- // unitState reports whether the unit is active and since when, and
- // the command it runs.
- unitState func(ctx context.Context, unit string) (active bool, since time.Time, argv []string, err error)
- // resolve follows symlinks.
- resolve func(string) (string, error)
-}
-
-func newReplicatorProbe() replicatorProbe {
- return replicatorProbe{
- configPath: externalReplicatorConfig,
- unit: externalReplicatorUnit,
- readFile: os.ReadFile,
- modTime: func(path string) (time.Time, error) {
- fi, err := os.Stat(path)
- if err != nil {
- return time.Time{}, err
- }
- return fi.ModTime(), nil
- },
- unitState: systemdUnitState,
- resolve: filepath.EvalSymlinks,
- }
-}
-
-// read observes the replicator. Anything it cannot establish is an Err, and
-// an Err attests nothing (durabilityScope).
-//
-// The unit and the config's modification time are read before and after
-// the config itself. If either moved (a restart or a rewrite landed while
-// this was reading), what was read may belong to neither state, so nothing
-// is concluded this time. between, when not nil, runs inside that bracket:
-// the caller reads the registry there, so the routes it pairs with this
-// state are from the same moment (readDurabilityScope).
-//
-// A config that is not, line for line, what the generator writes is an
-// Err: this code will not speak for a hand-kept config, because it cannot
-// know it has seen every database in it. A box whose config is still kept
-// by hand must be cut over to the generated one before it can attest.
-//
-// A running unit must be running the command this code expects, the
-// replicator reading that one config file and nothing else. A unit given
-// databases on its command line, or another config, replicates what this
-// code cannot see.
-//
-// Every configured path is resolved through symlinks, and both the path as
-// written and its target are reported. A path that cannot be resolved is
-// an Err.
-func (p replicatorProbe) read(ctx context.Context, between func() error) replicatorState {
- active, since, argv, err := p.unitState(ctx, p.unit)
- if err != nil {
- return replicatorState{Err: fmt.Errorf("unit %s: %w", p.unit, err)}
- }
- if active && !expectedReplicatorCommand(argv, p.configPath) {
- return replicatorState{Err: fmt.Errorf("unit %s runs %q, not the replicator on %s alone", p.unit, strings.Join(argv, " "), p.configPath)}
- }
- out := replicatorState{UnitActive: active, UnitStarted: since}
- raw, err := p.readFile(p.configPath)
- switch {
- case errors.Is(err, os.ErrNotExist):
- // No config: the external replicator has nothing to replicate. A
- // unit that is nevertheless running is replicating from something
- // this code cannot see.
- if active {
- return replicatorState{Err: fmt.Errorf("unit %s is running and %s does not exist", p.unit, p.configPath)}
- }
- if between != nil {
- if err := between(); err != nil {
- return replicatorState{Err: err}
- }
- }
- active2, _, _, err := p.unitState(ctx, p.unit)
- if err != nil || active2 {
- return replicatorState{Err: fmt.Errorf("unit %s changed state while it was being read", p.unit)}
- }
- if _, err := p.readFile(p.configPath); !errors.Is(err, os.ErrNotExist) {
- return replicatorState{Err: fmt.Errorf("%s appeared while it was being read", p.configPath)}
- }
- return out
- case err != nil:
- return replicatorState{Err: fmt.Errorf("read %s: %w", p.configPath, err)}
- }
- modified, err := p.modTime(p.configPath)
- if err != nil {
- return replicatorState{Err: fmt.Errorf("stat %s: %w", p.configPath, err)}
- }
- written, err := ops.GeneratedLitestreamDBs(string(raw))
- if err != nil {
- return replicatorState{Err: fmt.Errorf("%s: %w", p.configPath, err)}
- }
- paths := make([]string, 0, len(written))
- for _, path := range written {
- paths = append(paths, path)
- real, err := p.resolve(path)
- switch {
- case errors.Is(err, os.ErrNotExist):
- // Named and not there: nothing is streamed from it now, and
- // the path as written still names its owner.
- case err != nil:
- return replicatorState{Err: fmt.Errorf("resolve %s: %w", path, err)}
- case filepath.Clean(real) != filepath.Clean(path):
- paths = append(paths, filepath.Clean(real))
- }
- }
- if between != nil {
- if err := between(); err != nil {
- return replicatorState{Err: err}
- }
- }
- // The config, its modification time and the unit again: all must be
- // what they were.
- again, err := p.readFile(p.configPath)
- if err != nil || string(again) != string(raw) {
- return replicatorState{Err: fmt.Errorf("%s changed while it was being read", p.configPath)}
- }
- modified2, err := p.modTime(p.configPath)
- if err != nil || !modified2.Equal(modified) {
- return replicatorState{Err: fmt.Errorf("%s was rewritten while it was being read", p.configPath)}
- }
- active2, since2, argv2, err := p.unitState(ctx, p.unit)
- if err != nil {
- return replicatorState{Err: fmt.Errorf("unit %s: %w", p.unit, err)}
- }
- if active2 != active || !since2.Equal(since) || strings.Join(argv2, "\x00") != strings.Join(argv, "\x00") {
- return replicatorState{Err: fmt.Errorf("unit %s changed state while its config was being read", p.unit)}
- }
- out.ConfigPresent, out.Paths, out.ConfigModified = true, paths, modified
- return out
-}
-
-// expectedReplicatorCommand reports whether argv is the box-wide
-// replicator as bootstrap installs it: the litestream binary, replicate,
-// and this config file, with no database or replica arguments after it.
-func expectedReplicatorCommand(argv []string, config string) bool {
- return len(argv) == 4 && filepath.Base(argv[0]) == "litestream" && argv[1] == "replicate" && argv[2] == "-config" && argv[3] == config
-}
-
-// readDurabilityScope reads the routes and the replicator's state as one
-// observation and computes the scope from them. The registry is read
-// inside the probe's bracket: if the unit restarted or the config was
-// rewritten around that read, nothing is attested.
-func readDurabilityScope(ctx context.Context, p replicatorProbe, listRoutes func() ([]registry.Route, error), dataDir string) (usage.DurabilityAttestation, replicatorState, string) {
- var routes []registry.Route
- rs := p.read(ctx, func() error {
- var err error
- routes, err = listRoutes()
- if err != nil {
- return fmt.Errorf("list routes: %w", err)
- }
- return nil
- })
- scope, reason := durabilityScope(routes, dataDir, rs, p.resolve)
- return scope, rs, reason
-}
-
-// systemdUnitState asks systemd whether unit is active, when it became so,
-// and what it runs. The timestamp is requested in unix form: the default
-// rendering carries a zone abbreviation, which cannot be parsed back
-// reliably.
-func systemdUnitState(ctx context.Context, unit string) (bool, time.Time, []string, error) {
- out, err := exec.CommandContext(ctx, "systemctl", "show", unit, "--timestamp=unix", "-p", "LoadState", "-p", "ActiveState", "-p", "ActiveEnterTimestamp", "-p", "ExecStart").Output()
- if err != nil {
- return false, time.Time{}, nil, err
- }
- return parseSystemdUnitState(string(out))
-}
-
-// parseSystemdUnitState reads `systemctl show --timestamp=unix` output for
-// LoadState, ActiveState, ActiveEnterTimestamp and ExecStart. Only two
-// answers are accepted: a unit that is active with a start time and one
-// command, and a unit that is plainly not running (inactive or failed, or
-// not installed at all). Any state in between (activating, deactivating,
-// reloading), a missing property, a timestamp in another form, or a unit
-// with no command or several is an error.
-func parseSystemdUnitState(out string) (bool, time.Time, []string, error) {
- props := map[string]string{}
- execStarts := 0
- for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
- if k, v, ok := strings.Cut(line, "="); ok {
- if k == "ExecStart" {
- execStarts++
- }
- props[k] = strings.TrimSpace(v)
- }
- }
- load, state := props["LoadState"], props["ActiveState"]
- switch {
- case load == "not-found" && state == "inactive":
- return false, time.Time{}, nil, nil
- case load != "loaded":
- return false, time.Time{}, nil, fmt.Errorf("load state %q", load)
- case state == "inactive", state == "failed":
- return false, time.Time{}, nil, nil
- case state != "active":
- return false, time.Time{}, nil, fmt.Errorf("active state %q", state)
- }
- stamp, ok := strings.CutPrefix(props["ActiveEnterTimestamp"], "@")
- if !ok {
- return false, time.Time{}, nil, fmt.Errorf("start time %q is not in unix form", props["ActiveEnterTimestamp"])
- }
- secs, err := strconv.ParseInt(stamp, 10, 64)
- if err != nil || secs <= 0 {
- return false, time.Time{}, nil, fmt.Errorf("start time %q", props["ActiveEnterTimestamp"])
- }
- // ExecStart={ path=/usr/local/bin/litestream ; argv[]=/usr/local/bin/litestream replicate -config /etc/litestream.yml ; ignore_errors=no ; ... }
- if execStarts != 1 {
- return false, time.Time{}, nil, fmt.Errorf("%d ExecStart commands", execStarts)
- }
- _, rest, ok := strings.Cut(props["ExecStart"], "argv[]=")
- if !ok {
- return false, time.Time{}, nil, fmt.Errorf("ExecStart %q has no argv", props["ExecStart"])
- }
- args, _, ok := strings.Cut(rest, " ;")
- if !ok {
- return false, time.Time{}, nil, fmt.Errorf("ExecStart %q is not in the expected form", props["ExecStart"])
- }
- argv := strings.Fields(args)
- if len(argv) == 0 {
- return false, time.Time{}, nil, fmt.Errorf("ExecStart %q has an empty argv", props["ExecStart"])
- }
- return true, time.Unix(secs, 0).UTC(), argv, nil
-}
diff --git a/internal/host/durabilityreplicator_test.go b/internal/host/durabilityreplicator_test.go
deleted file mode 100644
index 81fd3c0c6..000000000
--- a/internal/host/durabilityreplicator_test.go
+++ /dev/null
@@ -1,214 +0,0 @@
-package host
-
-import (
- "context"
- "errors"
- "os"
- "reflect"
- "testing"
- "time"
-
- "github.com/carlosframework/platform/internal/ops"
- "github.com/carlosframework/platform/internal/registry"
-)
-
-const unitExec = "ExecStart={ path=/usr/local/bin/litestream ; argv[]=/usr/local/bin/litestream replicate -config /etc/litestream.yml ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }\n"
-
-func TestParseSystemdUnitState(t *testing.T) {
- cases := []struct {
- name string
- out string
- active bool
- since int64
- fails bool
- }{
- {"running", "LoadState=loaded\nActiveState=active\nActiveEnterTimestamp=@1790817621\n" + unitExec, true, 1790817621, false},
- {"stopped", "LoadState=loaded\nActiveState=inactive\nActiveEnterTimestamp=\n" + unitExec, false, 0, false},
- {"failed", "LoadState=loaded\nActiveState=failed\nActiveEnterTimestamp=@1790817621\n" + unitExec, false, 0, false},
- {"not installed", "LoadState=not-found\nActiveState=inactive\nActiveEnterTimestamp=\n", false, 0, false},
- {"starting: neither running nor stopped", "LoadState=loaded\nActiveState=activating\nActiveEnterTimestamp=\n" + unitExec, false, 0, true},
- {"stopping", "LoadState=loaded\nActiveState=deactivating\nActiveEnterTimestamp=@1790817621\n" + unitExec, false, 0, true},
- {"a start time with a zone abbreviation", "LoadState=loaded\nActiveState=active\nActiveEnterTimestamp=Thu 2026-10-01 01:40:21 IST\n" + unitExec, false, 0, true},
- {"running with no start time", "LoadState=loaded\nActiveState=active\nActiveEnterTimestamp=\n" + unitExec, false, 0, true},
- {"running with no command", "LoadState=loaded\nActiveState=active\nActiveEnterTimestamp=@1790817621\n", false, 0, true},
- {"running with two commands", "LoadState=loaded\nActiveState=active\nActiveEnterTimestamp=@1790817621\n" + unitExec + unitExec, false, 0, true},
- {"masked", "LoadState=masked\nActiveState=inactive\nActiveEnterTimestamp=\n", false, 0, true},
- {"nothing at all", "", false, 0, true},
- }
- for _, c := range cases {
- active, since, argv, err := parseSystemdUnitState(c.out)
- if (err != nil) != c.fails || active != c.active || (c.since != 0 && since.Unix() != c.since) {
- t.Errorf("%s: active=%v since=%v err=%v", c.name, active, since, err)
- }
- if active && !expectedReplicatorCommand(argv, "/etc/litestream.yml") {
- t.Errorf("%s: argv %q is not recognised as the replicator", c.name, argv)
- }
- }
- for name, argv := range map[string][]string{
- "a database on the command line": {"/usr/local/bin/litestream", "replicate", "/data/x.db", "s3://bucket/x"},
- "another config": {"/usr/local/bin/litestream", "replicate", "-config", "/etc/other.yml"},
- "an extra argument": {"/usr/local/bin/litestream", "replicate", "-config", "/etc/litestream.yml", "-exec", "x"},
- "another binary": {"/usr/local/bin/rsync", "replicate", "-config", "/etc/litestream.yml"},
- "nothing": nil,
- } {
- if expectedReplicatorCommand(argv, "/etc/litestream.yml") {
- t.Errorf("%s: accepted as the replicator", name)
- }
- }
-}
-
-func TestReplicatorProbe(t *testing.T) {
- ctx := context.Background()
- written := time.Date(2026, 9, 17, 8, 0, 0, 0, time.UTC)
- argv := []string{"/usr/local/bin/litestream", "replicate", "-config", "/etc/litestream.yml"}
- generated := ops.LitestreamConfig([]string{"/data/registry.db", "/data/api.example.com.db", "/var/lib/carlos-app/kass.example.com/instances/y.db"}, "bucket", "prefix", "eu-west-1")
- probe := func(cfg *string, active bool, since time.Time) replicatorProbe {
- return replicatorProbe{configPath: "/etc/litestream.yml", unit: externalReplicatorUnit,
- readFile: func(string) ([]byte, error) {
- if cfg == nil {
- return nil, os.ErrNotExist
- }
- return []byte(*cfg), nil
- },
- modTime: func(string) (time.Time, error) { return written, nil },
- unitState: func(context.Context, string) (bool, time.Time, []string, error) {
- if !active {
- return false, time.Time{}, nil, nil
- }
- return active, since, argv, nil
- },
- // One link: a file in the unit's tree that points at another
- // tenant's database.
- resolve: func(path string) (string, error) {
- if path == "/var/lib/carlos-app/kass.example.com/instances/y.db" {
- return "/data/y.example.com.db", nil
- }
- return path, nil
- },
- }
- }
- // The ordinary box: a generated config, the unit restarted after it.
- // The link is reported as written and as resolved.
- got := probe(&generated, true, written.Add(time.Minute)).read(ctx, nil)
- if got.Err != nil || !got.ConfigPresent || !got.UnitActive || !got.ConfigModified.Equal(written) ||
- !reflect.DeepEqual(got.Paths, []string{"/data/api.example.com.db", "/data/registry.db", "/var/lib/carlos-app/kass.example.com/instances/y.db", "/data/y.example.com.db"}) {
- t.Fatalf("generated config: %+v", got)
- }
- // No config and no unit: nothing external replicates.
- if got := probe(nil, false, time.Time{}).read(ctx, nil); got.Err != nil || got.ConfigPresent {
- t.Fatalf("no config, no unit: %+v", got)
- }
- // A running unit with no config is replicating from somewhere unseen.
- if got := probe(nil, true, written).read(ctx, nil); got.Err == nil {
- t.Fatal("a running unit with no config was accepted")
- }
- // A hand-kept config is refused whole.
- hand := "dbs:\n - path: /data/registry.db\n replicas:\n - url: s3://bucket/x\n"
- if got := probe(&hand, true, written.Add(time.Minute)).read(ctx, nil); !errors.Is(got.Err, ops.ErrConfigNotGenerated) || len(got.Paths) != 0 {
- t.Fatalf("a hand-kept config: %+v", got)
- }
- // A unit given a database on its command line.
- args := probe(&generated, true, written.Add(time.Minute))
- args.unitState = func(context.Context, string) (bool, time.Time, []string, error) {
- return true, written.Add(time.Minute), []string{"/usr/local/bin/litestream", "replicate", "/data/x.db", "s3://bucket/x"}, nil
- }
- if got := args.read(ctx, nil); got.Err == nil {
- t.Fatal("a unit replicating from its command line was accepted")
- }
- // A path that cannot be resolved.
- blind := probe(&generated, true, written.Add(time.Minute))
- blind.resolve = func(string) (string, error) { return "", errors.New("permission denied") }
- if got := blind.read(ctx, nil); got.Err == nil {
- t.Fatal("a config path that could not be resolved was accepted")
- }
- // The unit restarts while the config is being read.
- calls := 0
- racing := probe(&generated, true, written.Add(time.Minute))
- racing.unitState = func(context.Context, string) (bool, time.Time, []string, error) {
- calls++
- return true, written.Add(time.Duration(calls) * time.Minute), argv, nil
- }
- if got := racing.read(ctx, nil); got.Err == nil {
- t.Fatal("a unit that restarted mid-read was accepted")
- }
- // The config is rewritten while it is being read: its bytes, or only
- // its modification time.
- reads := 0
- rewriting := probe(&generated, true, written.Add(time.Minute))
- rewriting.readFile = func(string) ([]byte, error) {
- reads++
- if reads > 1 {
- return []byte(generated + " - path: /data/new.db\n"), nil
- }
- return []byte(generated), nil
- }
- if got := rewriting.read(ctx, nil); got.Err == nil {
- t.Fatal("a config rewritten mid-read was accepted")
- }
- stats := 0
- touched := probe(&generated, true, written.Add(time.Minute))
- touched.modTime = func(string) (time.Time, error) {
- stats++
- return written.Add(time.Duration(stats) * time.Second), nil
- }
- if got := touched.read(ctx, nil); got.Err == nil {
- t.Fatal("a config whose modification time moved mid-read was accepted")
- }
- // systemd cannot be asked.
- deaf := probe(&generated, true, written)
- deaf.unitState = func(context.Context, string) (bool, time.Time, []string, error) {
- return false, time.Time{}, nil, errors.New("systemctl: not found")
- }
- if got := deaf.read(ctx, nil); got.Err == nil {
- t.Fatal("an unreadable unit state was accepted")
- }
-}
-
-// The routes and the replicator's state are one observation. A route flips
-// from hibernating to always-on, the config is regenerated and the unit
-// restarts, all while the scope is being read: the registry read falls
-// inside the bracket, the unit is seen to have moved, and nothing is
-// attested. And the link in the unit's tree excludes the owner of the
-// database it points at.
-func TestReadDurabilityScopeIsOneObservation(t *testing.T) {
- ctx := context.Background()
- written := time.Date(2026, 9, 17, 8, 0, 0, 0, time.UTC)
- argv := []string{"/usr/local/bin/litestream", "replicate", "-config", "/etc/litestream.yml"}
- routes := []registry.Route{
- {Host: "kass.example.com", Account: "bae", Backing: registry.BackingUnit, DBPath: "/var/lib/carlos-app/kass.example.com"},
- {Host: "y.example.com", Account: "bac", Hibernate: true, DBPath: "/data/y.example.com.db"},
- {Host: "shop.example.com", Account: "bad", Hibernate: true, DBPath: "/data/shop.example.com.db"},
- }
- cfg := ops.LitestreamConfig([]string{"/data/registry.db", "/var/lib/carlos-app/kass.example.com/instances/y.db"}, "bucket", "prefix", "eu-west-1")
- restarts := 0
- p := replicatorProbe{configPath: "/etc/litestream.yml", unit: externalReplicatorUnit,
- readFile: func(string) ([]byte, error) { return []byte(cfg), nil },
- modTime: func(string) (time.Time, error) { return written, nil },
- unitState: func(context.Context, string) (bool, time.Time, []string, error) {
- return true, written.Add(time.Duration(1+restarts) * time.Minute), argv, nil
- },
- resolve: func(path string) (string, error) {
- if path == "/var/lib/carlos-app/kass.example.com/instances/y.db" {
- return "/data/y.example.com.db", nil
- }
- return path, nil
- },
- }
- scope, _, reason := readDurabilityScope(ctx, p, func() ([]registry.Route, error) { return routes, nil }, "/data")
- if !scope.Attested || !reflect.DeepEqual(scope.Unmetered, []string{"bac", "bae"}) {
- t.Fatalf("scope %+v (%s): want the unit's owner and the owner of the database its link points at excluded, and bad vouched for", scope, reason)
- }
- // The unit restarts while the registry is being read.
- scope, _, reason = readDurabilityScope(ctx, p, func() ([]registry.Route, error) {
- restarts++
- return routes, nil
- }, "/data")
- if scope.Attested || reason == "" {
- t.Fatalf("a restart around the registry read attested: %+v", scope)
- }
- // The registry cannot be read.
- scope, _, _ = readDurabilityScope(ctx, p, func() ([]registry.Route, error) { return nil, errors.New("registry locked") }, "/data")
- if scope.Attested {
- t.Fatal("an unreadable registry attested")
- }
-}
diff --git a/internal/host/durabilityscope.go b/internal/host/durabilityscope.go
deleted file mode 100644
index b2902eb2a..000000000
--- a/internal/host/durabilityscope.go
+++ /dev/null
@@ -1,269 +0,0 @@
-package host
-
-import (
- "context"
- "fmt"
- "log"
- "path/filepath"
- "sort"
- "strings"
- "time"
-
- "github.com/carlosframework/platform/internal/ops"
- "github.com/carlosframework/platform/internal/registry"
- "github.com/carlosframework/platform/internal/release"
- "github.com/carlosframework/platform/internal/usage"
-)
-
-// The scoped attestation of the replication ledger: what this box may
-// honestly claim about who replicates through the ledger and who does not
-// (docs/superpowers/plans/2026-10-01-durability-attestation-path.md, path
-// A). The agent calls this only when CARLOS_DURABILITY_ATTEST=scoped is
-// set in its environment (scopedAttestationEnabled). It is unset
-// everywhere, and setting it on a production box needs a scoped approval.
-//
-// A box has two replicators. The agent's own (the activator's embedded
-// Litestream, the move worker) records every request in the ledger. The
-// box-wide litestream unit, an external binary run as root from
-// /etc/litestream.yml, records nothing: it replicates every database that
-// is not a hibernating exec route's (ops.FleetDBs). An account can be
-// vouched for only when the external replicator touches none of its
-// databases, so the attestation names the accounts it does touch and
-// excludes them.
-
-// replicatorState is what the box can observe about the external
-// replicator.
-type replicatorState struct {
- // ConfigPresent is false when there is no config file at all: the
- // external replicator has nothing to replicate.
- ConfigPresent bool
- // Paths are the database paths the config names, each as written and,
- // where that differs, as resolved through symlinks.
- Paths []string
- // ConfigModified is the config file's modification time.
- ConfigModified time.Time
- // UnitActive and UnitStarted describe the unit that runs the external
- // replicator: whether it is running, and since when.
- UnitActive bool
- UnitStarted time.Time
- // Err is set when any of the above could not be read.
- Err error
-}
-
-// durabilityScope computes the attestation for this box from its routes
-// and the external replicator's observed state. reason says why nothing is
-// attested, when nothing is.
-//
-// An account is excluded when either says the external replicator may
-// touch its databases:
-//
-// - the registry: it owns a unit-backed route (whose database only the
-// box-wide replicator can reach, and which may sit in a hand-kept
-// config even when the route records no path), or an exec route that
-// records a database and does not hibernate (which the config
-// generator hands to the box-wide replicator). A route with no
-// database and no unit, a static site for one, excludes nobody;
-// - the config: it owns a route whose database path the live config
-// names, whatever kind of route the registry says it is. EVERY route
-// whose path equals or encloses a configured path is an owner, not the
-// first one found: a hibernating route's database can sit inside a
-// unit's tree, and two routes can record one file.
-//
-// Paths are compared as written and as resolved. resolve follows symlinks
-// (filepath.EvalSymlinks on a real box). A unit tenant controls its own
-// state directory and can link a file in it to another tenant's database;
-// the generator, which follows links, then names the link, and only the
-// resolved path shows whose database the replicator is streaming. The
-// caller resolves the config's paths (replicatorState.Paths); this
-// function resolves the routes'. A route path that does not resolve (the
-// database does not exist yet) is matched as written.
-//
-// Nothing is attested, for anyone, when the box cannot tell what the
-// external replicator is doing:
-//
-// - its state could not be read;
-// - the unit is running and there is no config file: it is replicating
-// from a config that has been removed since it started, or from
-// arguments, and neither can be seen;
-// - the config names a database no route owns and that is not the box's
-// own (the registry or the console database): somebody's data is being
-// replicated and nobody can be named as its owner;
-// - the unit has been running since before the config was last written:
-// it is replicating from an older config this code cannot see, which
-// may name databases the current one dropped.
-//
-// The box's own databases are platform data on the platform's prefix, not
-// tenant replication, and exclude nobody.
-//
-// What the caller owes: Paths must be every database the config names
-// (replicatorProbe refuses a config it cannot fully account for), the
-// routes and the replicator's state must be one coherent observation
-// (readDurabilityScope brackets them). The console database's path is
-// resolved the way the generator resolves it (ops.ConsoleDBPath).
-func durabilityScope(routes []registry.Route, dataDir string, rs replicatorState, resolve func(string) (string, error)) (usage.DurabilityAttestation, string) {
- if rs.Err != nil {
- return usage.DurabilityAttestation{}, fmt.Sprintf("the external replicator's state could not be read: %v", rs.Err)
- }
- if rs.UnitActive && !rs.ConfigPresent {
- return usage.DurabilityAttestation{}, "the external replicator is running and has no config file; what it replicates cannot be seen"
- }
- if rs.ConfigPresent && rs.UnitActive && rs.UnitStarted.Before(rs.ConfigModified) {
- return usage.DurabilityAttestation{}, "the external replicator has been running since before its config was last written; what it replicates is not what the config says"
- }
- excluded := map[string]bool{}
- type owned struct {
- path string
- tree bool
- account string
- }
- var owners []owned
- for _, rt := range routes {
- acct := release.RouteAccount(rt)
- if rt.Backing == registry.BackingUnit || (rt.DBPath != "" && !rt.Hibernate) {
- excluded[acct] = true
- }
- if rt.DBPath == "" {
- continue
- }
- tree := rt.Backing == registry.BackingUnit
- written := filepath.Clean(rt.DBPath)
- owners = append(owners, owned{path: written, tree: tree, account: acct})
- if resolve != nil {
- if real, err := resolve(written); err == nil && filepath.Clean(real) != written {
- owners = append(owners, owned{path: filepath.Clean(real), tree: tree, account: acct})
- }
- }
- }
- own := map[string]bool{
- filepath.Clean(filepath.Join(dataDir, "registry.db")): true,
- filepath.Clean(ops.ConsoleDBPath(dataDir)): true,
- }
- if rs.ConfigPresent {
- for _, raw := range rs.Paths {
- path := filepath.Clean(raw)
- if !filepath.IsAbs(path) {
- return usage.DurabilityAttestation{}, fmt.Sprintf("the external replicator's config names %s, which is not an absolute path", raw)
- }
- if own[path] {
- continue
- }
- matched := false
- for _, o := range owners {
- // A unit route's path may be a directory the generator
- // walks for databases (ops.treeDBs).
- if path == o.path || (o.tree && strings.HasPrefix(path, o.path+string(filepath.Separator))) {
- excluded[o.account] = true
- matched = true
- }
- }
- if !matched {
- return usage.DurabilityAttestation{}, fmt.Sprintf("the external replicator replicates %s, which no route owns", path)
- }
- }
- }
- out := usage.DurabilityAttestation{Attested: true}
- for acct := range excluded {
- out.Unmetered = append(out.Unmetered, acct)
- }
- sort.Strings(out.Unmetered)
- return out, ""
-}
-
-// durabilityAttestEnv is the agent's switch for the scoped attestation.
-// Unset, or set to anything but "scoped", the ledger attests nothing, as
-// it always has.
-const durabilityAttestEnv = "CARLOS_DURABILITY_ATTEST"
-
-func scopedAttestationEnabled(getenv func(string) string) bool {
- return strings.TrimSpace(getenv(durabilityAttestEnv)) == "scoped"
-}
-
-// enableScopedAttestation makes every publish tick observe the box and
-// apply the scope BEFORE it seals anything, so a day is never closed on a
-// claim that was not checked in the same tick.
-func (p *durabilityPublisher) enableScopedAttestation(probe replicatorProbe, listRoutes func() ([]registry.Route, error), dataDir string) {
- if p == nil {
- return
- }
- lastReason := "\x00"
- p.attest = func(ctx context.Context) {
- scope, rs, reason := readDurabilityScope(ctx, probe, listRoutes, dataDir)
- if err := p.applyScope(scope, rs, p.now()); err != nil {
- log.Printf("agent: durability: attestation: %v", err)
- }
- if reason != lastReason {
- if reason != "" {
- log.Printf("agent: durability: attesting nothing: %s", reason)
- } else {
- log.Printf("agent: durability: attesting, with %d account(s) excluded", len(scope.Unmetered))
- }
- lastReason = reason
- }
- }
-}
-
-// applyScope moves the ledger's claim to scope, dating every change at the
-// earliest instant the previous claim could have stopped being true.
-//
-// - Nothing was attested and scope attests: the claim starts now. It
-// says nothing about the time before this observation.
-// - Scope excludes an account the ledger was vouching for: the exclusion
-// starts no later than the last tick that verified the old claim, and
-// no later than the instant the replicator's config was last written
-// (the replicator cannot touch a database before a config naming it
-// existed). That keeps a day from closing complete for an account the
-// external replicator began touching just before midnight.
-// - Scope attests nothing and the ledger was attesting: the claim ends
-// at the last tick that verified it.
-//
-// An exclusion never lapses within a run: the ledger keeps the union.
-//
-// When the ledger refuses the instant (it precedes the current coverage
-// session), the change is filed as an escape instead, which the ledger
-// clamps itself and which also makes the day of the escape incomplete for
-// everyone on the box: blunter, and never more generous.
-func (p *durabilityPublisher) applyScope(scope usage.DurabilityAttestation, rs replicatorState, now time.Time) error {
- cur := p.ledger.Attestation()
- verified := p.scopeVerifiedAt
- if verified.IsZero() || verified.After(now) {
- verified = now
- }
- defer func() { p.scopeVerifiedAt = now }()
- if !scope.Attested {
- if !cur.Attested {
- return nil
- }
- if err := p.ledger.SetAttestation(usage.DurabilityAttestation{}, verified); err != nil {
- // An owner that cannot be named withdraws the claim for
- // everyone, from the instant given.
- p.ledger.Escaped("", verified)
- }
- return nil
- }
- if !cur.Attested {
- return p.ledger.SetAttestation(scope, now)
- }
- had := map[string]bool{}
- for _, acct := range cur.Unmetered {
- had[acct] = true
- }
- var added []string
- for _, acct := range scope.Unmetered {
- if !had[acct] {
- added = append(added, acct)
- }
- }
- if len(added) == 0 {
- return nil
- }
- from := verified
- if rs.ConfigPresent && !rs.ConfigModified.IsZero() && rs.ConfigModified.Before(from) {
- from = rs.ConfigModified
- }
- if err := p.ledger.SetAttestation(scope, from); err != nil {
- for _, acct := range added {
- p.ledger.Escaped(acct, from)
- }
- }
- return nil
-}
diff --git a/internal/host/durabilityscope_test.go b/internal/host/durabilityscope_test.go
deleted file mode 100644
index d7070eb66..000000000
--- a/internal/host/durabilityscope_test.go
+++ /dev/null
@@ -1,365 +0,0 @@
-package host
-
-import (
- "context"
- "errors"
- "reflect"
- "testing"
- "time"
-
- "github.com/carlosframework/platform/internal/ops"
- "github.com/carlosframework/platform/internal/registry"
- "github.com/carlosframework/platform/internal/release"
- "github.com/carlosframework/platform/internal/usage"
-)
-
-func scopeRoutes() []registry.Route {
- return []registry.Route{
- // A hibernating exec route: the activator replicates it, metered.
- {Host: "shop.example.com", Account: "bac", App: "shop", Hibernate: true, DBPath: "/data/shop.example.com.db"},
- // An always-on exec route with a database: the box-wide replicator's.
- {Host: "api.example.com", Account: "bad", App: "api", DBPath: "/data/api.example.com.db"},
- // A unit-backed route whose path is a tree.
- {Host: "kass.example.com", Account: "bae", App: "kass", Backing: registry.BackingUnit, Hibernate: true, DBPath: "/var/lib/carlos-app/kass.example.com"},
- // A static site: no process, no database.
- {Host: "docs.example.com", Account: "baf", App: "docs"},
- }
-}
-
-func scopeState(paths ...string) replicatorState {
- written := time.Date(2026, 9, 17, 8, 0, 0, 0, time.UTC)
- return replicatorState{ConfigPresent: true, Paths: paths, ConfigModified: written, UnitActive: true, UnitStarted: written.Add(time.Minute)}
-}
-
-// testResolve stands in for the filesystem: one link, everything else as
-// written.
-func testResolve(path string) (string, error) {
- if path == "/data/link.db" {
- return "/data/real.db", nil
- }
- return path, nil
-}
-
-func TestDurabilityScope(t *testing.T) {
- generated := []string{"/data/registry.db", "/data/console/console.db", "/data/api.example.com.db", "/var/lib/carlos-app/kass.example.com/registry.db", "/var/lib/carlos-app/kass.example.com/instances/a.db"}
- cases := []struct {
- name string
- routes []registry.Route
- state replicatorState
- attested bool
- unmetered []string
- }{
- {"the generated config: always-on and unit owners are excluded, hibernating and static owners are vouched for",
- scopeRoutes(), scopeState(generated...), true, []string{"bad", "bae"}},
- {"no config and no running unit: the registry alone decides",
- scopeRoutes(), replicatorState{}, true, []string{"bad", "bae"}},
- {"a running unit with no config file: nothing is attested",
- scopeRoutes()[:1], replicatorState{UnitActive: true, UnitStarted: time.Date(2026, 9, 17, 8, 0, 0, 0, time.UTC)}, false, nil},
- {"a hibernating route's database inside a unit's tree: both owners are excluded, in either route order",
- []registry.Route{
- {Host: "kass.example.com", Account: "bae", Backing: registry.BackingUnit, DBPath: "/srv/unit"},
- {Host: "a.example.com", Account: "bac", Hibernate: true, DBPath: "/srv/unit/a.db"},
- }, scopeState("/srv/unit/a.db"), true, []string{"bac", "bae"}},
- {"the same, routes reversed",
- []registry.Route{
- {Host: "a.example.com", Account: "bac", Hibernate: true, DBPath: "/srv/unit/a.db"},
- {Host: "kass.example.com", Account: "bae", Backing: registry.BackingUnit, DBPath: "/srv/unit"},
- }, scopeState("/srv/unit/a.db"), true, []string{"bac", "bae"}},
- {"two routes record one database: both owners are excluded",
- []registry.Route{
- {Host: "x.example.com", Account: "bac", Hibernate: true, DBPath: "/data/x.db"},
- {Host: "y.example.com", Account: "bad", Hibernate: true, DBPath: "/data/x.db"},
- }, scopeState("/data/x.db"), true, []string{"bac", "bad"}},
- {"a link in a unit's tree to a hibernating route's database, reported as written and as resolved: both owners are excluded",
- []registry.Route{
- {Host: "kass.example.com", Account: "bae", Backing: registry.BackingUnit, DBPath: "/srv/unit"},
- {Host: "y.example.com", Account: "bac", Hibernate: true, DBPath: "/data/y.example.com.db"},
- }, scopeState("/srv/unit/instances/y.db", "/data/y.example.com.db"), true, []string{"bac", "bae"}},
- {"a route whose own recorded path is a link: the config's resolved path still finds it",
- []registry.Route{{Host: "z.example.com", Account: "bac", Hibernate: true, DBPath: "/data/link.db"}},
- scopeState("/data/real.db"), true, []string{"bac"}},
- {"a relative path in the config: nothing is attested",
- scopeRoutes(), scopeState("data/x.db"), false, nil},
- {"only hibernating exec routes: nobody is excluded",
- scopeRoutes()[:1], scopeState("/data/registry.db"), true, nil},
- {"the config names a hibernating route's database: its owner is excluded on that evidence",
- scopeRoutes(), scopeState(append([]string{"/data/shop.example.com.db"}, generated...)...), true, []string{"bac", "bad", "bae"}},
- {"the config names a database no route owns: nothing is attested",
- scopeRoutes(), scopeState(append([]string{"/data/leftover.db"}, generated...)...), false, nil},
- {"a path that only shares a prefix with a unit's tree is not in the tree",
- scopeRoutes(), scopeState("/var/lib/carlos-app/kass.example.com.bak/x.db"), false, nil},
- {"the unit has been running since before the config was written: nothing is attested",
- scopeRoutes(), func() replicatorState {
- s := scopeState(generated...)
- s.UnitStarted = s.ConfigModified.Add(-time.Hour)
- return s
- }(), false, nil},
- {"the replicator's state could not be read: nothing is attested",
- scopeRoutes(), replicatorState{Err: errors.New("permission denied")}, false, nil},
- {"a route with no valid account is the platform's: the platform account is excluded",
- []registry.Route{{Host: "console.example.com", DBPath: "/data/other.db"}}, scopeState("/data/other.db"), true, []string{release.OpsAccount}},
- {"a unit route with no recorded path still excludes its owner",
- []registry.Route{{Host: "kass.example.com", Account: "bae", Backing: registry.BackingUnit}}, replicatorState{}, true, []string{"bae"}},
- }
- for _, c := range cases {
- t.Run(c.name, func(t *testing.T) {
- got, reason := durabilityScope(c.routes, "/data", c.state, testResolve)
- if got.Attested != c.attested || !reflect.DeepEqual(got.Unmetered, c.unmetered) {
- t.Fatalf("attested=%v unmetered=%v (reason %q), want attested=%v unmetered=%v", got.Attested, got.Unmetered, reason, c.attested, c.unmetered)
- }
- if !got.Attested && (reason == "" || len(got.Unmetered) != 0) {
- t.Fatalf("an unattested scope must say why and exclude nothing: %+v %q", got, reason)
- }
- })
- }
-}
-
-// The test the attestation plan requires before any box may attest: a box
-// with a unit-backed route and the external replicator present publishes a
-// day that is NOT complete for that route's owner, and is complete for an
-// account whose only route hibernates under the activator.
-func TestScopedAttestationPublishesADayIncompleteForTheUnitOwner(t *testing.T) {
- // The box came up the evening before, so the 17th is wholly inside
- // this run.
- at := time.Date(2026, 9, 16, 23, 0, 0, 0, time.UTC)
- clock := at
- p, err := newDurabilityPublisher(t.TempDir(), "box1", func() time.Time { return clock })
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() {
- ctx, cancel := context.WithTimeout(context.Background(), time.Second)
- defer cancel()
- if !p.stopped.Load() {
- _ = p.stop(ctx, nil)
- }
- _ = p.ledger.WaitLate(ctx)
- })
- // As opened in production the ledger attests nothing.
- if p.ledger.Attestation().Attested {
- t.Fatal("a freshly opened production ledger attests")
- }
- scope, reason := durabilityScope(scopeRoutes(), "/data", scopeState("/data/registry.db", "/data/api.example.com.db", "/var/lib/carlos-app/kass.example.com/registry.db"), testResolve)
- if !scope.Attested {
- t.Fatalf("fixture scope: %s", reason)
- }
- if err := p.ledger.SetAttestation(scope, at); err != nil {
- t.Fatal(err)
- }
- clock = time.Date(2026, 9, 17, 10, 0, 0, 0, time.UTC)
- rec := p.recorder(registry.Route{Account: "bac", App: "shop", Host: "shop.example.com"}, usage.PurposeReplicate)
- if rec == nil {
- t.Fatal("no recorder for the hibernating route")
- }
- clock = time.Date(2026, 9, 18, 0, 1, 0, 0, time.UTC) // past midnight: the day can close
- store := release.NewDirStore(t.TempDir())
- if err := p.publish(context.Background(), store); err != nil {
- t.Fatal(err)
- }
- raw, _, err := store.Get(context.Background(), usage.DurabilityDayKey("box1", "2026-09-17"))
- if err != nil {
- t.Fatal(err)
- }
- day, err := usage.ParseDurabilityDay(raw, "box1", "2026-09-17")
- if err != nil {
- t.Fatal(err)
- }
- from, to := time.Date(2026, 9, 17, 0, 0, 0, 0, time.UTC), time.Date(2026, 9, 18, 0, 0, 0, 0, time.UTC)
- ok, unmetered, why := day.Scope(from, to)
- if !ok {
- t.Fatalf("the day proves nothing for anyone: %s", why)
- }
- if !reflect.DeepEqual(unmetered, []string{"bad", "bae"}) {
- t.Fatalf("excluded %v, want the always-on owner and the unit owner", unmetered)
- }
- for _, snap := range day.Epochs {
- c, err := snap.Decode()
- if err != nil {
- t.Fatal(err)
- }
- if complete, _ := c.CompletenessFor("bae", from, to); complete {
- t.Fatal("the day reads complete for the unit-backed route's owner")
- }
- if complete, why := c.CompletenessFor("bac", from, to); !complete {
- t.Fatalf("the day does not read complete for the hibernating route's owner: %s", why)
- }
- }
-}
-
-// The switch. Only the exact value turns the attestation on.
-func TestScopedAttestationIsOffUnlessAskedForByName(t *testing.T) {
- for value, want := range map[string]bool{"": false, "scoped": true, " scoped ": true, "true": false, "1": false, "all": false, "SCOPED": false} {
- if got := scopedAttestationEnabled(func(string) string { return value }); got != want {
- t.Errorf("%s=%q: enabled=%v, want %v", durabilityAttestEnv, value, got, want)
- }
- }
- // A publisher nobody enabled attests nothing, tick after tick.
- p, _, _ := newTestDurabilityPublisher(t)
- store := release.NewDirStore(t.TempDir())
- for i := 0; i < 2; i++ {
- if err := p.publish(context.Background(), store); err != nil {
- t.Fatal(err)
- }
- }
- if p.attest != nil || p.ledger.Attestation().Attested {
- t.Fatal("a publisher with the switch off attests")
- }
-}
-
-// attestFixture is a publisher with the scoped attestation enabled over a
-// box whose routes, replicator config and unit the test controls.
-type attestFixture struct {
- p *durabilityPublisher
- clock time.Time
- routes []registry.Route
- paths []string
- written time.Time
- started time.Time
- stateErr error
- store release.Store
-}
-
-func newAttestFixture(t *testing.T) *attestFixture {
- t.Helper()
- f := &attestFixture{clock: time.Date(2026, 9, 16, 23, 0, 0, 0, time.UTC), store: release.NewDirStore(t.TempDir())}
- f.written = f.clock.Add(-time.Hour)
- f.started = f.written.Add(time.Minute)
- f.routes = []registry.Route{
- {Host: "shop.example.com", Account: "bac", App: "shop", Hibernate: true, DBPath: "/data/shop.example.com.db"},
- {Host: "api.example.com", Account: "bad", App: "api", DBPath: "/data/api.example.com.db"},
- }
- f.paths = []string{"/data/registry.db", "/data/api.example.com.db"}
- p, err := newDurabilityPublisher(t.TempDir(), "box1", func() time.Time { return f.clock })
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() {
- ctx, cancel := context.WithTimeout(context.Background(), time.Second)
- defer cancel()
- if !p.stopped.Load() {
- _ = p.stop(ctx, nil)
- }
- _ = p.ledger.WaitLate(ctx)
- })
- argv := []string{"/usr/local/bin/litestream", "replicate", "-config", "/etc/litestream.yml"}
- probe := replicatorProbe{configPath: "/etc/litestream.yml", unit: externalReplicatorUnit,
- readFile: func(string) ([]byte, error) {
- return []byte(ops.LitestreamConfig(f.paths, "bucket", "prefix", "eu-west-1")), nil
- },
- modTime: func(string) (time.Time, error) { return f.written, nil },
- unitState: func(context.Context, string) (bool, time.Time, []string, error) {
- if f.stateErr != nil {
- return false, time.Time{}, nil, f.stateErr
- }
- return true, f.started, argv, nil
- },
- resolve: func(path string) (string, error) { return path, nil },
- }
- p.enableScopedAttestation(probe, func() ([]registry.Route, error) { return f.routes, nil }, "/data")
- f.p = p
- return f
-}
-
-func (f *attestFixture) tick(t *testing.T, at time.Time) {
- t.Helper()
- f.clock = at
- if err := f.p.publish(context.Background(), f.store); err != nil {
- t.Fatal(err)
- }
-}
-
-// completeFor reads the published 17th and says whether it is complete for
-// acct over the whole day.
-func (f *attestFixture) completeFor(t *testing.T, acct string) bool {
- t.Helper()
- raw, _, err := f.store.Get(context.Background(), usage.DurabilityDayKey("box1", "2026-09-17"))
- if err != nil {
- t.Fatal(err)
- }
- day, err := usage.ParseDurabilityDay(raw, "box1", "2026-09-17")
- if err != nil {
- t.Fatal(err)
- }
- from, to := time.Date(2026, 9, 17, 0, 0, 0, 0, time.UTC), time.Date(2026, 9, 18, 0, 0, 0, 0, time.UTC)
- for _, snap := range day.Epochs {
- c, err := snap.Decode()
- if err != nil {
- t.Fatal(err)
- }
- ok, _ := c.CompletenessFor(acct, from, to)
- return ok
- }
- return false
-}
-
-// With the switch on, each tick observes the box before it seals. The
-// hibernating route's owner is vouched for and the always-on route's is
-// not.
-func TestEnabledScopedAttestationVouchesPerAccount(t *testing.T) {
- f := newAttestFixture(t)
- f.tick(t, f.clock)
- if got := f.p.ledger.Attestation(); !got.Attested || !reflect.DeepEqual(got.Unmetered, []string{"bad"}) {
- t.Fatalf("after the first tick: %+v", got)
- }
- f.tick(t, time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC))
- f.tick(t, time.Date(2026, 9, 18, 0, 5, 0, 0, time.UTC))
- if !f.completeFor(t, "bac") {
- t.Fatal("the day is not complete for the hibernating route's owner")
- }
- if f.completeFor(t, "bad") {
- t.Fatal("the day reads complete for the always-on route's owner")
- }
-}
-
-// At 23:58 a route stops hibernating, the config is regenerated to name
-// its database and the unit restarts. The next tick is at 00:05. The day
-// that is about to close must NOT close complete for that account: the
-// exclusion is dated at the config's writing, before the seal.
-func TestScopedAttestationExcludesFromWhenTheConfigWasWritten(t *testing.T) {
- f := newAttestFixture(t)
- f.tick(t, f.clock)
- f.tick(t, time.Date(2026, 9, 17, 23, 0, 0, 0, time.UTC))
- if !reflect.DeepEqual(f.p.ledger.Attestation().Unmetered, []string{"bad"}) {
- t.Fatalf("fixture: %+v", f.p.ledger.Attestation())
- }
- f.routes[0].Hibernate = false
- f.paths = append(f.paths, "/data/shop.example.com.db")
- f.written = time.Date(2026, 9, 17, 23, 58, 0, 0, time.UTC)
- f.started = f.written.Add(10 * time.Second)
- f.tick(t, time.Date(2026, 9, 18, 0, 5, 0, 0, time.UTC))
- if got := f.p.ledger.Attestation(); !got.Attested || !reflect.DeepEqual(got.Unmetered, []string{"bac", "bad"}) {
- t.Fatalf("after the route changed: %+v", got)
- }
- if f.completeFor(t, "bac") {
- t.Fatal("the 17th closed complete for an account the external replicator began touching at 23:58")
- }
-}
-
-// When the box can no longer tell what the replicator is doing, the claim
-// ends, and it ends at the last tick that verified it, not at the tick
-// that noticed.
-func TestScopedAttestationEndsWhenThePremiseFails(t *testing.T) {
- for name, breakIt := range map[string]func(f *attestFixture){
- "the unit's state cannot be read": func(f *attestFixture) { f.stateErr = errors.New("systemctl: not found") },
- "the config names a database no route owns": func(f *attestFixture) { f.paths = append(f.paths, "/data/leftover.db") },
- "the config is newer than the running unit": func(f *attestFixture) { f.written = f.started.Add(time.Hour) },
- } {
- t.Run(name, func(t *testing.T) {
- f := newAttestFixture(t)
- f.tick(t, f.clock)
- f.tick(t, time.Date(2026, 9, 17, 6, 0, 0, 0, time.UTC))
- if !f.p.ledger.Attestation().Attested {
- t.Fatal("fixture: should be attesting")
- }
- breakIt(f)
- f.tick(t, time.Date(2026, 9, 17, 18, 0, 0, 0, time.UTC))
- if f.p.ledger.Attestation().Attested {
- t.Fatal("still attesting after the premise failed")
- }
- f.tick(t, time.Date(2026, 9, 18, 0, 5, 0, 0, time.UTC))
- if f.completeFor(t, "bac") {
- t.Fatal("the day reads complete for an account although the claim ended during it")
- }
- })
- }
-}
diff --git a/internal/objstore/aws.go b/internal/objstore/aws.go
index 91fa09894..4e25bdccf 100644
--- a/internal/objstore/aws.go
+++ b/internal/objstore/aws.go
@@ -78,7 +78,7 @@ var _ Provisioner = (*awsProvisioner)(nil)
// twin, so a non-AWS backend (endpoint set, "aws" backend) still means
// IAM-against-real-AWS in v1 — there is nothing else for it to point at.
func NewAWSProvisioner(ctx context.Context, region, endpoint, boundary string) (Provisioner, error) {
- s3Client, err := S3ClientFor(ctx, region, endpoint)
+ s3Client, err := s3ClientFor(ctx, region, endpoint, func(o *awss3.Options) { o.HTTPSignerV4 = newFencedSigner() })
if err != nil {
return nil, err
}
@@ -86,7 +86,10 @@ func NewAWSProvisioner(ctx context.Context, region, endpoint, boundary string) (
if err != nil {
return nil, fmt.Errorf("objstore: load aws config: %w", err)
}
- iamClient := iam.NewFromConfig(cfg)
+ // Both clients sign through the fence (fence.go): a request made inside
+ // a walk carries the walk's lease instant as its signing time, so AWS
+ // itself refuses it once that lease is too old.
+ iamClient := iam.NewFromConfig(cfg, func(o *iam.Options) { o.HTTPSignerV4 = newFencedSigner() })
return &awsProvisioner{s3: s3Client, iam: iamClient, region: region, boundary: boundary}, nil
}
@@ -97,6 +100,10 @@ func NewAWSProvisioner(ctx context.Context, region, endpoint, boundary string) (
// The economics storage sweep (console.sizeProvisionedStore) is the first:
// it sizes a tenant's provisioned bucket directly, with no IAM involved.
func S3ClientFor(ctx context.Context, region, endpoint string) (*awss3.Client, error) {
+ return s3ClientFor(ctx, region, endpoint)
+}
+
+func s3ClientFor(ctx context.Context, region, endpoint string, extra ...func(*awss3.Options)) (*awss3.Client, error) {
cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region))
if err != nil {
return nil, fmt.Errorf("objstore: load aws config: %w", err)
@@ -108,6 +115,9 @@ func S3ClientFor(ctx context.Context, region, endpoint string) (*awss3.Client, e
o.RequestChecksumCalculation = aws.RequestChecksumCalculationWhenRequired
o.ResponseChecksumValidation = aws.ResponseChecksumValidationWhenRequired
}
+ for _, fn := range extra {
+ fn(o)
+ }
}), nil
}
diff --git a/internal/objstore/fence.go b/internal/objstore/fence.go
new file mode 100644
index 000000000..3edb48641
--- /dev/null
+++ b/internal/objstore/fence.go
@@ -0,0 +1,76 @@
+package objstore
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "time"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
+)
+
+// The fence: what makes a walk's lease mean something at the BACKEND.
+//
+// A lease the walk checks before it calls the provider is not enough. A
+// process can be paused after the check and before the request leaves
+// (a stopped VM, a long collection, a debugger), and wake after its lease
+// has run out and another walk has taken the bucket. Nothing the client
+// checks beforehand can stop the request it then sends. Only the backend
+// can refuse it.
+//
+// So every request a walk makes is stamped with the instant its lease was
+// taken, and the backend refuses a request whose stamp is too old:
+//
+// - AWS signs each request with a timestamp and rejects one whose
+// timestamp is more than fifteen minutes from its own clock. The
+// signer normally stamps "now"; under a fence it stamps the lease's
+// instant instead (fencedSigner). A request sent after that window is
+// rejected by AWS whenever the process happens to send it.
+// - The in-memory provisioner refuses a call whose fence is older than
+// the same window.
+//
+// The lease on a bucket name lasts longer than the window (fenceWindow
+// plus fenceSkew), and a lease that was not given back cleanly cannot be
+// taken over until it has run out. So by the time another walk may take
+// the name, every request the earlier walk could ever send is one the
+// backend refuses. fenceSkew is the allowance for this host's clock
+// running ahead of the backend's.
+const (
+ fenceWindow = 15 * time.Minute
+ fenceSkew = 5 * time.Minute
+)
+
+// ErrFenced: the backend refused a request whose walk's lease is too old.
+var ErrFenced = errors.New("objstore: request refused: its walk's lease has run out")
+
+type fenceKey struct{}
+
+// withFence marks every provider call made with ctx as part of a walk
+// whose lease was taken at `at`.
+func withFence(ctx context.Context, at time.Time) context.Context {
+ return context.WithValue(ctx, fenceKey{}, at.UTC())
+}
+
+func fenceFrom(ctx context.Context) (time.Time, bool) {
+ at, ok := ctx.Value(fenceKey{}).(time.Time)
+ return at, ok
+}
+
+// fencedSigner signs with the walk's lease instant when the request's
+// context carries one, and with the time it is given otherwise. It is
+// installed on the provisioner's S3 and IAM clients.
+type fencedSigner struct {
+ inner interface {
+ SignHTTP(ctx context.Context, credentials aws.Credentials, r *http.Request, payloadHash string, service string, region string, signingTime time.Time, optFns ...func(*v4.SignerOptions)) error
+ }
+}
+
+func newFencedSigner() fencedSigner { return fencedSigner{inner: v4.NewSigner()} }
+
+func (f fencedSigner) SignHTTP(ctx context.Context, credentials aws.Credentials, r *http.Request, payloadHash string, service string, region string, signingTime time.Time, optFns ...func(*v4.SignerOptions)) error {
+ if at, ok := fenceFrom(ctx); ok {
+ signingTime = at
+ }
+ return f.inner.SignHTTP(ctx, credentials, r, payloadHash, service, region, signingTime, optFns...)
+}
diff --git a/internal/objstore/fence_aws_test.go b/internal/objstore/fence_aws_test.go
new file mode 100644
index 000000000..022cbb2da
--- /dev/null
+++ b/internal/objstore/fence_aws_test.go
@@ -0,0 +1,91 @@
+package objstore
+
+import (
+ "context"
+ "errors"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ awsconfig "github.com/aws/aws-sdk-go-v2/config"
+ "github.com/aws/aws-sdk-go-v2/service/iam"
+ awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
+ "github.com/aws/smithy-go"
+)
+
+// TestFenceAgainstAWS checks the fence's premise against the real backend,
+// with read-only calls: AWS refuses a request whose signing time is older
+// than its window, whenever it arrives, and accepts one inside it.
+//
+// It runs only when CARLOS_FENCE_AWS_BUCKET names a bucket the caller's
+// credentials may list. It lists one key of that bucket and asks IAM for a
+// user that does not exist. It changes nothing.
+func TestFenceAgainstAWS(t *testing.T) {
+ bucket := os.Getenv("CARLOS_FENCE_AWS_BUCKET")
+ if bucket == "" {
+ t.Skip("set CARLOS_FENCE_AWS_BUCKET (and AWS credentials) to check the fence against AWS; read-only")
+ }
+ region := os.Getenv("CARLOS_FENCE_AWS_REGION")
+ if region == "" {
+ region = "eu-west-1"
+ }
+ ctx := context.Background()
+ s3c, err := s3ClientFor(ctx, region, "", func(o *awss3.Options) {
+ o.HTTPSignerV4 = newFencedSigner()
+ o.RetryMaxAttempts = 1
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region))
+ if err != nil {
+ t.Fatal(err)
+ }
+ iamc := iam.NewFromConfig(cfg, func(o *iam.Options) {
+ o.HTTPSignerV4 = newFencedSigner()
+ o.RetryMaxAttempts = 1
+ })
+ code := func(err error) string {
+ var api smithy.APIError
+ if errors.As(err, &api) {
+ return api.ErrorCode() + ": " + api.ErrorMessage()
+ }
+ if err != nil {
+ return err.Error()
+ }
+ return "ok"
+ }
+ list := func(age time.Duration) string {
+ _, err := s3c.ListObjectsV2(withFence(ctx, time.Now().Add(-age)), &awss3.ListObjectsV2Input{Bucket: aws.String(bucket), MaxKeys: aws.Int32(1)})
+ return code(err)
+ }
+ getUser := func(age time.Duration) string {
+ _, err := iamc.GetUser(withFence(ctx, time.Now().Add(-age)), &iam.GetUserInput{UserName: aws.String("carlos-fence-probe-does-not-exist")})
+ return code(err)
+ }
+ for _, c := range []struct {
+ age time.Duration
+ refused bool
+ }{
+ {time.Minute, false},
+ {fenceWindow - 2*time.Minute, false},
+ {fenceWindow + time.Minute, true},
+ {fenceWindow + fenceSkew, true},
+ } {
+ s3Answer, iamAnswer := list(c.age), getUser(c.age)
+ t.Logf("signed %v ago: S3 says %q; IAM says %q", c.age, s3Answer, iamAnswer)
+ s3Refused := strings.Contains(s3Answer, "RequestTimeTooSkewed")
+ iamRefused := strings.Contains(iamAnswer, "Signature expired") || strings.Contains(iamAnswer, "SignatureDoesNotMatch") || strings.Contains(iamAnswer, "RequestExpired")
+ if s3Refused != c.refused {
+ t.Errorf("S3, signed %v ago: refused=%v, want %v (%s)", c.age, s3Refused, c.refused, s3Answer)
+ }
+ if c.refused && !iamRefused {
+ t.Errorf("IAM, signed %v ago: accepted (%s); the fence does not hold for IAM", c.age, iamAnswer)
+ }
+ if !c.refused && iamRefused {
+ t.Logf("IAM refuses a request signed only %v ago: its window is shorter than S3's, which is the safe direction", c.age)
+ }
+ }
+}
diff --git a/internal/objstore/memprovisioner.go b/internal/objstore/memprovisioner.go
index 4da037136..2f6d98b21 100644
--- a/internal/objstore/memprovisioner.go
+++ b/internal/objstore/memprovisioner.go
@@ -67,6 +67,9 @@ func (p *MemProvisioner) takeFailure(method string) error {
}
func (p *MemProvisioner) EnsureBucket(ctx context.Context, name string) (string, error) {
+ if err := memFenced(ctx); err != nil {
+ return "", err
+ }
p.mu.Lock()
defer p.mu.Unlock()
if err := p.takeFailure("EnsureBucket"); err != nil {
@@ -79,6 +82,9 @@ func (p *MemProvisioner) EnsureBucket(ctx context.Context, name string) (string,
}
func (p *MemProvisioner) EnsureUser(ctx context.Context, name, path, bucketARN string, deny *DenyTag) (string, error) {
+ if err := memFenced(ctx); err != nil {
+ return "", err
+ }
p.mu.Lock()
defer p.mu.Unlock()
if err := p.takeFailure("EnsureUser"); err != nil {
@@ -110,6 +116,9 @@ func (p *MemProvisioner) PolicyFor(user string) string {
}
func (p *MemProvisioner) MintKey(ctx context.Context, user string) (string, string, error) {
+ if err := memFenced(ctx); err != nil {
+ return "", "", err
+ }
p.mu.Lock()
defer p.mu.Unlock()
if err := p.takeFailure("MintKey"); err != nil {
@@ -130,6 +139,9 @@ func (p *MemProvisioner) MintKey(ctx context.Context, user string) (string, stri
}
func (p *MemProvisioner) DeleteKey(ctx context.Context, user, id string) error {
+ if err := memFenced(ctx); err != nil {
+ return err
+ }
p.mu.Lock()
defer p.mu.Unlock()
if err := p.takeFailure("DeleteKey"); err != nil {
@@ -142,6 +154,9 @@ func (p *MemProvisioner) DeleteKey(ctx context.Context, user, id string) error {
}
func (p *MemProvisioner) ReapKeys(ctx context.Context, user string, keep []string) error {
+ if err := memFenced(ctx); err != nil {
+ return err
+ }
p.mu.Lock()
defer p.mu.Unlock()
if err := p.takeFailure("ReapKeys"); err != nil {
@@ -164,6 +179,9 @@ func (p *MemProvisioner) ReapKeys(ctx context.Context, user string, keep []strin
}
func (p *MemProvisioner) EmptyBucket(ctx context.Context, name string) error {
+ if err := memFenced(ctx); err != nil {
+ return err
+ }
p.mu.Lock()
defer p.mu.Unlock()
if err := p.takeFailure("EmptyBucket"); err != nil {
@@ -178,6 +196,9 @@ func (p *MemProvisioner) EmptyBucket(ctx context.Context, name string) error {
}
func (p *MemProvisioner) DeleteBucket(ctx context.Context, name string) error {
+ if err := memFenced(ctx); err != nil {
+ return err
+ }
p.mu.Lock()
defer p.mu.Unlock()
if err := p.takeFailure("DeleteBucket"); err != nil {
@@ -188,6 +209,9 @@ func (p *MemProvisioner) DeleteBucket(ctx context.Context, name string) error {
}
func (p *MemProvisioner) DeleteUser(ctx context.Context, user string) error {
+ if err := memFenced(ctx); err != nil {
+ return err
+ }
p.mu.Lock()
defer p.mu.Unlock()
if err := p.takeFailure("DeleteUser"); err != nil {
@@ -228,3 +252,13 @@ func (m *MemProvisioner) BucketGrantees(_ context.Context, name string) ([]strin
sort.Strings(out)
return out, nil
}
+
+// memFenced is the in-memory backend's half of the fence (fence.go): a call
+// whose walk's lease instant is older than the window is refused, as AWS
+// refuses a request whose signing time is too old.
+func memFenced(ctx context.Context) error {
+ if at, ok := fenceFrom(ctx); ok && walkClock().After(at.Add(fenceWindow)) {
+ return ErrFenced
+ }
+ return nil
+}
diff --git a/internal/objstore/objstore.go b/internal/objstore/objstore.go
index b8a1a7063..63a8656ef 100644
--- a/internal/objstore/objstore.go
+++ b/internal/objstore/objstore.go
@@ -145,13 +145,18 @@ type Record struct {
Teardown *TeardownBlock `json:"teardown,omitempty"`
}
-// TeardownBlock records the irreversible steps of a teardown that have
-// succeeded.
+// TeardownBlock is a teardown's write-ahead record on the store it is
+// taking apart. It is written before the teardown revokes or destroys
+// anything, so from that write on the record never again reads as a
+// healthy, provisioned store: a grant over it runs the walk in full
+// instead of trusting a Provisioned block whose user or bucket may be gone.
type TeardownBlock struct {
+ // StartedAt is when a teardown first began on this record.
+ StartedAt time.Time `json:"started_at"`
// BucketDeletedAt: this store's bucket was emptied and deleted. A
// re-run must never empty or delete a bucket of that name again: if
// one exists now, somebody else created it.
- BucketDeletedAt time.Time `json:"bucket_deleted_at"`
+ BucketDeletedAt time.Time `json:"bucket_deleted_at,omitzero"`
}
func Key(acct, app string) string { return release.AppPrefix(acct, app) + "store.json" }
diff --git a/internal/objstore/ownership.go b/internal/objstore/ownership.go
index 00b866a68..b30a2c4b8 100644
--- a/internal/objstore/ownership.go
+++ b/internal/objstore/ownership.go
@@ -132,6 +132,11 @@ type BucketOwnership struct {
// enabled on it and did not disable it, because another app still held
// the bucket. Whoever destroys the bucket owes the scanner's removal.
ScannerOwed bool `json:"scanner_owed,omitempty"`
+ // DestroyedAt is when a teardown last finished destroying the bucket:
+ // whatever any store had put in it before that instant is gone. A store
+ // that had already left the bucket by then has nothing left in it, and
+ // its own unfinished teardown has nothing left to wait for.
+ DestroyedAt *time.Time `json:"destroyed_at,omitempty"`
}
func validOwnershipTime(t time.Time) error {
@@ -396,7 +401,8 @@ func CloseBucketOwner(ctx context.Context, s release.Store, bucket, acct, app st
// marker goes with its interval, and the scanner it owed is gone
// with the bucket.
if d := b.Destroying; d != nil && d.Account == acct && d.App == app {
- b.Destroying, b.ScannerOwed, changed = nil, false, true
+ at := until
+ b.Destroying, b.ScannerOwed, b.DestroyedAt, changed = nil, false, &at, true
}
if !changed {
return nil, errOwnershipUnchanged
@@ -422,18 +428,21 @@ func CloseBucketOwner(ctx context.Context, s release.Store, bucket, acct, app st
// same time cannot each defer to the other and leave it with nobody: the
// second to write finds the first Leaving and destroys. If this app had
// scanning enabled, the ledger records that the scanner is owed. other
-// names one holder.
+// names one holder. foreign reports whether ANY app of another account
+// still has an open interval on the bucket, leaving or not: an app that is
+// leaving has not had its credentials revoked yet, and may never, so
+// objects left in the bucket are still within another account's reach.
//
// A store with no interval of its own (provisioned before ledgers) gets
// one, from its grant, so the decision is recorded. An app whose interval
// is already closed (a re-run after its close) is not reopened.
-func beginBucketTeardown(ctx context.Context, s release.Store, bucket, acct, app string, from time.Time, holders []OwnerRef, scanning bool, now time.Time) (destroy, scannerOwed bool, other OwnerRef, err error) {
+func beginBucketTeardown(ctx context.Context, s release.Store, bucket, acct, app string, from time.Time, holders []OwnerRef, scanning bool, now time.Time) (destroy, scannerOwed bool, other OwnerRef, foreign bool, err error) {
from, now = from.UTC(), now.UTC()
if err := validOwnershipTime(from); err != nil {
- return false, false, OwnerRef{}, fmt.Errorf("objstore: teardown of bucket %s: %v", bucket, err)
+ return false, false, OwnerRef{}, false, fmt.Errorf("objstore: teardown of bucket %s: %v", bucket, err)
}
err = writeBucketOwnership(ctx, s, bucket, func(b *BucketOwnership) (*BucketOwnership, error) {
- destroy, scannerOwed, other = false, false, OwnerRef{}
+ destroy, scannerOwed, other, foreign = false, false, OwnerRef{}, false
if b == nil {
b = &BucketOwnership{V: 1, Bucket: bucket}
}
@@ -446,21 +455,30 @@ func beginBucketTeardown(ctx context.Context, s release.Store, bucket, acct, app
}
continue
}
+ if iv.open() && iv.Account != acct {
+ foreign = true
+ }
if iv.open() && !iv.Leaving && other.Account == "" {
other = OwnerRef{Account: iv.Account, App: iv.App, Since: iv.From}
}
}
- if other.Account == "" {
- for _, h := range holders {
- if !known[h.Account+"/"+h.App] {
- other = h
- break
- }
+ for _, h := range holders {
+ if known[h.Account+"/"+h.App] {
+ continue
+ }
+ if h.Account != acct {
+ foreign = true
+ }
+ if other.Account == "" {
+ other = h
}
}
if d := b.Destroying; other.Account == "" && d != nil && (d.Account != acct || d.App != app) {
// Another teardown already decided it is the last holder.
other = *d
+ if d.Account != acct {
+ foreign = true
+ }
}
if mine < 0 && !known[acct+"/"+app] {
b.Intervals = append(b.Intervals, OwnerInterval{Account: acct, App: app, From: from})
@@ -487,7 +505,7 @@ func beginBucketTeardown(ctx context.Context, s release.Store, bucket, acct, app
}
return b, nil
})
- return destroy, scannerOwed, other, err
+ return destroy, scannerOwed, other, foreign, err
}
// stillDestroying re-reads the ledger and reports whether acct/app's
diff --git a/internal/objstore/ownership_test.go b/internal/objstore/ownership_test.go
index 88514ae1d..dea2ef67a 100644
--- a/internal/objstore/ownership_test.go
+++ b/internal/objstore/ownership_test.go
@@ -4,6 +4,8 @@ import (
"context"
"encoding/json"
"errors"
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "net/http"
"testing"
"time"
@@ -503,11 +505,12 @@ func TestGrantStopsWhenLedgerWriteFails(t *testing.T) {
mustLedger(t, s, ledgerBucket)
}
-// A bucket that already exists under the name this grant would mint may be
-// reachable by someone the ledger never saw (an operator adopted it for
-// another app before ledgers existed). The first grant must not claim the
-// ledger is complete.
-func TestGrantClaimsNothingForABucketThatAlreadyExists(t *testing.T) {
+// A grant that mints its bucket finds a bucket of that name already there,
+// and the ledger has never heard of this store. Somebody's objects are in
+// it. The grant is refused and leaves no grant block behind; an operator
+// who knows whose bucket it is adopts it by name, and that adoption claims
+// nothing about its past.
+func TestMintingGrantIsRefusedForABucketThatAlreadyExists(t *testing.T) {
ctx := context.Background()
s := release.NewDirStore(t.TempDir())
_, pub := testSealKeypair(t)
@@ -515,9 +518,23 @@ func TestGrantClaimsNothingForABucketThatAlreadyExists(t *testing.T) {
if _, err := p.EnsureBucket(ctx, ledgerBucket); err != nil {
t.Fatal(err)
}
+ p.Buckets[ledgerBucket]["left-behind"] = []byte("somebody's")
declare(t, s, "bab", "titogo", "TITO_S3")
- if _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", GrantSourceOperator, "", t0); err != nil {
- t.Fatal(err)
+ if _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", GrantSourceOperator, "", t0); !errors.Is(err, ErrBucketHeld) {
+ t.Fatalf("a minting grant onto an existing bucket: %v, want ErrBucketHeld", err)
+ }
+ if rec, _, _ := Get(ctx, s, "bab", "titogo"); rec == nil || rec.Grant != nil || rec.Provisioned != nil {
+ t.Fatalf("the refused grant left its block on the record: %+v", rec)
+ }
+ if _, ok := p.Users[UserName("bab", "titogo")]; ok {
+ t.Fatal("the refused grant created a user")
+ }
+ if b, _, _ := ReadBucketOwnership(ctx, s, ledgerBucket); b != nil {
+ t.Fatalf("the refused grant wrote a ledger: %+v", b)
+ }
+ // Adopted by name, on an operator's say-so.
+ if _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", GrantSourceOperator, ledgerBucket, t0); err != nil {
+ t.Fatalf("adopting the existing bucket: %v", err)
}
b := mustLedger(t, s, ledgerBucket)
if b.CompleteSince != nil {
@@ -614,6 +631,7 @@ func TestPartialBackfillOfASharedBucketResolvesNoOwner(t *testing.T) {
// bucket. When A is granted, the bucket does not exist, and A's ledger must
// still not claim to name everyone: B would reach the new bucket at once.
func TestGrantClaimsNothingWhileAnotherPrincipalNamesTheBucket(t *testing.T) {
+ advance := leaseClock(t)
ctx := context.Background()
s := release.NewDirStore(t.TempDir())
_, pub := testSealKeypair(t)
@@ -630,6 +648,8 @@ func TestGrantClaimsNothingWhileAnotherPrincipalNamesTheBucket(t *testing.T) {
if err := Teardown(ctx, s, p, nil, "bac", "other"); err == nil {
t.Fatal("the injected DeleteUser failure did not stop the teardown")
}
+ // The failed walk keeps its lease until it runs out.
+ advance(walkLease + time.Second)
p.FailNext = nil
if _, exists := p.Buckets[ledgerBucket]; exists {
t.Fatal("fixture: the bucket should be gone")
@@ -762,11 +782,23 @@ func TestObservationHourDoesNotResolveToTheNewOwner(t *testing.T) {
// Probes answer at once in these tests unless one says otherwise.
func init() { grantElapsed = func(time.Time) time.Duration { return 0 } }
+// leaseClock puts the walk leases on a clock the test moves. The returned
+// function advances it.
+func leaseClock(t *testing.T) func(time.Duration) {
+ t.Helper()
+ clock := time.Now()
+ prev := walkClock
+ walkClock = func() time.Time { return clock }
+ t.Cleanup(func() { walkClock = prev })
+ return func(d time.Duration) { clock = clock.Add(d) }
+}
+
// A teardown closes its ownership interval only after the credentials are
// revoked, and while it is unfinished nothing can be granted the name: the
// ledger says the bucket is being destroyed, and a grant is refused by the
// same compare-and-swap that would have opened its interval.
func TestTeardownHoldsTheNameUntilItHasFinished(t *testing.T) {
+ advance := leaseClock(t)
ctx := context.Background()
s := release.NewDirStore(t.TempDir())
_, pub := testSealKeypair(t)
@@ -779,6 +811,8 @@ func TestTeardownHoldsTheNameUntilItHasFinished(t *testing.T) {
if err := Teardown(ctx, s, p, nil, "bab", "titogo"); err == nil {
t.Fatal("the injected DeleteUser failure did not stop the teardown")
}
+ // The failed walk keeps its lease until it runs out.
+ advance(walkLease + time.Second)
if _, exists := p.Buckets[ledgerBucket]; exists {
t.Fatal("fixture: the bucket should be gone by the time DeleteUser fails")
}
@@ -830,6 +864,7 @@ func TestTeardownHoldsTheNameUntilItHasFinished(t *testing.T) {
// back: the marker is lifted, the record forgets the deleted bucket, and a
// later teardown destroys the new one.
func TestGrantOverItsOwnUnfinishedTeardownTakesTheStoreBack(t *testing.T) {
+ advance := leaseClock(t)
ctx := context.Background()
s := release.NewDirStore(t.TempDir())
_, pub := testSealKeypair(t)
@@ -842,6 +877,8 @@ func TestGrantOverItsOwnUnfinishedTeardownTakesTheStoreBack(t *testing.T) {
if err := Teardown(ctx, s, p, nil, "bab", "titogo"); err == nil {
t.Fatal("the injected DeleteUser failure did not stop the teardown")
}
+ // The failed walk keeps its lease until it runs out.
+ advance(walkLease + time.Second)
p.FailNext = nil
if _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", GrantSourceOperator, "", t0.Add(time.Hour)); err != nil {
t.Fatalf("re-grant: %v", err)
@@ -903,6 +940,7 @@ func TestMintingGrantIsRefusedWhileAnotherAppHoldsTheName(t *testing.T) {
// the teardown does not report itself done: B's record stands until a
// person, or A's own teardown, settles the bucket.
func TestRetriedLegacyTeardownLeavesABucketAnotherAppNowHolds(t *testing.T) {
+ advance := leaseClock(t)
ctx := context.Background()
s := release.NewDirStore(t.TempDir())
_, pub := testSealKeypair(t)
@@ -915,6 +953,8 @@ func TestRetriedLegacyTeardownLeavesABucketAnotherAppNowHolds(t *testing.T) {
if err := Teardown(ctx, s, p, nil, "bac", "other"); err == nil {
t.Fatal("the injected DeleteUser failure did not stop the teardown")
}
+ // The failed walk keeps its lease until it runs out.
+ advance(walkLease + time.Second)
p.FailNext = nil
// As the older build left it.
if err := s.Delete(ctx, BucketOwnershipKey(ledgerBucket)); err != nil {
@@ -929,9 +969,26 @@ func TestRetriedLegacyTeardownLeavesABucketAnotherAppNowHolds(t *testing.T) {
t.Fatal(err)
}
declare(t, s, "bab", "titogo", "TITO_S3")
+ // This build would not grant A the name at all while B's record names
+ // the bucket.
+ if _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", GrantSourceOperator, "", t0.Add(72*time.Hour)); !errors.Is(err, ErrBucketHeld) {
+ t.Fatalf("a minting grant while a legacy store's record names the bucket: %v, want ErrBucketHeld", err)
+ }
+ // The older build did. B's record is put out of sight for the length
+ // of A's grant, which is what that build saw, and put back.
+ raw, _, err := s.Get(ctx, Key("bac", "other"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.Delete(ctx, Key("bac", "other")); err != nil {
+ t.Fatal(err)
+ }
if _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", GrantSourceOperator, "", t0.Add(72*time.Hour)); err != nil {
t.Fatal(err)
}
+ if _, err := s.Put(ctx, Key("bac", "other"), raw, ""); err != nil {
+ t.Fatal(err)
+ }
p.Buckets[ledgerBucket]["invoice.pdf"] = []byte("the new owner's data")
sc := NewMemScanner()
if err := sc.EnsureEnabled(ctx, ledgerBucket); err != nil {
@@ -1090,11 +1147,11 @@ func TestConcurrentTeardownsOfASharedBucketLeaveExactlyOneDestroyer(t *testing.T
}
// Both teardowns have read the ledger with both intervals open. Then
// each makes its decision.
- destroyOne, _, _, err := beginBucketTeardown(ctx, s, ledgerBucket, "bab", "one", t0, nil, true, now)
+ destroyOne, _, _, _, err := beginBucketTeardown(ctx, s, ledgerBucket, "bab", "one", t0, nil, true, now)
if err != nil {
t.Fatal(err)
}
- destroyTwo, owed, _, err := beginBucketTeardown(ctx, s, ledgerBucket, "bab", "two", t0, nil, false, now)
+ destroyTwo, owed, _, _, err := beginBucketTeardown(ctx, s, ledgerBucket, "bab", "two", t0, nil, false, now)
if err != nil {
t.Fatal(err)
}
@@ -1109,10 +1166,10 @@ func TestConcurrentTeardownsOfASharedBucketLeaveExactlyOneDestroyer(t *testing.T
t.Fatalf("destroying marker: %+v", b.Destroying)
}
// A decision repeated (a re-run) is the same decision.
- if again, _, _, _ := beginBucketTeardown(ctx, s, ledgerBucket, "bab", "one", t0, nil, true, now); again {
+ if again, _, _, _, _ := beginBucketTeardown(ctx, s, ledgerBucket, "bab", "one", t0, nil, true, now); again {
t.Fatal("the leaving app's re-run decided to destroy a bucket another app is destroying")
}
- if again, _, _, _ := beginBucketTeardown(ctx, s, ledgerBucket, "bab", "two", t0, nil, false, now); !again {
+ if again, _, _, _, _ := beginBucketTeardown(ctx, s, ledgerBucket, "bab", "two", t0, nil, false, now); !again {
t.Fatal("the destroyer's re-run changed its mind")
}
}
@@ -1132,7 +1189,7 @@ func TestGrantAndTeardownCannotBothWin(t *testing.T) {
if err := OpenBucketOwner(ctx, s, ledgerBucket, "bab", "two", now, now, false); err != nil {
t.Fatal(err)
}
- if destroy, _, other, err := beginBucketTeardown(ctx, s, ledgerBucket, "bab", "one", t0, nil, false, now); err != nil || destroy || other.App != "two" {
+ if destroy, _, other, _, err := beginBucketTeardown(ctx, s, ledgerBucket, "bab", "one", t0, nil, false, now); err != nil || destroy || other.App != "two" {
t.Fatalf("teardown after a grant landed: destroy=%v other=%+v err=%v", destroy, other, err)
}
// The teardown first.
@@ -1140,7 +1197,7 @@ func TestGrantAndTeardownCannotBothWin(t *testing.T) {
if err := OpenBucketOwner(ctx, s, ledgerBucket, "bab", "one", t0, t0, false); err != nil {
t.Fatal(err)
}
- if destroy, _, _, err := beginBucketTeardown(ctx, s, ledgerBucket, "bab", "one", t0, nil, false, now); err != nil || !destroy {
+ if destroy, _, _, _, err := beginBucketTeardown(ctx, s, ledgerBucket, "bab", "one", t0, nil, false, now); err != nil || !destroy {
t.Fatalf("teardown with no other holder: destroy=%v err=%v", destroy, err)
}
if err := OpenBucketOwner(ctx, s, ledgerBucket, "bab", "two", now, now, false); !errors.Is(err, ErrBucketBeingDestroyed) {
@@ -1189,3 +1246,326 @@ func TestCompletenessIsDatedAfterTheProbesAnswer(t *testing.T) {
t.Fatal("the 11:00 hour did not resolve")
}
}
+
+// One walk at a time on a bucket name, across processes, and the authority
+// is the invocation's, not the app's.
+func TestWalkLeaseIsOneInvocationAtATime(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ _, pub := testSealKeypair(t)
+ p := NewMemProvisioner("eu-west-1")
+ declare(t, s, "bab", "titogo", "TITO_S3")
+ if _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", GrantSourceOperator, "", t0); err != nil {
+ t.Fatal(err)
+ }
+ p.Buckets[ledgerBucket]["kept"] = []byte("data")
+ // Another process is in the middle of a teardown of this very app.
+ other, err := acquireBucketWalk(ctx, s, ledgerBucket, "bab", "titogo", "teardown")
+ if err != nil {
+ t.Fatal(err)
+ }
+ // A second teardown of the same app does not inherit its authority.
+ if err := Teardown(ctx, s, p, nil, "bab", "titogo"); !errors.Is(err, ErrBucketBusy) {
+ t.Fatalf("a second teardown while one holds the lease: %v, want ErrBucketBusy", err)
+ }
+ if string(p.Buckets[ledgerBucket]["kept"]) != "data" {
+ t.Fatal("the refused teardown touched the bucket")
+ }
+ // Nor does a grant, of this app or another.
+ if _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", GrantSourceOperator, "", t0.Add(time.Hour)); !errors.Is(err, ErrBucketBusy) {
+ t.Fatalf("a grant while a teardown holds the lease: %v, want ErrBucketBusy", err)
+ }
+ declare(t, s, "bac", "other", "OTHER_S3")
+ if _, err := Grant(ctx, s, p, testDeployment(), pub, "bac", "other", "op", GrantSourceOperator, ledgerBucket, t0.Add(time.Hour)); !errors.Is(err, ErrBucketBusy) {
+ t.Fatalf("another app's grant while a teardown holds the lease: %v, want ErrBucketBusy", err)
+ }
+ // The holder's calls carry its fence, and a deadline that falls before
+ // the backend's window for that fence closes.
+ bounded, cancel := other.bound(ctx)
+ deadline, ok := bounded.Deadline()
+ at, fenced := fenceFrom(bounded)
+ cancel()
+ if !ok || !fenced || !at.Equal(other.lock.SignedAt) || !deadline.Before(time.Now().Add(fenceWindow)) || !at.Add(fenceWindow).Before(other.lock.Until) {
+ t.Fatalf("the walk's calls are not fenced inside its lease: fence %v deadline %v, lease %v to %v", at, deadline, other.lock.SignedAt, other.lock.Until)
+ }
+ if held, err := other.held(ctx); err != nil || !held {
+ t.Fatalf("the holder does not hold its own lease: %v %v", held, err)
+ }
+ // Given back cleanly, the name is free again; and a release cannot be
+ // repeated into someone else's lease.
+ other.release(true)
+ next, err := acquireBucketWalk(ctx, s, ledgerBucket, "bac", "other", "grant")
+ if err != nil {
+ t.Fatalf("after a clean release: %v", err)
+ }
+ other.closed = false
+ other.release(true) // a stale release, after another invocation took the lease
+ if held, err := next.held(ctx); err != nil || !held {
+ t.Fatalf("a stale release took another invocation's lease away: %v %v", held, err)
+ }
+ next.release(true)
+ if err := Teardown(ctx, s, p, nil, "bab", "titogo"); err != nil {
+ t.Fatalf("teardown once the lease is free: %v", err)
+ }
+}
+
+// A walk that ended with an error may have a request still on its way. Its
+// lease is not freed: until it runs out, only the same app running the
+// same kind of walk again may take the name. And a clean finish of that
+// retry still does not free the name before the first attempt has run out.
+func TestWalkLeaseStandsAfterAWalkThatFailed(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ advance := leaseClock(t)
+ failed, err := acquireBucketWalk(ctx, s, ledgerBucket, "bab", "titogo", "teardown")
+ if err != nil {
+ t.Fatal(err)
+ }
+ failed.release(false)
+ if _, err := acquireBucketWalk(ctx, s, ledgerBucket, "bac", "other", "grant"); !errors.Is(err, ErrBucketBusy) {
+ t.Fatalf("another app after a failed walk: %v, want ErrBucketBusy", err)
+ }
+ if _, err := acquireBucketWalk(ctx, s, ledgerBucket, "bab", "titogo", "grant"); !errors.Is(err, ErrBucketBusy) {
+ t.Fatalf("the same app, another kind of walk, after a failed walk: %v, want ErrBucketBusy", err)
+ }
+ advance(time.Minute)
+ retry, err := acquireBucketWalk(ctx, s, ledgerBucket, "bab", "titogo", "teardown")
+ if err != nil {
+ t.Fatalf("the same app re-running the same walk: %v", err)
+ }
+ retry.release(true)
+ // The retry was clean, but the first attempt's requests could still be
+ // accepted by the backend until its window closes.
+ if _, err := acquireBucketWalk(ctx, s, ledgerBucket, "bac", "other", "grant"); !errors.Is(err, ErrBucketBusy) {
+ t.Fatalf("another app before the failed attempt has run out: %v, want ErrBucketBusy", err)
+ }
+ advance(walkLease)
+ free, err := acquireBucketWalk(ctx, s, ledgerBucket, "bac", "other", "grant")
+ if err != nil {
+ t.Fatalf("after the failed attempt ran out: %v", err)
+ }
+ free.release(true)
+}
+
+// The proof that a timed lease is not all there is. A teardown takes its
+// lease and is then paused, for longer than the lease, at the worst
+// moment: after every check it makes and before its request leaves. While
+// it sleeps the lease runs out, another walk takes the name, finishes, and
+// a different app is granted the bucket and fills it. The first teardown
+// wakes and sends its request to empty the bucket.
+//
+// No check in the client can stop that request: the checks were all made
+// before the pause. The backend stops it. The request carries the lease's
+// instant, and the backend refuses a request stamped that long ago.
+//
+// At every instant, one of two things is true: the backend would still
+// accept the old walk's requests and nobody can take its lease, or
+// somebody has taken the lease and the backend refuses the old walk.
+func TestPausedWalkCannotActAfterItsLeaseIsTaken(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ _, pub := testSealKeypair(t)
+ p := NewMemProvisioner("eu-west-1")
+ advance := leaseClock(t)
+ declare(t, s, "bab", "titogo", "TITO_S3")
+ if _, err := Grant(ctx, s, p, testDeployment(), pub, "bab", "titogo", "op", GrantSourceOperator, "", t0); err != nil {
+ t.Fatal(err)
+ }
+ // The teardown that will be paused: it holds the lease, and this is
+ // the context every one of its provider calls is made with. The test
+ // keeps the context without its client-side deadline, which is exactly
+ // what a pause after the deadline check amounts to.
+ paused, err := acquireBucketWalk(ctx, s, ledgerBucket, "bab", "titogo", "teardown")
+ if err != nil {
+ t.Fatal(err)
+ }
+ stale := withFence(ctx, paused.lock.SignedAt)
+
+ // While the backend would still accept its requests, nobody can take
+ // the lease: not at the start, and not at the very end of the window.
+ for _, d := range []time.Duration{0, fenceWindow - time.Second} {
+ advance(d)
+ if err := memFenced(stale); err != nil {
+ t.Fatalf("%v into the lease the backend already refuses the holder: %v", d, err)
+ }
+ if _, err := acquireBucketWalk(ctx, s, ledgerBucket, "bac", "other", "grant"); !errors.Is(err, ErrBucketBusy) {
+ t.Fatalf("%v into the lease it was taken over while the backend still accepts the holder: %v", d, err)
+ }
+ }
+ // Past the window and before the lease runs out: the backend refuses
+ // the holder, and still nobody can take the lease. The two never overlap.
+ advance(2 * time.Second)
+ if err := memFenced(stale); !errors.Is(err, ErrFenced) {
+ t.Fatalf("past the window the backend accepts the holder: %v", err)
+ }
+ if _, err := acquireBucketWalk(ctx, s, ledgerBucket, "bac", "other", "grant"); !errors.Is(err, ErrBucketBusy) {
+ t.Fatalf("taken over before the lease ran out: %v", err)
+ }
+ // The lease runs out. Another process finishes the teardown, and
+ // another app is granted the name and fills the bucket.
+ advance(fenceSkew)
+ if err := Teardown(ctx, s, p, nil, "bab", "titogo"); err != nil {
+ t.Fatalf("the teardown that took over: %v", err)
+ }
+ declare(t, s, "bac", "other", "OTHER_S3")
+ if _, err := Grant(ctx, s, p, testDeployment(), pub, "bac", "other", "op", GrantSourceOperator, ledgerBucket, t0.Add(time.Hour)); err != nil {
+ t.Fatalf("the grant after it: %v", err)
+ }
+ p.Buckets[ledgerBucket]["invoice.pdf"] = []byte("the new holder's data")
+ // The paused teardown wakes and sends what it was about to send.
+ for name, call := range map[string]func() error{
+ "EmptyBucket": func() error { return p.EmptyBucket(stale, ledgerBucket) },
+ "DeleteBucket": func() error { return p.DeleteBucket(stale, ledgerBucket) },
+ "DeleteUser": func() error { return p.DeleteUser(stale, UserName("bac", "other")) },
+ } {
+ if err := call(); !errors.Is(err, ErrFenced) {
+ t.Fatalf("%s from the paused walk after its lease was taken: %v, want the backend's refusal", name, err)
+ }
+ }
+ if got := string(p.Buckets[ledgerBucket]["invoice.pdf"]); got != "the new holder's data" {
+ t.Fatalf("the paused walk destroyed the new holder's bucket (object now %q)", got)
+ }
+ if _, ok := p.Users[UserName("bac", "other")]; !ok {
+ t.Fatal("the paused walk removed the new holder's user")
+ }
+ // And its release, when it finally returns, touches nobody's lease.
+ paused.release(false)
+ if _, err := Grant(ctx, s, p, testDeployment(), pub, "bac", "other", "op", GrantSourceOperator, ledgerBucket, t0.Add(2*time.Hour)); err != nil {
+ t.Fatalf("the new holder's next walk after the stale release: %v", err)
+ }
+}
+
+// The AWS half of the fence: a request made inside a walk is signed with
+// the walk's lease instant, not with the time it happens to be sent, so
+// AWS's own check on the age of a signature is a check on the lease.
+func TestFencedSignerStampsTheLeaseInstant(t *testing.T) {
+ signer := newFencedSigner()
+ creds := aws.Credentials{AccessKeyID: "AKIDEXAMPLE", SecretAccessKey: "secret"}
+ lease := time.Date(2026, 9, 3, 10, 0, 0, 0, time.UTC)
+ sentAt := lease.Add(40 * time.Minute) // the process woke much later
+ sign := func(ctx context.Context) string {
+ req, err := http.NewRequest(http.MethodDelete, "https://s3.eu-west-1.amazonaws.com/"+ledgerBucket, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := signer.SignHTTP(ctx, creds, req, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "s3", "eu-west-1", sentAt); err != nil {
+ t.Fatal(err)
+ }
+ return req.Header.Get("X-Amz-Date")
+ }
+ if got := sign(withFence(context.Background(), lease)); got != "20260903T100000Z" {
+ t.Fatalf("a fenced request is dated %s, want the lease instant 20260903T100000Z", got)
+ }
+ if got := sign(context.Background()); got != "20260903T104000Z" {
+ t.Fatalf("an unfenced request is dated %s, want the time it was sent", got)
+ }
+}
+
+// A store that left a bucket to another account does not wait for ever.
+// Once the last holder has destroyed that bucket, the first store's objects
+// are gone with it, and its teardown finishes, even if a bucket of the same
+// name has since been granted to somebody new.
+func TestRetainedTeardownFinishesOnceTheBucketItLeftIsDestroyed(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ _, pub := testSealKeypair(t)
+ p := NewMemProvisioner("eu-west-1")
+ const shared = "carlos-store-shared-archive"
+ at := t0
+ clock := ownershipNow
+ ownershipNow = func() time.Time { return at }
+ defer func() { ownershipNow = clock }()
+ for _, acct := range []string{"bac", "bad"} {
+ declare(t, s, acct, "one", "ARCHIVE_S3")
+ if _, err := Grant(ctx, s, p, testDeployment(), pub, acct, "one", "op", GrantSourceOperator, shared, at); err != nil {
+ t.Fatal(err)
+ }
+ }
+ at = at.Add(time.Hour)
+ if err := Teardown(ctx, s, p, nil, "bac", "one"); !errors.Is(err, ErrBucketRetained) {
+ t.Fatalf("first teardown: %v, want ErrBucketRetained", err)
+ }
+ // Re-granted while retained: the walk runs in full and mints a new key.
+ // It does not report the store healthy on a key that was deleted.
+ at = at.Add(time.Hour)
+ rec, err := Grant(ctx, s, p, testDeployment(), pub, "bac", "one", "op", GrantSourceOperator, shared, at)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if u, ok := p.Users[UserName("bac", "one")]; !ok || len(u.Keys) == 0 || rec.Provisioned == nil || u.Keys[rec.Provisioned.KeyID] == "" {
+ t.Fatalf("the re-grant left the store on a key that no longer exists: record %+v", rec.Provisioned)
+ }
+ at = at.Add(time.Hour)
+ if err := Teardown(ctx, s, p, nil, "bac", "one"); !errors.Is(err, ErrBucketRetained) {
+ t.Fatalf("second teardown: %v, want ErrBucketRetained", err)
+ }
+ // The other holder goes, and takes the bucket with it.
+ at = at.Add(time.Hour)
+ if err := Teardown(ctx, s, p, nil, "bad", "one"); err != nil {
+ t.Fatal(err)
+ }
+ if _, exists := p.Buckets[shared]; exists {
+ t.Fatal("the last holder's teardown left the bucket")
+ }
+ // Somebody new is granted the name before the first store re-runs.
+ at = at.Add(time.Hour)
+ declare(t, s, "bae", "new", "ARCHIVE_S3")
+ if _, err := Grant(ctx, s, p, testDeployment(), pub, "bae", "new", "op", GrantSourceOperator, shared, at); err != nil {
+ t.Fatal(err)
+ }
+ p.Buckets[shared]["fresh"] = []byte("the newcomer's data")
+ at = at.Add(time.Hour)
+ if err := Teardown(ctx, s, p, nil, "bac", "one"); err != nil {
+ t.Fatalf("the first store's teardown after its bucket was destroyed: %v, want it to finish", err)
+ }
+ if rec, _, _ := Get(ctx, s, "bac", "one"); rec != nil {
+ t.Fatalf("the first store's record remains: %+v", rec)
+ }
+ if string(p.Buckets[shared]["fresh"]) != "the newcomer's data" {
+ t.Fatal("finishing the old teardown touched the newcomer's bucket")
+ }
+}
+
+// The decision to destroy is read again from the ledger immediately before
+// each destructive call. The lease keeps other walks out, so a change in
+// between is a hand on the ledger (an operator's, or a build that does not
+// take the lease). Whichever it is, the teardown stops: nothing more is
+// destroyed on a decision the ledger no longer carries.
+func TestTeardownStopsWhenTheLedgerNoLongerCarriesItsDecision(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ _, pub := testSealKeypair(t)
+ mem := NewMemProvisioner("eu-west-1")
+ leaseClock(t)
+ declare(t, s, "bab", "titogo", "TITO_S3")
+ if _, err := Grant(ctx, s, mem, testDeployment(), pub, "bab", "titogo", "op", GrantSourceOperator, "", t0); err != nil {
+ t.Fatal(err)
+ }
+ // The marker is withdrawn while the bucket is being emptied: after the
+ // walk confirmed that step, before it would delete the bucket.
+ var calls []string
+ p := &interleavingProvisioner{MemProvisioner: mem, between: func(method string) {
+ calls = append(calls, method)
+ if method != "EmptyBucket" {
+ return
+ }
+ if err := writeBucketOwnership(ctx, s, ledgerBucket, func(b *BucketOwnership) (*BucketOwnership, error) {
+ b.Destroying = nil
+ return b, nil
+ }); err != nil {
+ t.Error(err)
+ }
+ }}
+ err := Teardown(ctx, s, p, nil, "bab", "titogo")
+ if err == nil {
+ t.Fatalf("the teardown finished on a decision the ledger no longer carried (calls %v)", calls)
+ }
+ if _, ok := mem.Buckets[ledgerBucket]; !ok {
+ t.Fatalf("the bucket was deleted on a decision the ledger no longer carried (calls %v)", calls)
+ }
+ for _, c := range calls {
+ if c == "DeleteBucket" {
+ t.Fatalf("DeleteBucket was called: %v", calls)
+ }
+ }
+}
diff --git a/internal/objstore/walk.go b/internal/objstore/walk.go
index d6a1a6c9d..f764d857b 100644
--- a/internal/objstore/walk.go
+++ b/internal/objstore/walk.go
@@ -139,7 +139,7 @@ func ValidBucketName(name string) bool { return bucketNameRe.MatchString(name) }
// whose authority was already established and must not restate it, exactly as
// it must not re-choose the bucket.
func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment,
- sealPub *ecdh.PublicKey, acct, app, actor, source, adoptBucket string, now time.Time) (*Record, error) {
+ sealPub *ecdh.PublicKey, acct, app, actor, source, adoptBucket string, now time.Time) (granted *Record, walkErr error) {
// The caller's clock was read before this walk began. What the walk
// observes, it observes later: the completeness claim below is dated
// by the caller's clock plus the time this walk has run, taken AFTER
@@ -154,18 +154,14 @@ func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment,
if rec == nil || rec.Declared == nil {
return nil, ErrNotDeclared
}
- // A grant over an unfinished teardown takes the store back. Whatever
- // that teardown had already destroyed is created again below, so its
- // record of having destroyed it must not outlive this. And the store is
- // not healthy whatever its record and delivered variables say: the
- // walk runs in full (rebuilt, at the short-circuit below).
+ // A grant over an unfinished teardown takes the store back. The record
+ // says a teardown began (TeardownBlock), so whatever its Provisioned
+ // block and delivered variables say, the user or the bucket may be
+ // gone: the walk runs in full (rebuilt, at the short-circuit below).
+ // The block is cleared only by the write that records the rebuilt
+ // store. A grant that is refused or fails part-way leaves it standing,
+ // so the record never reads as healthy in between.
rebuilt := rec.Teardown != nil
- if rebuilt {
- rec.Teardown = nil
- if etag, err = Put(ctx, s, acct, app, rec, etag); err != nil {
- return nil, err
- }
- }
// The key this walk started from, for the step-6 retry's guard below. ""
// is the ordinary case: a first grant, or a re-run of one that died before
// it recorded anything.
@@ -186,12 +182,51 @@ func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment,
if !ValidBucketName(bucket) {
return nil, fmt.Errorf("objstore: invalid bucket name %q", bucket)
}
- if rec.Grant == nil {
+ // One walk at a time on this bucket name, across every process
+ // (walklock.go). Everything below runs inside the lease and stops
+ // before it ends.
+ walk, err := acquireBucketWalk(ctx, s, bucket, acct, app, "grant")
+ if err != nil {
+ return nil, err
+ }
+ defer func() { walk.release(walkEndedClean(walkErr)) }()
+ ctx, cancel := walk.bound(ctx)
+ defer cancel()
+ // The record again, now that no other walk can be changing it.
+ if rec, etag, err = Get(ctx, s, acct, app); err != nil {
+ return nil, err
+ }
+ if rec == nil || rec.Declared == nil {
+ return nil, ErrNotDeclared
+ }
+ if held := rec.Grant; held != nil && held.Bucket != "" && held.Bucket != bucket {
+ return nil, fmt.Errorf("objstore: store record for %s/%s changed while its walk was starting — re-run: %w", acct, app, release.ErrCASConflict)
+ }
+ rebuilt = rec.Teardown != nil
+ startKeyID = ""
+ if rec.Provisioned != nil {
+ startKeyID = rec.Provisioned.KeyID
+ }
+ wroteGrant := rec.Grant == nil
+ if wroteGrant {
rec.Grant = &GrantBlock{Backend: d.Backend, Bucket: bucket, Source: source, At: now, By: actor, Minted: adoptBucket == ""}
if etag, err = Put(ctx, s, acct, app, rec, etag); err != nil {
return nil, err
}
}
+ // refused undoes the grant block this call wrote when the name turns
+ // out not to be this store's to take. A store whose grant was refused
+ // holds nothing, and its record must not say otherwise: another
+ // store's teardown reads these records to find who holds a bucket.
+ refused := func(cause error) error {
+ if wroteGrant {
+ rec.Grant = nil
+ if _, perr := Put(ctx, s, acct, app, rec, etag); perr != nil {
+ return errors.Join(cause, fmt.Errorf("objstore: undo the grant block of %s/%s: %w", acct, app, perr))
+ }
+ }
+ return cause
+ }
// 1b. bucket ownership history (ownership.go). Opened here, before the
// idempotence short-circuit and before any credential exists, so that
// no tenant can move a byte out of this bucket while the ledger names
@@ -226,8 +261,44 @@ func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment,
if rec.Grant.Minted && rec.Provisioned == nil {
prober, canProbe := p.(BucketProber)
inventory, canList := p.(GranteeProber)
+ exists, perr := false, error(nil)
+ if canProbe {
+ exists, perr = prober.BucketExists(ctx, bucket)
+ }
+ // A grant that mints its bucket and has never held the name must
+ // not be handed a bucket that is already there: it holds somebody's
+ // objects (a store that left it behind, or one the platform does
+ // not know). The ledger says whether this store held the name
+ // before; a re-run of its own crashed grant did, and goes on.
+ ledger, _, lerr := ReadBucketOwnership(ctx, s, bucket)
+ if lerr != nil {
+ return nil, fmt.Errorf("objstore: read ownership of %s: %w", bucket, lerr)
+ }
+ held := false
+ if ledger != nil {
+ for _, iv := range ledger.Intervals {
+ if iv.Account == acct && iv.App == app {
+ held = true
+ }
+ }
+ }
+ if !held {
+ if canProbe && perr == nil && exists {
+ return nil, refused(fmt.Errorf("%w: %s already exists and %s/%s has never held it", ErrBucketHeld, bucket, acct, app))
+ }
+ // And never while another store's RECORD names the bucket. A
+ // store provisioned before ledgers existed holds its bucket
+ // with no interval to show for it; the ledger's own check
+ // below cannot see it, and its record can.
+ holders, herr := otherStoreHolders(ctx, s, bucket, acct, app)
+ if herr != nil {
+ return nil, fmt.Errorf("objstore: list the stores holding %s: %w", bucket, herr)
+ }
+ if len(holders) > 0 {
+ return nil, refused(fmt.Errorf("%w: %s by %s/%s", ErrBucketHeld, bucket, holders[0].Account, holders[0].App))
+ }
+ }
if canProbe && canList {
- exists, perr := prober.BucketExists(ctx, bucket)
grantees, gerr := inventory.BucketGrantees(ctx, bucket)
minted = perr == nil && gerr == nil && !exists && len(grantees) == 0
observed = now.Add(grantElapsed(started))
@@ -238,7 +309,11 @@ func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment,
// grant is refused while another app's teardown is destroying the
// bucket. Both are decided by the ledger write itself.
if err := openBucketOwner(ctx, s, bucket, acct, app, grantOwnershipFrom(rec.Grant, now), observed, minted, rec.Grant.Minted); err != nil {
- return nil, fmt.Errorf("objstore: record ownership of %s: %w", bucket, err)
+ err = fmt.Errorf("objstore: record ownership of %s: %w", bucket, err)
+ if errors.Is(err, ErrBucketHeld) || errors.Is(err, ErrBucketBeingDestroyed) {
+ return nil, refused(err)
+ }
+ return nil, err
}
// 2. the principal and its policy. EnsureUser runs UNCONDITIONALLY, before
// the idempotence short-circuit below — the same fix internal/mail's walk
@@ -317,6 +392,7 @@ func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment,
}
rec.Provisioned = &Provisioned{Bucket: bucket, Region: region, Endpoint: d.Endpoint,
IAMUser: user, UserARN: arn, KeyID: id, PrevKeyID: prev, At: now}
+ rec.Teardown = nil // the store is whole again
if _, err := Put(ctx, s, acct, app, rec, etag); err != nil {
if !errors.Is(err, release.ErrCASConflict) {
return nil, err
@@ -351,6 +427,7 @@ func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment,
return nil, err
}
fresh.Provisioned = rec.Provisioned
+ fresh.Teardown = nil
if _, perr := Put(ctx, s, acct, app, fresh, freshEtag); perr != nil {
return nil, perr
}
@@ -525,8 +602,8 @@ func RotateFinish(ctx context.Context, s release.Store, p Provisioner, acct, app
// it as trivially deletable would erase the only record naming that estate,
// which is precisely the failure §9's ordering rule exists to prevent. Only a
// record with NO grant at all — declared, never acted on — dies trivially.
-func Teardown(ctx context.Context, s release.Store, p Provisioner, sc Scanner, acct, app string) error {
- rec, _, err := Get(ctx, s, acct, app)
+func Teardown(ctx context.Context, s release.Store, p Provisioner, sc Scanner, acct, app string) (walkErr error) {
+ rec, etag, err := Get(ctx, s, acct, app)
if err != nil {
return err
}
@@ -555,6 +632,53 @@ func Teardown(ctx context.Context, s release.Store, p Provisioner, sc Scanner, a
if p == nil {
return fmt.Errorf("objstore: %s/%s has backend estate and this deployment has no storage backend configured", acct, app)
}
+ // One walk at a time on this bucket name, across every process
+ // (walklock.go). The lease is this invocation's alone: a second
+ // teardown of the same app waits, and cannot inherit this one's
+ // authority or keep it after this one has finished.
+ leased := ""
+ switch {
+ case rec.Provisioned != nil:
+ leased = rec.Provisioned.Bucket
+ case rec.Grant != nil:
+ leased = rec.Grant.Bucket
+ }
+ if leased != "" && ValidBucketName(leased) {
+ walk, werr := acquireBucketWalk(ctx, s, leased, acct, app, "teardown")
+ if werr != nil {
+ return werr
+ }
+ defer func() { walk.release(walkEndedClean(walkErr)) }()
+ var cancel context.CancelFunc
+ ctx, cancel = walk.bound(ctx)
+ defer cancel()
+ // The record again, now that no other walk can be changing it.
+ if rec, etag, err = Get(ctx, s, acct, app); err != nil {
+ return err
+ }
+ if rec == nil {
+ return nil
+ }
+ startKeyID, startBucket = "", ""
+ if rec.Provisioned != nil {
+ startKeyID = rec.Provisioned.KeyID
+ }
+ if rec.Grant != nil {
+ startBucket = rec.Grant.Bucket
+ }
+ if rec.Grant == nil && rec.Provisioned == nil {
+ return deleteRecordIfUnchanged(ctx, s, acct, app, startKeyID, startBucket)
+ }
+ }
+ // Written ahead of everything else: from here on the record says a
+ // teardown has begun, whatever it gets through before it stops. A grant
+ // that finds this block does not take the Provisioned block at its word.
+ if rec.Teardown == nil {
+ rec.Teardown = &TeardownBlock{StartedAt: ownershipNow()}
+ if etag, err = Put(ctx, s, acct, app, rec, etag); err != nil {
+ return fmt.Errorf("objstore: record the teardown of %s/%s: %w", acct, app, err)
+ }
+ }
// Coordinates: the provisioned block when there is one, since the bucket
// an app's data is in is whatever was recorded at provision time. Falling
// back to the grant covers the crashed-grant shape above — and the user
@@ -591,7 +715,7 @@ func Teardown(ctx context.Context, s release.Store, p Provisioner, sc Scanner, a
//
// A teardown that already deleted its bucket says so on its own record
// (TeardownBlock) and never touches a bucket of that name again.
- destroy, scannerOwed := false, false
+ destroy, scannerOwed, foreign := false, false, false
bucketDone := rec.Teardown != nil && !rec.Teardown.BucketDeletedAt.IsZero()
var holder OwnerRef
if bucket != "" && ValidBucketName(bucket) && !bucketDone {
@@ -602,7 +726,7 @@ func Teardown(ctx context.Context, s release.Store, p Provisioner, sc Scanner, a
now := ownershipNow()
scanning := rec.Scanning != nil && rec.Scanning.Enabled != nil
var derr error
- destroy, scannerOwed, holder, derr = beginBucketTeardown(ctx, s, bucket, acct, app, grantOwnershipFrom(rec.Grant, now), holders, scanning, now)
+ destroy, scannerOwed, holder, foreign, derr = beginBucketTeardown(ctx, s, bucket, acct, app, grantOwnershipFrom(rec.Grant, now), holders, scanning, now)
if derr != nil {
return fmt.Errorf("objstore: decide the teardown of %s: %w", bucket, derr)
}
@@ -646,6 +770,11 @@ func Teardown(ctx context.Context, s release.Store, p Provisioner, sc Scanner, a
// the marker standing no grant can have landed, so a change here is
// an operator's hand on the ledger; the teardown stops and is re-run.
confirm := func(step string) error {
+ // The lease first: a walk whose time has run out does not
+ // start another destructive call, whatever the ledger says.
+ if err := ctx.Err(); err != nil {
+ return fmt.Errorf("objstore: the teardown of %s/%s ran out of its lease before %s; run it again: %w", acct, app, step, err)
+ }
if !ValidBucketName(bucket) {
return nil
}
@@ -674,16 +803,14 @@ func Teardown(ctx context.Context, s release.Store, p Provisioner, sc Scanner, a
return fmt.Errorf("objstore: delete bucket %s: %w", bucket, err)
}
// Recorded at once: a re-run after a failure below must not empty or
- // delete a bucket of this name again.
- fresh, fetag, gerr := Get(ctx, s, acct, app)
- if gerr != nil {
- return fmt.Errorf("objstore: re-read store record for %s/%s: %w", acct, app, gerr)
- }
- if fresh != nil {
- fresh.Teardown = &TeardownBlock{BucketDeletedAt: ownershipNow()}
- if _, perr := Put(ctx, s, acct, app, fresh, fetag); perr != nil {
- return fmt.Errorf("objstore: record the deleted bucket for %s/%s: %w", acct, app, perr)
- }
+ // delete a bucket of this name again. The mark is written against
+ // the record exactly as this teardown last wrote it (its etag), never
+ // against a fresh read: a record that has changed since describes
+ // some other estate, and saying ITS bucket was deleted would be
+ // false. If it has changed the write loses, and the walk stops.
+ rec.Teardown.BucketDeletedAt = ownershipNow()
+ if etag, err = Put(ctx, s, acct, app, rec, etag); err != nil {
+ return fmt.Errorf("objstore: record the deleted bucket for %s/%s: %w", acct, app, err)
}
}
// DeleteUser reaps the keys and the inline policy, so the credential dies
@@ -707,16 +834,47 @@ func Teardown(ctx context.Context, s release.Store, p Provisioner, sc Scanner, a
return fmt.Errorf("objstore: close ownership of %s: %w", bucket, err)
}
}
- // Left in place for an app of ANOTHER account: the bucket may still hold
- // this app's objects, and that account can read them. This app's access
+ // Left in place while an app of ANOTHER account can still reach it (it
+ // holds the bucket, or it is leaving and its credentials are not yet
+ // revoked): the bucket may still hold this app's objects, and that
+ // account can read them. This app's access
// is revoked and its interval closed, and the walk stops here with the
// record standing, so the teardown is not reported as done and a person
// decides what happens to the data. Run again once the other store is
// gone, it destroys the bucket and finishes. An app of the SAME account
// holding the bucket is the operator's own sharing, and the teardown
// finishes quietly.
- if !destroy && !bucketDone && holder.Account != "" && holder.Account != acct {
- return fmt.Errorf("%w: %s is held by %s/%s; %s/%s's access is revoked and its record kept", ErrBucketRetained, bucket, holder.Account, holder.App, acct, app)
+ // The decision was made before this app's credentials were revoked, and
+ // an operator may have adopted the bucket for another account since.
+ // The ledger is read once more, so the answer given here is about now.
+ //
+ // And one thing lifts the hold: the bucket this store left has since
+ // been destroyed by its last holder (DestroyedAt is later than this
+ // store's own leaving). Its objects went with it. Whoever holds a
+ // bucket of that name now was granted a new one, and this teardown has
+ // nothing left to wait for.
+ if !destroy && !bucketDone && bucket != "" && ValidBucketName(bucket) {
+ latest, _, lerr := ReadBucketOwnership(ctx, s, bucket)
+ if lerr != nil {
+ return fmt.Errorf("objstore: re-read ownership of %s: %w", bucket, lerr)
+ }
+ if latest != nil {
+ var left *time.Time
+ for _, iv := range latest.Intervals {
+ if iv.open() && iv.Account != acct {
+ foreign, holder = true, OwnerRef{Account: iv.Account, App: iv.App, Since: iv.From}
+ }
+ if iv.Account == acct && iv.App == app && iv.Until != nil && (left == nil || iv.Until.After(*left)) {
+ left = iv.Until
+ }
+ }
+ if d := latest.DestroyedAt; foreign && d != nil && left != nil && left.Before(*d) {
+ foreign = false
+ }
+ }
+ }
+ if !destroy && !bucketDone && foreign {
+ return fmt.Errorf("%w: %s is held by %s/%s, and a store of another account can still reach it; %s/%s's access is revoked and its record kept", ErrBucketRetained, bucket, holder.Account, holder.App, acct, app)
}
// The delivered set goes even though the credentials behind it are already
// dead: leaving them would hand the app's next render a bucket name and a
@@ -771,6 +929,16 @@ func Teardown(ctx context.Context, s release.Store, p Provisioner, sc Scanner, a
return deleteRecordIfUnchanged(ctx, s, acct, app, startKeyID, startBucket)
}
+// walkEndedClean reports whether a walk that returned err left nothing on
+// its way to the backend: it succeeded, or it was refused before it called
+// the backend, or it stopped on a decision (a retained bucket) after every
+// call it made had been answered. Any other error may be a call that timed
+// out here and can still run there, so the lease is not freed.
+func walkEndedClean(err error) bool {
+ return err == nil || errors.Is(err, ErrBucketRetained) || errors.Is(err, ErrBucketHeld) ||
+ errors.Is(err, ErrBucketBeingDestroyed) || errors.Is(err, ErrNotDeclared)
+}
+
// grantElapsed is how long a grant has been running. A seam, so a test can
// make a probe slow.
var grantElapsed = func(started time.Time) time.Duration { return time.Since(started) }
diff --git a/internal/objstore/walk_sim_test.go b/internal/objstore/walk_sim_test.go
new file mode 100644
index 000000000..a897db940
--- /dev/null
+++ b/internal/objstore/walk_sim_test.go
@@ -0,0 +1,366 @@
+package objstore
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "math/rand"
+ "os"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// The invariants of a bucket name shared by several stores, checked under
+// random interleavings of grants and teardowns with a failure injected at
+// a random provider step. The earlier tests each pin one interleaving a
+// reviewer found; this one states what must hold after EVERY step of ANY
+// interleaving, and what must be reachable at the end.
+//
+// Data. A store that was granted and has not been asked to go still
+// has its bucket and the object it wrote.
+// Marker. The ledger names an app as destroying the bucket only while
+// that app's teardown is unfinished.
+// Scanner. A scanner plan never outlives its bucket.
+// Gone. A store whose teardown finished has no user and no record.
+// Owner. The ledger resolves an hour to an app only if, at every
+// instant sampled in that hour, no other app had credentials.
+// Mint. A grant that mints its bucket is never handed a bucket that
+// holds the objects of another account's store that has gone.
+// Quiet. A teardown that reports success has not left its store's
+// objects where another account's credentials can read them.
+//
+// The walks are not only run one after another. With some probability a
+// second walk (another app's grant or teardown) runs in the MIDDLE of the
+// first, at the moment the first calls the provider to create, empty or
+// delete a bucket or a user: the interleaving of two console processes.
+//
+// Settles. With no more failures, re-running every unfinished teardown
+// reaches: no bucket, no user, no record, no scanner plan, no
+// open interval, no marker. Nothing is stuck for good.
+type simApp struct {
+ acct, app, adopt string
+ // "none": no store. "live": granted, owed its data. "broken": a grant
+ // or teardown stopped part-way. "retained": its teardown revoked its
+ // access and reported that the bucket was left to another account.
+ state string
+}
+
+func (a *simApp) name() string { return a.acct + "/" + a.app }
+
+// interleavingProvisioner runs between() at the start of each destructive
+// or creating provider call, before the call takes effect.
+type interleavingProvisioner struct {
+ *MemProvisioner
+ between func(method string)
+}
+
+func (p *interleavingProvisioner) EnsureBucket(ctx context.Context, name string) (string, error) {
+ p.between("EnsureBucket")
+ return p.MemProvisioner.EnsureBucket(ctx, name)
+}
+
+func (p *interleavingProvisioner) EmptyBucket(ctx context.Context, name string) error {
+ p.between("EmptyBucket")
+ return p.MemProvisioner.EmptyBucket(ctx, name)
+}
+
+func (p *interleavingProvisioner) DeleteBucket(ctx context.Context, name string) error {
+ p.between("DeleteBucket")
+ return p.MemProvisioner.DeleteBucket(ctx, name)
+}
+
+func (p *interleavingProvisioner) DeleteUser(ctx context.Context, user string) error {
+ p.between("DeleteUser")
+ return p.MemProvisioner.DeleteUser(ctx, user)
+}
+
+func TestBucketNameInvariantsUnderRandomWalks(t *testing.T) {
+ seeds := 400
+ if n, err := strconv.Atoi(os.Getenv("CARLOS_SIM_SEEDS")); err == nil && n > 0 {
+ seeds = n
+ }
+ if testing.Short() {
+ seeds = 60
+ }
+ for seed := 0; seed < seeds; seed++ {
+ if trace, problem := simulateBucketName(t, int64(seed), 40); problem != "" {
+ t.Fatalf("seed %d: %s\n%s", seed, problem, strings.Join(trace, "\n"))
+ }
+ }
+}
+
+func simulateBucketName(t *testing.T, seed int64, steps int) (trace []string, problem string) {
+ t.Helper()
+ ctx := context.Background()
+ rng := rand.New(rand.NewSource(seed))
+ s := release.NewDirStore(t.TempDir())
+ _, pub := testSealKeypair(t)
+ mem := NewMemProvisioner("eu-west-1")
+ ip := &interleavingProvisioner{MemProvisioner: mem, between: func(string) {}}
+ var p Provisioner = ip
+ sc := NewMemScanner()
+ apps := []*simApp{
+ {acct: "bab", app: "titogo", adopt: "", state: "none"}, // mints the name
+ {acct: "bac", app: "other", adopt: ledgerBucket, state: "none"}, // another account adopts it
+ {acct: "bab", app: "second", adopt: ledgerBucket, state: "none"}, // the same account adopts it
+ }
+ faults := []string{"EnsureBucket", "EnsureUser", "MintKey", "EmptyBucket", "DeleteBucket", "DeleteUser", "BucketExists", "BucketGrantees"}
+ logf := func(format string, args ...any) { trace = append(trace, fmt.Sprintf(format, args...)) }
+ object := func(a *simApp) string { return "obj-" + a.acct + "-" + a.app }
+
+ mintLeak := ""
+ grant := func(a *simApp, at time.Time) {
+ if rec, _, _ := Get(ctx, s, a.acct, a.app); rec == nil {
+ declare(t, s, a.acct, a.app, "S3")
+ }
+ was := a.state
+ _, err := Grant(ctx, s, p, testDeployment(), pub, a.acct, a.app, "op", GrantSourceOperator, a.adopt, at)
+ logf("grant %s: %v", a.name(), err)
+ if err != nil {
+ // A refused or failed grant does not un-report a retained
+ // bucket, and does not take a live store's data away.
+ if a.state != "live" && a.state != "retained" {
+ a.state = "broken"
+ }
+ return
+ }
+ a.state = "live"
+ // An app an operator adopted the name for while it is held (and
+ // which is live now) is sharing by the operator's choice. Objects
+ // of a store that is not holding the bucket are a leak.
+ if a.adopt == "" && was != "live" {
+ for key := range mem.Buckets[ledgerBucket] {
+ for _, o := range apps {
+ // A sibling app of the same account sharing the
+ // bucket is the operator's own arrangement.
+ if key == object(o) && o != a && o.acct != a.acct && o.state != "live" && o.state != "retained" {
+ mintLeak = fmt.Sprintf("Mint: %s minted its bucket and found %s in it, left by a store that no longer holds it", a.name(), key)
+ }
+ }
+ }
+ }
+ if b, ok := mem.Buckets[ledgerBucket]; ok {
+ b[object(a)] = []byte(a.name())
+ }
+ if rng.Intn(3) == 0 {
+ if err := sc.EnsureEnabled(ctx, ledgerBucket); err != nil {
+ t.Fatal(err)
+ }
+ enableScanning(t, s, a.acct, a.app)
+ logf(" scanning enabled by %s", a.name())
+ }
+ }
+ teardown := func(a *simApp) error {
+ rec, _, _ := Get(ctx, s, a.acct, a.app)
+ if rec == nil {
+ return nil
+ }
+ // Asked to go: from this instant the store is not owed its data.
+ a.state = "broken"
+ estate := rec.Grant != nil || rec.Provisioned != nil
+ err := Teardown(ctx, s, p, sc, a.acct, a.app)
+ logf("teardown %s: %v", a.name(), err)
+ if errors.Is(err, ErrBucketRetained) {
+ // Reported, not hidden: the bucket was left to another
+ // account and may hold this store's objects. A person decides.
+ a.state = "retained"
+ return err
+ }
+ if err != nil {
+ return err
+ }
+ a.state = "none"
+ // A teardown that reports success has not left this store's
+ // objects where another ACCOUNT can read them.
+ // (Only a teardown that had something to take apart: a later no-op
+ // on a bare declaration is not what left anything behind.)
+ if b, ok := mem.Buckets[ledgerBucket]; ok && estate && b[object(a)] != nil {
+ for _, o := range apps {
+ if _, has := mem.Users[UserName(o.acct, o.app)]; has && o.acct != a.acct {
+ mintLeak = fmt.Sprintf("Quiet: %s's teardown reported success and left its object readable by %s", a.name(), o.name())
+ }
+ }
+ }
+ return nil
+ }
+ check := func() string {
+ if mintLeak != "" {
+ return mintLeak
+ }
+ bucket, exists := mem.Buckets[ledgerBucket]
+ for _, a := range apps {
+ switch a.state {
+ case "live":
+ if !exists {
+ return fmt.Sprintf("Data: %s is live and its bucket is gone", a.name())
+ }
+ if string(bucket[object(a)]) != a.name() {
+ return fmt.Sprintf("Data: %s is live and its object is gone", a.name())
+ }
+ case "none":
+ if _, ok := mem.Users[UserName(a.acct, a.app)]; ok {
+ return fmt.Sprintf("Gone: %s is torn down and still has a user", a.name())
+ }
+ if rec, _, _ := Get(ctx, s, a.acct, a.app); rec != nil && (rec.Grant != nil || rec.Provisioned != nil) {
+ return fmt.Sprintf("Gone: %s is torn down and still has a granted record", a.name())
+ }
+ }
+ }
+ if !exists && sc.IsEnabled(ledgerBucket) {
+ return "Scanner: the plan outlived the bucket"
+ }
+ ledger, _, err := ReadBucketOwnership(ctx, s, ledgerBucket)
+ if err != nil {
+ return "ledger unreadable: " + err.Error()
+ }
+ if ledger != nil && ledger.Destroying != nil {
+ for _, a := range apps {
+ if a.acct == ledger.Destroying.Account && a.app == ledger.Destroying.App && a.state != "broken" && a.state != "retained" {
+ return fmt.Sprintf("Marker: the ledger says %s is destroying the bucket and its state is %s", a.name(), a.state)
+ }
+ }
+ }
+ return ""
+ }
+
+ at := t0
+ prevNow := ownershipNow
+ ownershipNow = func() time.Time { return at }
+ defer func() { ownershipNow = prevNow }()
+ // The walk leases run on the same simulated clock. Each step is an
+ // hour after the last, so a walk that failed has run out of its lease
+ // by the next step; a walk that runs in the middle of another meets
+ // the other's lease, as two processes would.
+ prevWalk := walkClock
+ walkClock = func() time.Time { return at }
+ defer func() { walkClock = prevWalk }()
+ // Who had credentials at each sampled instant.
+ type sample struct {
+ at time.Time
+ users map[string]bool
+ }
+ var samples []sample
+ snapshot := func() {
+ users := map[string]bool{}
+ for _, a := range apps {
+ if _, ok := mem.Users[UserName(a.acct, a.app)]; ok {
+ users[a.name()] = true
+ }
+ }
+ samples = append(samples, sample{at, users})
+ }
+ owner := func() string {
+ ledger, _, err := ReadBucketOwnership(ctx, s, ledgerBucket)
+ if err != nil || ledger == nil {
+ return ""
+ }
+ for _, sm := range samples {
+ acct, app, ok := ledger.OwnerAt(sm.at)
+ if !ok {
+ continue
+ }
+ for name := range sm.users {
+ if name != acct+"/"+app {
+ return fmt.Sprintf("Owner: the hour of %s resolves to %s/%s while %s had credentials", sm.at.Format("01-02 15:04"), acct, app, name)
+ }
+ }
+ }
+ return ""
+ }
+ for step := 0; step < steps; step++ {
+ // Two samples an hour: one just after the step, one mid-hour.
+ at = at.Add(30 * time.Minute)
+ snapshot()
+ at = at.Add(30 * time.Minute)
+ a := apps[rng.Intn(len(apps))]
+ mem.FailNext = nil
+ if rng.Intn(3) == 0 {
+ f := faults[rng.Intn(len(faults))]
+ mem.FailNext = map[string]error{f: errors.New("injected " + f)}
+ logf(" next %s fails", f)
+ }
+ // Sometimes another app's walk runs in the middle of this one, at
+ // one of its provider calls. Only one level deep, and the data
+ // invariant is checked the moment the inner walk returns: a store
+ // granted inside another's teardown is owed its data from then on.
+ inner := false
+ midProblem := ""
+ ip.between = func(method string) {
+ if inner || rng.Intn(4) != 0 {
+ return
+ }
+ inner = true
+ b := apps[rng.Intn(len(apps))]
+ if b == a {
+ inner = false
+ return
+ }
+ logf(" -- during %s of %s:", method, a.name())
+ if rng.Intn(2) == 0 {
+ grant(b, at)
+ } else {
+ _ = teardown(b)
+ }
+ logf(" -- back to %s", a.name())
+ inner = false
+ }
+ if rng.Intn(2) == 0 {
+ grant(a, at)
+ } else {
+ _ = teardown(a)
+ }
+ ip.between = func(string) {}
+ if midProblem != "" {
+ return trace, midProblem
+ }
+ snapshot()
+ if problem := check(); problem != "" {
+ return trace, problem
+ }
+ if problem := owner(); problem != "" {
+ return trace, problem
+ }
+ }
+ // No more failures. Everything unfinished is re-run until nothing moves.
+ mem.FailNext = nil
+ ip.between = func(string) {}
+ logf("-- settle --")
+ for round := 0; round < 8; round++ {
+ for _, a := range apps {
+ at = at.Add(time.Hour)
+ _ = teardown(a)
+ }
+ if problem := check(); problem != "" {
+ return trace, "while settling: " + problem
+ }
+ }
+ for _, a := range apps {
+ if rec, _, _ := Get(ctx, s, a.acct, a.app); rec != nil && (rec.Grant != nil || rec.Provisioned != nil) {
+ return trace, fmt.Sprintf("Settles: %s still has a granted record", a.name())
+ }
+ if _, ok := mem.Users[UserName(a.acct, a.app)]; ok {
+ return trace, fmt.Sprintf("Settles: %s still has a user", a.name())
+ }
+ }
+ if _, exists := mem.Buckets[ledgerBucket]; exists {
+ return trace, "Settles: the bucket is still there with nobody holding it"
+ }
+ if sc.IsEnabled(ledgerBucket) {
+ return trace, "Settles: the scanner plan is still there"
+ }
+ if ledger, _, _ := ReadBucketOwnership(ctx, s, ledgerBucket); ledger != nil {
+ if ledger.Destroying != nil {
+ return trace, fmt.Sprintf("Settles: the ledger still says %s/%s is destroying the bucket", ledger.Destroying.Account, ledger.Destroying.App)
+ }
+ for _, iv := range ledger.Intervals {
+ if iv.Until == nil {
+ return trace, fmt.Sprintf("Settles: an interval is still open for %s/%s", iv.Account, iv.App)
+ }
+ }
+ }
+ return trace, ""
+}
diff --git a/internal/objstore/walklock.go b/internal/objstore/walklock.go
new file mode 100644
index 000000000..0eea6e2fe
--- /dev/null
+++ b/internal/objstore/walklock.go
@@ -0,0 +1,209 @@
+package objstore
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// The walk lease: one walk at a time on a bucket name, across every
+// console process, with authority bound to the invocation that holds it.
+//
+// Why it exists. A grant and a teardown each make several provider calls,
+// and what they are entitled to do is decided from the ownership ledger.
+// The console's own lock is per process. Two processes could therefore run
+// two walks on one bucket at once, and every protection written into the
+// ledger named an APP, not an invocation: a second teardown of the same
+// app inherited the first one's authority to destroy, and kept it after
+// the first had finished and the name had been granted to someone else.
+// Each review round found another interleaving of that one fault.
+//
+// The invariant. No provider call of a walk executes outside that walk's
+// lease, and leases on one bucket name do not overlap. It is kept by two
+// things together:
+//
+// - the lease is one object, taken and extended by compare-and-swap, so
+// two invocations cannot both hold it;
+// - every call the walk makes carries a context deadline that falls
+// before the lease ends. A process that is paused past its lease wakes
+// to a deadline that has passed, and its next call does not start.
+//
+// The deadline only stops a process that is running. A process paused
+// between its last check and the request leaving it is stopped by the
+// backend, not by this: see fence.go. The lease is as long as the
+// backend's window for a request stamped at the lease's start, plus an
+// allowance for clock skew, so a lease cannot be taken over while any
+// request of the walk that held it could still be accepted.
+//
+// A walk that ends with an error may have a request still on its way (a
+// call that timed out here can run there). Its lease is therefore not
+// freed: it is marked ended, and until it runs out only the same app
+// running the same kind of walk again may take it. A walk that ends
+// cleanly had every call answered, and frees the name at once, unless an
+// earlier attempt it took over from had not run out yet.
+const (
+ // walkLease is how long a bucket name stays reserved for one walk.
+ walkLease = fenceWindow + fenceSkew
+ // walkMargin is how long before the backend's window closes the walk
+ // stops calling the provider. Emptying a large bucket is paginated and
+ // resumable: a walk that runs out of time stops, and running it again
+ // takes a new lease and carries on.
+ walkMargin = 30 * time.Second
+)
+
+// ErrBucketBusy: another walk holds the bucket's lease.
+var ErrBucketBusy = errors.New("objstore: another walk is in progress on this bucket")
+
+// walkClock is the wall clock leases are measured on. A seam for tests.
+var walkClock = time.Now
+
+// walkLockKey is the lease object for a bucket name. It is deliberately
+// not under the ownership ledger's prefix: it is coordination, not
+// evidence, and nothing that reads ledgers should ever meet it.
+func walkLockKey(bucket string) string { return "objstore/walks/" + bucket + ".json" }
+
+type walkLock struct {
+ V int `json:"v"`
+ Bucket string `json:"bucket"`
+ Account string `json:"account_sqid"`
+ App string `json:"app"`
+ Kind string `json:"kind"` // "grant" or "teardown"
+ Token string `json:"token"`
+ // SignedAt is the instant the lease was taken: the stamp every request
+ // of the walk carries, and what the backend's window is measured from.
+ SignedAt time.Time `json:"signed_at"`
+ // Until is when the name stops being reserved.
+ Until time.Time `json:"until"`
+ // Ended: the walk has returned, with an error. The reservation stands
+ // until Until, for anyone but the same app re-running the same kind.
+ Ended bool `json:"ended,omitempty"`
+ // Carry is the latest Until of the ended attempts this lease took over
+ // from: the name is not freed before it.
+ Carry time.Time `json:"carry,omitzero"`
+}
+
+// bucketWalk is a held lease.
+type bucketWalk struct {
+ s release.Store
+ key string
+ lock walkLock
+ etag string
+ closed bool
+}
+
+// acquireBucketWalk takes the bucket's lease for one invocation, or
+// refuses with ErrBucketBusy naming who holds it. A lease whose time has
+// passed is taken over; one that was released is free.
+func acquireBucketWalk(ctx context.Context, s release.Store, bucket, acct, app, kind string) (*bucketWalk, error) {
+ var raw [12]byte
+ if _, err := rand.Read(raw[:]); err != nil {
+ return nil, err
+ }
+ key := walkLockKey(bucket)
+ for attempt := 0; attempt < 3; attempt++ {
+ now := walkClock()
+ var carry time.Time
+ prev, etag, err := s.Get(ctx, key)
+ switch {
+ case errors.Is(err, os.ErrNotExist):
+ etag = ""
+ case err != nil:
+ return nil, fmt.Errorf("objstore: read the walk lease of %s: %w", bucket, err)
+ default:
+ var held walkLock
+ if jerr := json.Unmarshal(prev, &held); jerr != nil {
+ return nil, fmt.Errorf("objstore: walk lease of %s: %w", bucket, jerr)
+ }
+ if held.Until.After(now) {
+ retry := held.Ended && held.Account == acct && held.App == app && held.Kind == kind
+ if !retry {
+ return nil, fmt.Errorf("%w: %s, by a %s of %s/%s until %s", ErrBucketBusy, bucket, held.Kind, held.Account, held.App, held.Until.UTC().Format(time.RFC3339))
+ }
+ carry = held.Until
+ if held.Carry.After(carry) {
+ carry = held.Carry
+ }
+ }
+ }
+ lock := walkLock{V: 1, Bucket: bucket, Account: acct, App: app, Kind: kind, Token: hex.EncodeToString(raw[:]), SignedAt: now.UTC(), Until: now.Add(walkLease).UTC(), Carry: carry}
+ body, err := json.Marshal(lock)
+ if err != nil {
+ return nil, err
+ }
+ newEtag, err := s.Put(ctx, key, body, etag)
+ if err == nil {
+ return &bucketWalk{s: s, key: key, lock: lock, etag: newEtag}, nil
+ }
+ if !errors.Is(err, release.ErrCASConflict) {
+ return nil, fmt.Errorf("objstore: take the walk lease of %s: %w", bucket, err)
+ }
+ }
+ return nil, fmt.Errorf("%w: %s (lost the race for the lease three times)", ErrBucketBusy, bucket)
+}
+
+// bound returns ctx carrying the walk's fence and cut off before the
+// backend's window for it closes. Every call the walk makes goes through
+// it.
+func (w *bucketWalk) bound(ctx context.Context) (context.Context, context.CancelFunc) {
+ // The time left is measured on the lease's clock and counted down on
+ // the context's own.
+ left := w.lock.SignedAt.Add(fenceWindow - walkMargin).Sub(walkClock())
+ return context.WithTimeout(withFence(ctx, w.lock.SignedAt), left)
+}
+
+// held reports whether the lease is still this invocation's: the object
+// carries its token and its time has not passed.
+func (w *bucketWalk) held(ctx context.Context) (bool, error) {
+ if !walkClock().Before(w.lock.SignedAt.Add(fenceWindow - walkMargin)) {
+ return false, nil
+ }
+ raw, _, err := w.s.Get(ctx, w.key)
+ if err != nil {
+ return false, err
+ }
+ var cur walkLock
+ if err := json.Unmarshal(raw, &cur); err != nil {
+ return false, err
+ }
+ return cur.Token == w.lock.Token, nil
+}
+
+// release gives the lease up. clean says the walk returned without error:
+// every call it made was answered, so nothing of it can still be on its
+// way, and the name is freed (unless an attempt it took over from has not
+// run out). Otherwise the lease is marked ended and stands until it runs
+// out.
+//
+// It writes against the etag this invocation last wrote, so it can only
+// ever release its own lease: if another invocation has taken over, the
+// write loses and changes nothing.
+func (w *bucketWalk) release(clean bool) {
+ if w == nil || w.closed {
+ return
+ }
+ w.closed = true
+ done := w.lock
+ done.Ended = true
+ if clean {
+ done.Until = time.Time{}
+ if done.Carry.After(walkClock()) {
+ done.Until = done.Carry
+ }
+ } else if done.Carry.After(done.Until) {
+ done.Until = done.Carry
+ }
+ body, err := json.Marshal(done)
+ if err != nil {
+ return
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ _, _ = w.s.Put(ctx, w.key, body, w.etag)
+}
diff --git a/internal/ops/litestream.go b/internal/ops/litestream.go
index c75eeb0c5..21396ef01 100644
--- a/internal/ops/litestream.go
+++ b/internal/ops/litestream.go
@@ -22,12 +22,10 @@ package ops
import (
"bytes"
- "errors"
"fmt"
"os"
"os/exec"
"path/filepath"
- "regexp"
"sort"
"strconv"
"strings"
@@ -186,60 +184,6 @@ func ParseLitestreamDBs(cfg string) []string {
return dbs
}
-// ErrConfigNotGenerated means a config is not, line for line, what
-// LitestreamConfig writes.
-var ErrConfigNotGenerated = errors.New("litestream config is not the generated shape")
-
-var generatedConfigLines = []*regexp.Regexp{
- regexp.MustCompile(`^#.*$`),
- regexp.MustCompile(`^snapshot:$`),
- regexp.MustCompile(`^ interval: \S+$`),
- regexp.MustCompile(`^ retention: \S+$`),
- regexp.MustCompile(`^dbs:$`),
- regexp.MustCompile(`^ replicas:$`),
- regexp.MustCompile(`^ - url: s3://\S+$`),
- regexp.MustCompile(`^ region: \S+$`),
-}
-
-var generatedConfigPath = regexp.MustCompile(`^ - path: (/\S+)$`)
-
-// GeneratedLitestreamDBs is ParseLitestreamDBs for a caller that must know
-// it has seen EVERY database the config names, not only the ones written
-// the way the generator writes them. It accounts for every line of the
-// file: each must be one LitestreamConfig produces, and a file with any
-// other line (a hand-kept config, a second replica, a directory entry, an
-// include, a tab) is refused with ErrConfigNotGenerated. It is not a YAML
-// parser and does not try to be; a config it cannot fully account for is
-// one it will not speak for.
-func GeneratedLitestreamDBs(cfg string) ([]string, error) {
- if !strings.HasPrefix(cfg, "# GENERATED by `carlos ops litestream`") {
- return nil, ErrConfigNotGenerated
- }
- var dbs []string
- seen := map[string]bool{}
- for n, line := range strings.Split(strings.TrimSuffix(cfg, "\n"), "\n") {
- if m := generatedConfigPath.FindStringSubmatch(line); m != nil {
- if seen[m[1]] {
- return nil, fmt.Errorf("%w: line %d names %s twice", ErrConfigNotGenerated, n+1, m[1])
- }
- seen[m[1]] = true
- dbs = append(dbs, m[1])
- continue
- }
- known := false
- for _, re := range generatedConfigLines {
- if re.MatchString(line) {
- known = true
- break
- }
- }
- if !known {
- return nil, fmt.Errorf("%w: line %d", ErrConfigNotGenerated, n+1)
- }
- }
- return dbs, nil
-}
-
// CoverageShortfall names every database the EXISTING config replicates that
// the proposed set does not — the databases a write would stop replicating.
//
@@ -369,10 +313,6 @@ func treeDBs(root string) []string {
// layout <data>/console/console.db — expressed against dataDir rather than
// hardcoded to /data so a test box's console is found under its own root and
// never the real one.
-// ConsoleDBPath is where this box's console keeps its database, as the
-// config generator resolves it.
-func ConsoleDBPath(dataDir string) string { return consoleDBPath(dataDir) }
-
func consoleDBPath(dataDir string) string {
if p := strings.TrimSpace(os.Getenv("CONSOLE_DB")); p != "" {
return filepath.Clean(p)
diff --git a/internal/ops/litestream_generated_test.go b/internal/ops/litestream_generated_test.go
deleted file mode 100644
index 5ff612dda..000000000
--- a/internal/ops/litestream_generated_test.go
+++ /dev/null
@@ -1,52 +0,0 @@
-package ops
-
-import (
- "errors"
- "reflect"
- "strings"
- "testing"
-)
-
-// The strict reader accounts for every line the generator writes, with and
-// without a region, and returns exactly the generator's databases.
-func TestGeneratedLitestreamDBsRoundTrip(t *testing.T) {
- paths := []string{"/data/registry.db", "/data/console/console.db", "/var/lib/carlos-app/kass.example.com/instances/a.db"}
- for _, region := range []string{"eu-west-1", ""} {
- cfg := LitestreamConfig(paths, "bucket", "prefix", region)
- got, err := GeneratedLitestreamDBs(cfg)
- if err != nil {
- t.Fatalf("region %q: %v\n%s", region, err, cfg)
- }
- want := ParseLitestreamDBs(cfg)
- if !reflect.DeepEqual(got, want) || len(got) != len(paths) {
- t.Fatalf("region %q: got %v, the loose parser reads %v", region, got, want)
- }
- }
- if got, err := GeneratedLitestreamDBs(LitestreamConfig(nil, "bucket", "prefix", "eu-west-1")); err != nil || len(got) != 0 {
- t.Fatalf("an empty generated config: %v %v", got, err)
- }
-}
-
-// Anything the generator does not write is refused: the loose parser would
-// read such a file and silently miss what it does not recognise.
-func TestGeneratedLitestreamDBsRefusesAnythingElse(t *testing.T) {
- base := LitestreamConfig([]string{"/data/registry.db"}, "bucket", "prefix", "eu-west-1")
- cases := map[string]string{
- "a hand-kept config with no marker": strings.SplitN(base, "\n", 2)[1],
- "a database written in flow style": base + " - {path: /data/hidden.db}\n",
- "a database indented differently": base + " - path: /data/hidden.db\n",
- "a directory entry": base + " - dir: /var/lib/tenants\n",
- "a second replica": base + " - type: sftp\n",
- "a relative path": base + " - path: data/relative.db\n",
- "a path with a trailing comment": base + " - path: /data/x.db # and more\n",
- "the same database twice": base + " - path: /data/registry.db\n",
- "a tab": strings.Replace(base, " - path:", "\t- path:", 1),
- "a key the generator never writes": base + "addr: :9090\n",
- "an empty file": "",
- }
- for name, cfg := range cases {
- if got, err := GeneratedLitestreamDBs(cfg); !errors.Is(err, ErrConfigNotGenerated) {
- t.Errorf("%s: got %v %v, want ErrConfigNotGenerated", name, got, err)
- }
- }
-}