carlos / platform Public

moves: Task 2a review fixes: the frozen plan 3 parser holds its own literals and pins the host-key move it reads as a box, a refused bootstrap or normalisation names its cause, and the ownership test fails instead of hanging

DirStore.PutOnce moves below PutFrom so each keeps its doc. The census
overlay re-pins the moves, release and lease (Task 3's 74526ffc)
envelopes; the note records that the plan's table writers reach the
record-write census only through sendTableWrite, and asks Paul for the
PutOnce census-method rule.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 3c6eb6ac36a243191efbcf7b8b1f91af3b9f9a99 parent 74526ff
7 files changed, +119 −31
  • docs/superpowers/notes/census-proposal-self-serve-moves-4.md +8 −0
  • internal/moves/offline_compat_test.go +38 −10
  • internal/moves/reserve.go +28 −10
  • internal/moves/reserve_test.go +25 −0
  • internal/moves/tablewrite_test.go +11 −3
  • internal/provision/testdata/plan14-root-reads-self-serve-moves-4.tsv +4 −3
  • internal/release/store.go +5 −5
diff --git a/docs/superpowers/notes/census-proposal-self-serve-moves-4.md b/docs/superpowers/notes/census-proposal-self-serve-moves-4.md
index 3bd16765d..f57c6506b 100644
--- a/docs/superpowers/notes/census-proposal-self-serve-moves-4.md
+++ b/docs/superpowers/notes/census-proposal-self-serve-moves-4.md
@@ -54,6 +54,10 @@ The record-write census changes in two ways:
`S3Store.Put` keeps its reviewed digest. `S3Store.PutOnce` restates its body rather than sharing a helper that would move it.
+**A deviation from the plan, recorded for review.** The plan (Global Constraints, "Census") lists the table's writers as added record-write units: `moves.PutTable` and the resends of `moves.TableWrites`, `moves.ReadActive`'s bootstrap, `moves.ReadForAcquire`'s normalisation write and `moves.Release`. The record-write census does not find units by what they write. It finds them by the calls they make that are named in `censusMethods`, or by the record-key names they mention. None of those functions calls a census method itself: each writes through `PutTable`, and `PutTable` and `Resend` send through `sendTableWrite`. So `moves.sendTableWrite` is the only table unit the census finds, and the only one pinned, as nonrecord. The others carry no census kind, and the census has no row to take for them. The write audit `TestEveryTableWriteGoesThroughPutTable` is what holds every one of them to the table key: no call outside `sendTableWrite` may write `control/offline-moves/active.json`.
+
+**A ruling asked of Paul.** Does the record-write census take `PutOnce` as a write method (the `censusMethods` entry added here)? If yes, the rows above stand. If no, the entry comes out, the six `PutOnce` units leave the inventory, and the single-attempt primitive is outside the census, which would then see no record written through it.
+
Task 2a's rejection ruling re-pins `S3Store.PutOnce` in the record-write inventory. It now answers a non-CAS 4xx as `release.ErrPutRejected`, and adds no root read.
The console's `Server.reserveBoxes` and `Server.releaseBoxes` stay nonrecord `sel:Put` rows until Task 2b rewrites them over `moves.PutTable`. That task removes or re-pins those rows.
@@ -78,3 +82,7 @@ Three new units are frozen as accepted legacy:
- `lease/leasetest/hold.go:Holder.ServeHTTP`: a test double that reads each request body. Its one non-test importer is host's end-to-end bridge, which only the e2e witnesses call.
Envelopes added or re-pinned: `internal/activator`, `internal/host`, `internal/lease`, `internal/lease/leasetest`, `internal/moves` and `internal/release` (the last two for Task 2a's rejection ruling). The seal moves to `840bb92c…`.
+
+## Task 2a's review fix round, and Task 3's fix round 1
+
+No frozen unit moves, and the seal is unchanged. Three envelopes are re-pinned in place: `internal/moves` and `internal/release` (`DirStore.PutOnce` moved below `PutFrom`; a refused bootstrap or normalisation names its cause) and `internal/lease` (Task 3's `74526ffc`).
diff --git a/internal/moves/offline_compat_test.go b/internal/moves/offline_compat_test.go
index 5b5afda96..e5ff3eabd 100644
--- a/internal/moves/offline_compat_test.go
+++ b/internal/moves/offline_compat_test.go
@@ -35,34 +35,40 @@ type plan3OfflineReservation struct {
// plan3Valid and parsePlan3OfflineActive are a verbatim copy, frozen, of
// OfflineReservation.valid() and ParseOfflineActive as built at 0840937f
// (internal/moves/offline.go:584-587 and :599-622), over the plan 3 types
-// above. They are the witness of what a console from before plan 4 reads:
+// above, with every constant they named inlined as its value at 0840937f
+// (errPlan3Malformed for ErrOfflineActiveMalformed), so no later change to
+// the live package can move them. They are the witness of what a console
+// from before plan 4 reads:
// a create or place entry is a malformed table to it, which is why no such
// console may run once a plan 4 console has written the table (Global
// Constraint "No console rollback across the plan 4 boundary"). Never edit
// them to follow the live parser.
+// errPlan3Malformed is ErrOfflineActiveMalformed as 0840937f declared it.
+var errPlan3Malformed = errors.New("moves: active.json is malformed")
+
func (r plan3OfflineReservation) plan3Valid() bool {
- return (r.Kind == OfflineKindMove || r.Kind == OfflineKindCleanup) && r.ID != "" && r.Host != "" && !r.ReservedAt.IsZero()
+ return (r.Kind == "move" || r.Kind == "cleanup") && r.ID != "" && r.Host != "" && !r.ReservedAt.IsZero()
}
func parsePlan3OfflineActive(raw []byte) (map[string]plan3OfflineReservation, error) {
var a plan3OfflineActive
if err := json.Unmarshal(raw, &a); err != nil {
- return nil, fmt.Errorf("%w: %v", ErrOfflineActiveMalformed, err)
+ return nil, fmt.Errorf("%w: %v", errPlan3Malformed, err)
}
if a.V == 0 && a.Reservations == nil {
- e := plan3OfflineReservation{Kind: OfflineKindMove, ID: a.Op, Host: a.Host, Account: a.Account, App: a.App, ReservedAt: a.At}
+ e := plan3OfflineReservation{Kind: "move", ID: a.Op, Host: a.Host, Account: a.Account, App: a.App, ReservedAt: a.At}
if !e.plan3Valid() {
- return nil, fmt.Errorf("%w: a legacy lock without a host, an op or a time", ErrOfflineActiveMalformed)
+ return nil, fmt.Errorf("%w: a legacy lock without a host, an op or a time", errPlan3Malformed)
}
- return map[string]plan3OfflineReservation{OfflineSentinel: e}, nil
+ return map[string]plan3OfflineReservation{"*": e}, nil
}
- if a.V != OfflineActiveVersion {
- return nil, fmt.Errorf("%w: version %d", ErrOfflineActiveMalformed, a.V)
+ if a.V != 1 {
+ return nil, fmt.Errorf("%w: version %d", errPlan3Malformed, a.V)
}
table := make(map[string]plan3OfflineReservation, len(a.Reservations))
for k, e := range a.Reservations {
if k == "" || !e.plan3Valid() {
- return nil, fmt.Errorf("%w: entry %q", ErrOfflineActiveMalformed, k)
+ return nil, fmt.Errorf("%w: entry %q", errPlan3Malformed, k)
}
table[k] = e
}
@@ -153,6 +159,28 @@ func TestNewParserReadsThePlan3Table(t *testing.T) {
}
}
+ // A move's host key, which every plan 4 move selection holds: the plan 3
+ // parser accepts it, as a box id named "host/<h>". So a console before
+ // plan 4 neither refuses the table nor knows the key holds a hostname:
+ // it would grant a create or placement of that host nothing to meet,
+ // which is why the no-rollback rule is absolute, not limited to tables
+ // holding a create or a place.
+ hostMove := map[string]OfflineReservation{
+ "i-0a": {Kind: OfflineKindMove, ID: "mv_a", Host: "a.example.com", Account: "acct", App: "shop", ReservedAt: compatAt},
+ HostKey("a.example.com"): {Kind: OfflineKindMove, ID: "mv_a", Host: "a.example.com", Account: "acct", App: "shop", ReservedAt: compatAt},
+ }
+ raw, err := json.Marshal(NextActiveBody(OfflineActive{V: 1, Epoch: "00112233445566778899aabbccddeeff", Rev: 7}, hostMove))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, err := ParseOfflineActive(raw); err != nil || len(got) != 2 || got[HostKey("a.example.com")].ID != "mv_a" {
+ t.Fatalf("the new parser read the host-key move as %+v %v", got, err)
+ }
+ old, err := parsePlan3OfflineActive(raw)
+ if err != nil || len(old) != 2 || old["host/a.example.com"].Kind != "move" || old["i-0a"].ID != "mv_a" {
+ t.Fatalf("the plan 3 parser read the host-key move as %+v %v, want it read as one more box key", old, err)
+ }
+
for _, kind := range []string{OfflineKindCreate, OfflineKindPlace} {
table := map[string]OfflineReservation{
"i-0a": {Kind: OfflineKindMove, ID: "mv_a", Host: "a.example.com", Account: "acct", App: "shop", ReservedAt: compatAt},
@@ -166,7 +194,7 @@ func TestNewParserReadsThePlan3Table(t *testing.T) {
if err != nil || len(got) != 2 || got[HostKey("n.example.com")].Kind != kind {
t.Fatalf("%s: the new parser read %+v %v", kind, got, err)
}
- if old, err := parsePlan3OfflineActive(raw); !errors.Is(err, ErrOfflineActiveMalformed) || !strings.Contains(err.Error(), "host/n.example.com") {
+ if old, err := parsePlan3OfflineActive(raw); !errors.Is(err, errPlan3Malformed) || !strings.Contains(err.Error(), "host/n.example.com") {
t.Fatalf("%s: the plan 3 parser read %+v %v, want the table malformed", kind, old, err)
}
}
diff --git a/internal/moves/reserve.go b/internal/moves/reserve.go
index 2dc5a54a2..bced6fa48 100644
--- a/internal/moves/reserve.go
+++ b/internal/moves/reserve.go
@@ -82,7 +82,8 @@ func PeekActive(ctx context.Context, s release.Store) (Active, error) {
// ReadActive reads the table, creating it first when it is absent: one
// create-only PutTable of {v: 1, epoch, rev: 1}, then a second read whatever
// that write answered (a lost create means another writer created it; an
-// uncertain one can only ever create an empty table). A store without
+// uncertain one can only ever create an empty table); a read error after a
+// failed bootstrap names the write's failure too. A store without
// PutOnce answers release.ErrNoSingleAttemptPut for an absent table, with
// nothing sent. A read error or a malformed body is ErrUnreadable. Only an
// armed writer reaches this on a box; every other read is PeekActive.
@@ -95,14 +96,26 @@ func ReadActive(ctx context.Context, s release.Store, tw *TableWrites) (Active,
if err != nil {
return Active{}, err
}
- if _, err := PutTable(ctx, s, tw, raw, ""); errors.Is(err, release.ErrNoSingleAttemptPut) {
- return Active{}, err
+ _, werr := PutTable(ctx, s, tw, raw, "")
+ if errors.Is(werr, release.ErrNoSingleAttemptPut) {
+ return Active{}, werr
}
a, found, err = getActive(ctx, s)
if err == nil && !found {
err = fmt.Errorf("%w: absent after its creation", ErrUnreadable)
}
- return a, err
+ return a, withWriteCause(err, werr)
+}
+
+// withWriteCause names a table write's own failure (a rejection, an
+// uncertainty) inside the read error that followed it, so a refused
+// bootstrap or normalisation says why. A lost condition is no cause: it
+// means another writer moved the table, which the read already shows.
+func withWriteCause(err, werr error) error {
+ if err == nil || werr == nil || errors.Is(werr, release.ErrCASConflict) {
+ return err
+ }
+ return fmt.Errorf("%w (after the write: %w)", err, werr)
}
// NextActiveBody is the body a write against prev puts: table's body
@@ -127,22 +140,27 @@ func normalised(b OfflineActive) bool { return b.Epoch != "" && b.Rev != 0 }
// normalised by one PutTable, If-Match the read ETag, of NextActiveBody (the
// same entries, a fresh epoch, rev 1), whatever it answers, then read
// again; at most three rounds. So only an epoch-bearing ETag is ever an
-// acquisition's condition. A body still without both is ErrUnreadable.
+// acquisition's condition. A body still without both is ErrUnreadable,
+// naming the last write's own failure when it had one (a rejection).
func ReadForAcquire(ctx context.Context, s release.Store, tw *TableWrites) (Active, error) {
+ var werr error // the last normalisation write's own error
for round := 0; ; round++ {
a, err := ReadActive(ctx, s, tw)
- if err != nil || normalised(a.Body) {
- return a, err
+ if err != nil {
+ return a, withWriteCause(err, werr)
+ }
+ if normalised(a.Body) {
+ return a, nil
}
if round == 3 {
- return Active{}, fmt.Errorf("%w: the table could not be normalised", ErrUnreadable)
+ return Active{}, withWriteCause(fmt.Errorf("%w: the table could not be normalised", ErrUnreadable), werr)
}
raw, err := json.Marshal(NextActiveBody(a.Body, a.Table))
if err != nil {
return Active{}, err
}
- if _, err := PutTable(ctx, s, tw, raw, a.ETag); errors.Is(err, release.ErrNoSingleAttemptPut) {
- return Active{}, err
+ if _, werr = PutTable(ctx, s, tw, raw, a.ETag); errors.Is(werr, release.ErrNoSingleAttemptPut) {
+ return Active{}, werr
}
}
}
diff --git a/internal/moves/reserve_test.go b/internal/moves/reserve_test.go
index 7542ee789..053a32d66 100644
--- a/internal/moves/reserve_test.go
+++ b/internal/moves/reserve_test.go
@@ -540,3 +540,28 @@ func TestStoreWithoutPutOnceWritesNothing(t *testing.T) {
t.Fatalf("%d writes left registered", tw.Unanswered())
}
}
+
+// A bootstrap or a normalisation the server refuses names its cause: the
+// ErrUnreadable the next read returns wraps the write's own error.
+func TestRefusedTableSetupNamesItsCause(t *testing.T) {
+ ctx := context.Background()
+ forbidden := fmt.Errorf("fake: 403 AccessDenied: %w", release.ErrPutRejected)
+ refuse := func(f *tableStore) {
+ f.onPutOnce = func(int, string, []byte, string) (bool, error) { return false, forbidden }
+ }
+ var tw TableWrites
+ f := newTableStore()
+ refuse(f)
+ if _, err := ReadActive(ctx, f, &tw); !errors.Is(err, ErrUnreadable) || !errors.Is(err, release.ErrPutRejected) {
+ t.Fatalf("a refused bootstrap = %v, want ErrUnreadable naming the rejection", err)
+ }
+ g := newTableStore()
+ g.set(OfflineActiveKey, plan3Body(t, map[string]OfflineReservation{"i-0a": moveEntry("mv_a", "a.example.com")}))
+ refuse(g)
+ if _, err := ReadForAcquire(ctx, g, &tw); !errors.Is(err, ErrUnreadable) || !errors.Is(err, release.ErrPutRejected) || !strings.Contains(err.Error(), "could not be normalised") {
+ t.Fatalf("a refused normalisation = %v, want ErrUnreadable naming the rejection", err)
+ }
+ if tw.Unanswered() != 0 {
+ t.Fatalf("%d refused writes left registered", tw.Unanswered())
+ }
+}
diff --git a/internal/moves/tablewrite_test.go b/internal/moves/tablewrite_test.go
index a2ff2a3be..b3ae960ed 100644
--- a/internal/moves/tablewrite_test.go
+++ b/internal/moves/tablewrite_test.go
@@ -159,11 +159,19 @@ func TestRegistrationIsRemovedOnlyByItsOwner(t *testing.T) {
n int
release chan error
}
- inside := make(chan held)
+ // Buffered, and each held attempt answers a timeout by itself after 2 s,
+ // so a Resend that wrongly claims an owned write returns and fails the
+ // request-count assertion instead of hanging the package.
+ inside := make(chan held, 8)
f.onPutOnce = func(n int, _ string, _ []byte, _ string) (bool, error) {
- h := held{n, make(chan error)}
+ h := held{n, make(chan error, 1)}
inside <- h
- return false, <-h.release // the answer the test hands it
+ select {
+ case err := <-h.release: // the answer the test hands it
+ return false, err
+ case <-time.After(2 * time.Second):
+ return false, errTimeout
+ }
}
var tw TableWrites
body := tableBody(t, OfflineActive{V: 1, Epoch: "00112233445566778899aabbccddeeff", Rev: 2, Reservations: map[string]OfflineReservation{"i-0a": moveEntry("mv_a", "a.example.com")}})
diff --git a/internal/provision/testdata/plan14-root-reads-self-serve-moves-4.tsv b/internal/provision/testdata/plan14-root-reads-self-serve-moves-4.tsv
index daf264c0d..731a0155f 100644
--- a/internal/provision/testdata/plan14-root-reads-self-serve-moves-4.tsv
+++ b/internal/provision/testdata/plan14-root-reads-self-serve-moves-4.tsv
@@ -3,6 +3,7 @@
# Task 1 fix round 2 (8c258e1b), rev3=PROPOSED-self-serve-moves-4: a box with no hibernate region still records its obligation and one with no versioned store never holds gone back. No frozen unit moves and the seal is unchanged; the internal/host and internal/offlinecleanup envelopes are re-pinned in place.
# Task 2a (the table's kinds, epoch and rev, and its single-attempt writes), rev3=PROPOSED-self-serve-moves-4: moves.newEpoch is new and frozen accepted-legacy (J4 root-input, crypto/rand for the table's epoch, as the console's other random ids); the table's readers, release and writes read no file. release gains SingleAttemptPutter and PutOnce (S3Store, DirStore, prefixStore), which read nothing. The record-write census gains PutOnce as a write method (plan14SelfServeMoves4WriteCensusInventory). The seal moves. Envelopes re-pinned: internal/moves, internal/release.
# Task 3 (the cleaning mark, its refusals, the wake's answer and the writer inventory) and Task 2a's rejection ruling, rev3=PROPOSED-self-serve-moves-4: no record envelope changes and no root read class or task is added beyond accepted-legacy. Re-pinned in place with reviewed bases: Service.doWake and ExecBackend.Acquire (a cleaning-held lease refuses the wake), Service.serveWakeError (diagnostic, the 503 with Retry-After: 30) and LockState (a cleaning: owner is held whatever its expiry). readLock is now a read-free wrapper and is reclassified as a hint so its frozen declaration stays classified; its read moved to fetchLock, frozen new. Frozen new: lease.newWriteID (J4 root-input, crypto/rand for the lock body's write id) and leasetest's Holder.ServeHTTP (a test double). Task 2a's rejection ruling (S3Store.PutOnce answers a non-CAS 4xx as release.ErrPutRejected) adds no root read. The seal moves. Envelopes added or re-pinned: internal/activator, internal/host, internal/lease, internal/lease/leasetest, internal/moves, internal/release.
+# Task 2a review fix round and Task 3 fix round 1 (74526ffc), rev3=PROPOSED-self-serve-moves-4: DirStore.PutOnce moves below PutFrom (its unit unchanged), ReadActive and ReadForAcquire name a refused write's cause (no read added), and Task 3's UnmarkCleaning answers and per-operation budget add no root read. No frozen unit moves and the seal is unchanged; the internal/lease, internal/moves and internal/release envelopes are re-pinned in place.
@legacy 74fb3b90400ab84ce62dbd77469e292595d9ad5f463f71f6553d0a090c515383 internal/host/movescleanup_retired.go moveAsideCorrupt legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-4;basis=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): new, self-serve moves 4 Task 1: moves a damaged retired.json aside create-only: an Lstat of the file, os.Link to the first free retired.json.corrupt-<n> (a name already holding the same inode, by Lstat and os.SameFile, counts as the target, which covers a crash after the link), os.Remove of the original and a directory fsync; reads no content; a link refused for permission is errRetiredUnmovable (fix round 1); scanRetired's only file:Lstat@514,file:Lstat@65 3125aba7d2f7910dbe8fd4624ebd44dc71d4a8c5574cb2d268f777b1c191188a
internal/host/movescleanup_retired.go moveAsideCorrupt legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-4;basis=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): new, self-serve moves 4 Task 1: moves a damaged retired.json aside create-only: an Lstat of the file, os.Link to the first free retired.json.corrupt-<n> (a name already holding the same inode, by Lstat and os.SameFile, counts as the target, which covers a crash after the link), os.Remove of the original and a directory fsync; reads no content; a link refused for permission is errRetiredUnmovable (fix round 1); scanRetired's only file:Lstat@514,file:Lstat@65 3125aba7d2f7910dbe8fd4624ebd44dc71d4a8c5574cb2d268f777b1c191188a
@legacy a0509323f7af3ad73b961e8b8f2447494ba49e2250cf231709a67e41372a4c4c internal/host/movescleanup_retired.go scanRetired legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-4;basis=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): new, self-serve moves 4 Task 1: the corrupt scan of this move's own obligation file, moves.RetiredFile (<data>/.offline-moves/<host>/<op>/retired.json, under the agent's own data directory, which it creates and owns): an Lstat of retired.json and of retired.json.corrupt-1, and a ReadFile of retired.json only when its Lstat shows a regular file (no link is followed to read); a body that is neither a valid Retired nor a valid stub of this obligation is moved aside (moveAsideCorrupt) and a stub written (writeCorruptStub); reached only from recordRetired at gone; never run by root file:Lstat@158,file:Lstat@80,file:ReadFile@491 740947f051de79c814b5a3f4a959c334933b0880471d17a83935ba6cc14ee907
@@ -41,8 +42,8 @@ internal/lease/cleaning.go newWriteID legacy accepted-legacy accepted-legacy;rev
internal/lease/leasetest/hold.go Holder.ServeHTTP legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-4;basis=tests only (internal/lease/leasetest; its one non-test importer is host's e2e_bridge.go, the exported end-to-end bridge that only the e2e witnesses call): new, self-serve moves 4 Task 3: the holding S3 proxy reads each request's body to record it and to hold or forward the write; a test double that grants no production root authority file:ReadAll@341 5fb506ee83ab7049992e139ce3e60de89eae88ee205fd5b74ad48c5a5dcf6423
@coverage internal/activator 0bb7d94d921626bf607ad22186bcbc658e3da06af90b22e62b4d89d376e121f3
@coverage internal/host 7709a620e76b3161cf4fa7c8a6c80a4a612fec1aec0e48cf3ed624d41e863c75
-@coverage internal/lease 20f2e391c6f8e0d9ff7f57a374cec056c5eeefbe8191572fcd3695661a141119
+@coverage internal/lease 29b15d09b1a852c66ee6127881ac284ec460e29512112cf034e82ccf2507d382
@coverage internal/lease/leasetest 726797feb98925d74aec9577cb6b1d36c5f6fa0fd32e53adc0751e01ad22305e
-@coverage internal/moves c29e4ac0264ecd9181a327c4d556439e55206dfcfadfc474bed1166263ef2f02
+@coverage internal/moves d88420877c8bdafacfe2397a7efdc8f7723426e51c47321608414be6a7405234
@coverage internal/offlinecleanup db9a962a529e985617d69ddc80ebf0dc128e6d5f229e3547613a321e63cb2778
-@coverage internal/release 93341ee9413d3d1445d0c4e842cdeb6bf799d6663c317e8a4f7ba69a99bab114
+@coverage internal/release 4b46b6dc2cd22fbc650fa6c688bf96b3a2231bc4aaad4a0d80bda4ea06b0f576
diff --git a/internal/release/store.go b/internal/release/store.go
index 6f980d969..da1454a52 100644
--- a/internal/release/store.go
+++ b/internal/release/store.go
@@ -496,11 +496,6 @@ func (d *DirStore) Put(_ context.Context, key string, body []byte, ifMatch strin
// PutFrom is Put streamed: the body goes to a temp file (hashed as it
// goes, so the etag is the same content sha256 Put would give) and is
// renamed into place under d.mu after the same CAS gate.
-// PutOnce is Put: a local write has no retries.
-func (d *DirStore) PutOnce(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
- return d.Put(ctx, key, body, ifMatch)
-}
-
func (d *DirStore) PutFrom(_ context.Context, key string, body io.ReadSeeker, size int64, ifMatch string) (string, error) {
p, err := d.path(key)
if err != nil {
@@ -549,6 +544,11 @@ func (d *DirStore) PutFrom(_ context.Context, key string, body io.ReadSeeker, si
return hex.EncodeToString(h.Sum(nil)), nil
}
+// PutOnce is Put: a local write has no retries.
+func (d *DirStore) PutOnce(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ return d.Put(ctx, key, body, ifMatch)
+}
+
// List is ListMod minus the mtimes — one walk owns the prefix filter and the
// .tmp exclusion, so the two listings cannot drift.
func (d *DirStore) List(ctx context.Context, prefix string) ([]string, error) {