filer: conditional UpdateEntry with a chunk-set write condition (#10382)

* filer: accept a WriteCondition on UpdateEntry, under the per-path lock

UpdateEntry was a bare read-modify-write: the precondition check, the
chunk garbage diff, and the store write could interleave with a
concurrent update to the same path. Take the per-path lock CreateEntry
already holds, and evaluate an optional CreateEntry-style WriteCondition
under it, failing with FailedPrecondition like expected_extended.

* filer: IF_CHUNKS_EQUAL write condition compares the stored chunk fid set

A chunk-preserving read-modify-write (tagging, setattr, copy-in-place)
races UpdateEntry's garbage diff: if a concurrent update empties the
chunk list first, the stale writer's commit resurrects fids that are
already queued for deletion, stranding the entry on a dead needle once
vacuum reclaims it. The reverse also holds: a writer that read an empty
chunk list can wipe chunks a concurrent update just added.

IF_CHUNKS_EQUAL guards both: the stored chunk fid multiset must still
equal what the caller read, order-independent, with an empty fids list
expecting no chunks. Absent entry counts as no chunks for CreateEntry
overwrites and transactions.

* filer: delete and append serialize on the entry path lock

DeleteEntry queues the entry's chunks for deletion and AppendToEntry
rewrites the chunk list, but neither held the per-path lock, so either
could interleave with a conditional update between its precondition
check and its write — a passed IF_CHUNKS_EQUAL would then resurrect
fids already on the deletion queue, or clobber a freshly appended
chunk. AppendToEntry keeps the cluster lock for cross-filer append
serialization; the path lock covers the local read-modify-write.

* filer: reuse lockPath in UpdateEntry lookup
This commit is contained in:
Chris Lu
2026-07-21 00:19:15 -07:00
committed by GitHub
parent 542495f1f1
commit cdb60069a6
8 changed files with 549 additions and 114 deletions
@@ -265,6 +265,7 @@ message WriteCondition {
IF_MODIFIED_SINCE = 6; // fail if present and mtime <= unix_time
IF_EXTENDED_NOT_EQUAL = 7; // fail if present and extended[ext_key] == ext_value
IF_EXTENDED_TIME_ELAPSED = 8; // fail if present and extended[ext_key] (unix seconds) is in the future
IF_CHUNKS_EQUAL = 9; // fail unless the stored chunk fid multiset equals fids (absent entry = no chunks)
}
// Clause is one primitive comparison. IF_ETAG_MATCH holds when the current
// entry's ETag equals any value in etags; IF_ETAG_NOT_MATCH holds when it
@@ -278,6 +279,11 @@ message WriteCondition {
// clock). The caller composes these and, for governance-bypass, simply omits
// the retention clause when the bypass is authorized — the filer makes no
// authorization decision.
//
// IF_CHUNKS_EQUAL guards a chunk-preserving read-modify-write: the stored
// chunk fid set must still equal what the caller read, so a stale write
// cannot resurrect needles that a concurrent update already diffed away
// and queued for deletion. An empty fids list expects no chunks.
message Clause {
Kind kind = 1;
repeated string etags = 2; // ETag set for IF_ETAG_* kinds
@@ -287,6 +293,7 @@ message WriteCondition {
string ext_value = 6; // blocking value for IF_EXTENDED_NOT_EQUAL
string gate_key = 7; // IF_EXTENDED_TIME_ELAPSED: only enforce when extended[gate_key] == gate_value
string gate_value = 8; // gate value (e.g. retention mode COMPLIANCE for governance bypass)
repeated string fids = 9; // chunk fid strings for IF_CHUNKS_EQUAL
}
repeated Clause clauses = 1; // all must hold (logical AND)
}
@@ -447,6 +454,10 @@ message UpdateEntryRequest {
bool is_from_other_cluster = 3;
repeated int32 signatures = 4;
map<string, bytes> expected_extended = 5;
// Optional precondition evaluated against the current entry atomically with
// the write, under the filer's per-path lock. The caller must route the
// key's writes to this entry's owner filer for the check to be authoritative.
WriteCondition condition = 6;
}
message UpdateEntryResponse {
SubscribeMetadataResponse metadata_event = 1;