-
feat(storage): server asset registry over a pluggable byte layer (#1007) (#1077)
发布于
2026-08-10 04:30:06 +00:00 - docs(storage): specify the asset registry HTTP contract (#1007)
Adds the contract document and the server-side store, byte-layer, and
handler types for the asset layer, the only layer with no server backend.
Runtime behaviour is unchanged: the new modules are deliberately
unreferenced until the backends land.Reclamation moves offline, and that decision carries the design.
remove
deletes a registry entry and nothing else -- it never reads, counts, or
deletes bytes. A separate collector takes byte rows that have had no
references for a grace period. Three things follow. The hardest race in
the design disappears, because no request can delete bytes another
request is adopting, so no request path needs a lock on the byte row and
the write path never has to reconcile taking one with never branching on
whether the bytes already existed. A side channel closes, because a
removethat only deletes a row costs the same whether or not another
principal holds the same bytes. And nothing is given up: under global
deduplicationremovecould never promise the bytes were destroyed, so
deleting them synchronously bought far less than it appeared to. The cost
is that storage grows until the collector runs, which a deployment must
configure rather than inherit.Offline reclamation is also what makes the byte layer pluggable, so this
keeps #1007's pluggable-backend model rather than narrowing it. With
collection out of every request path, the requirement on a byte layer is
an ordering rule rather than an atomicity one: bytes are written before
the row that references them, deleted after the last row referencing
them, and the reference count is serialized on the blob row that every
implementation keeps regardless of where bytes sit. Two implementations
are planned -- bytes in a column of the transactional store, and an
object store keyed by content hash -- which is why the interface exists;
an interface with one implementation is not a seam, and this package has
removed such a seam before. The browser backend keeps no such seam,
because it reclaims inline and its bytes therefore cannot leave its
transaction; four comments that stated this as a storage difference now
state it as a reclamation one.Metadata crosses the wire as a multipart part. It is open-ended and
callers already fill it with generated-narration text and image prompts,
so a query parameter would put unbounded user content into the request
target and into every intermediary's logs. That also motivates a rule
the other layers do not need: metadata must never appear in a URL, a
response header, a log line, or an error message. Metadata gets a value
domain, and the document is explicit that this is a capability
reduction -- the browser backend's structured clone carries Date, Map,
Set, and cycles faithfully, so a deployment moving to a server backend
must audit its metadata rather than expect a quietly lossy path to
tighten.Resolution yields a client-minted object URL, superseding the resolution
that a server deployment resolves to a self-hosted proxy path. A media
element sends only ambient credentials, so a proxy path works for
cookie-session deployments and silently fails for header-authenticated
ones; closing that gap means minting URL tokens, a second credential
form inside a storage library. Fetching bytes over the client's own
authenticated request needs none and removes a class of hash-disclosure
surface. The client obligations that make it preserve the browser
backend's semantics are specified, and split into the two the shared
suite already pins and the four an HTTP backend must pin itself. The
cost, no range requests and no progressive playback, is stated.Channels that could carry a principal or a content hash are closed
structurally rather than by enumeration: the route table admits no
segment that could carry either, and no route takes a query string at
all. Headers are closed by not reading them -- the contract assigns no
meaning to any header beyond content type and length, and a handler must
not derive identity from one outside its authenticate hook. Rejecting
header names by pattern was tried and removed: no pattern broad enough
to catch X-Remote-User spares User-Agent, and the forwarded-identity
headers such a rule would reject are exactly what an authenticating
reverse proxy injects for that hook to read.Ids that cannot be a path segment are resolved by the client as misses
rather than raised as errors, a deliberate divergence from the KV
contract. An unknown id is a miss by this layer's id-domain rule and the
shared suite pins the empty string as one, so a client that threw would
fail that suite and reintroduce the shape oracle the rule prevents.Also specified: response values may not derive from the shared byte row
(Last-Modified off that row is an existence oracle), a collision-
resistant digest is required because writes are unconditional and an
object layer keys on that digest, byte responses carry the revision and
refuse Range, HEAD errors carry the code in a header since they have no
body, quota exhaustion has a status and a code, and relabelling a
non-renderable type is not refusal -- resolve still returns a URL.-
feat(storage): add pluggable server asset store (#1007)
-
fix(storage): write asset bytes only after claiming the blob row (#1007)
The write path wrote bytes twice: once before the registry transaction
and once inside it, after the upsert that claims the blob row. Only the
second one carries any safety, and the first is what a large-media
deployment pays for on every upload.The ordering that matters is claim, write, reference. Claiming the blob
row takes its lock, so a collector already holding that lock finishes
before the write proceeds; writing bytes before the claim instead lets
the collector delete them while the claim waits, leaving a fresh entry
that points at nothing. Writing them before the entry keeps every
surviving entry backed by bytes that were actually stored. Dropping the
first write preserves both ends of that order and removes a crash window
that produced orphans for no reason.For a byte layer inside the transactional store this also means a failed
write now leaves nothing at all, rather than an orphan the collector had
to sweep later.The four tests this changed were each pinned to the removed write:
- the statement-sequence test hardcoded the old four-statement shape; it
now pins claim, write, entry, and still asserts the two existence paths
are identical, which is the property that matters; - the quota test asserted two writes per accepted put;
- the crash-window test asserted an orphan that a transactional byte
layer no longer produces. It is now two tests: a transactional layer
leaves nothing, and a non-transactional one strands an object with no
blob row -- which the collector deliberately cannot see, since
recovering it is deployment housekeeping rather than reference
counting. It also no longer depends on state left behind by earlier
tests in the file; - the collector-race test drove its interleaving off the removed write.
It was choreography rather than concurrency in any case: PGlite is
single-connection, and the test serialized transactions, so no row lock
was ever contended. It is replaced by the two invariants that actually
make the race safe -- the byte write is unconditional, and bytes the
collector has already removed are re-stored by an adopting put. The
contended-lock case belongs to the real-PostgreSQL suite, where it can
be tested rather than mimed.
Reverting the unconditional write fails two of them, so they pin it.
The contract said a failed write leaves no partially written bytes while
its byte-layer section permits exactly that orphan; the two are
reconciled, and the ordering rule now states the claim step and the
unconditional write it depends on, along with the cost that follows --
the byte write happens with the registry transaction open, so an object
store holds a row lock across a network upload.- test(storage): fix the real-PostgreSQL asset tests against the new write order
Both were written against the removed pre-transaction byte write, and
both failed once it went away -- one of them by hanging, which also
timed out the next test's TRUNCATE.The lock-contention test waited for the adopting put to start writing
bytes before releasing the collector. Bytes are now written after the
upsert that claims the blob row, and the collector holds that lock, so
the write could never start: a circular wait. It now waits for a backend
to actually appear in pg_stat_activity blocked on a lock, which is the
condition it meant to wait for, observed rather than signalled. Its final
assertion carries real weight now -- had the bytes been written before
the claim, the collector would have deleted them and the adopting entry
would resolve to nothing.The orphan test asserted that a failed registry transaction strands
bytes. With this byte layer inside the registry's transaction it strands
nothing, so it now asserts that instead. Object storage does strand an
object, and that case is covered where it belongs.All of this ran against PostgreSQL 16: 781 tests pass with 3 skipped,
and those 3 pass against a real S3-compatible server. Nothing in the
suite is now unverified for lack of infrastructure.-
feat(storage): add asset HTTP backend (#1007)
-
docs(storage): separate trusting a header from reading one (#1007)
The header rule said a server reads exactly Content-Type and
Content-Length, while the size rules require reading Content-Encoding in
order to reject it -- the two cannot both be literal, and the
implementation had to pick one.What the rule means is narrower: no request header may be trusted to say
who is asking, outside the authenticate hook. Reading a header to decide
how to frame or refuse a request is a different thing, and the transport
headers are read and acted on.- chore(storage): bump to 0.2.3 for the asset server backend (#1007)
Additive: new asset registry, byte layers, collector, HTTP handler and
client, and their entry points. Nothing existing is removed or changed
incompatibly, so this is a patch under the pre-1.0 rule.- fix(storage): close four defects found reviewing the asset backend (#1007)
Concurrent writes could exceed a principal's logical quota. The check
ran on the pool before the write transaction, so two concurrent puts both
read the old total and both passed; enough concurrency amplified it
arbitrarily. It now runs inside the write transaction behind a
transaction-scoped advisory lock on the principal, taken only when a quota
is configured -- a branch on deployment configuration, never on data.
Pinned by a real-PostgreSQL test: four concurrent six-byte writes against
a ten-byte quota, of which exactly one may be accepted. Removing the lock
accepts three.A part name smuggled inside a quoted filename was read as a real part
name.Content-Dispositionwas scanned with a regex that does not
understand quoted strings, sofilename="x; name=meta; y"parsed as a
nameparameter here while an RFC-aware intermediary sees an unnamed
part. That is exactly the parser differential the contract requires be
rejected. Replaced with a tokenizer that honours quoted strings and
escapes and requires exactly one realname.Any S3 404 was reported as an absent object.
NoSuchBucket, a
misdirected endpoint, and a revoked access point all answer 404 while the
bytes still exist, so a storage outage surfaced as404 ASSET_NOT_FOUND
and a caller clearing its reference on that would turn it into real data
loss -- the same failure the contract spells out for401. Only
key-absent codes map to a miss now; everything else propagates to
500 INTERNAL_ERROR.Exceeding
maxPartsanswered400rather than413. It is one of
the declared multipart resource limits and now answers like the other
three.Verified against PostgreSQL 16 and a real S3-compatible server:
840 tests, none skipped.- fix(storage): repair two defects the previous fix round introduced (#1007)
Both were created by the fixes themselves rather than surviving them,
which is the failure mode a second review round exists to catch.An over-quota replace answered 500. Moving the quota check inside the
write transaction put it under a catch that re-threw only
AssetNotFoundError, so AssetQuotaExceededError was collapsed into a
generic registry failure and the handler mapped it to INTERNAL_ERROR --
losing the status and code the contract gives that condition. Both typed
errors now survive the catch.The hand-written disposition tokenizer accepted malformed input and
trusted it. The quoted-value loop never checked that it found a closing
quote, soname="metaran to the end of the header and returnedmeta;
name="meta"junkreturnedmetaas well; and an extendedname*form
competing with a plain one was silently ignored. Each recreates the
parser differential the tokenizer was written to remove. A quoted value
now requires its closing quote, rejects a dangling escape, and permits
only whitespace and a separator after it, and an extended name form is
refused rather than resolved.Also widened the quota lock key from
hashtexttohashtextextended.
The 32-bit form collides -- two unrelated principals sharing a key would
block each other for the whole transaction, which spans a byte write that
may be a network upload.Regression tests cover all three: an over-quota replace raises the quota
error and leaves the original bytes, and each of the three malformed
dispositions is refused.Verified against PostgreSQL 16 and a real S3-compatible server:
842 tests, none skipped.- fix(storage): narrow the multipart disposition surface and the error boundary (#1007)
Three review rounds have each found a defect in the part-disposition
parser: a regex that misread quoted strings, then a tokenizer that
accepted an unterminated quote, then RFC 2231 continuation forms
(name*0*=UTF-8''bytes) and malformed empty parameter slots. Patching it
a fourth time would have been the wrong move.This contract needs exactly one parameter, so it now accepts exactly one:
a part disposition isform-dataplus a singlenamewhose value is
metaorbytes, and everything else is a validation failure. That
removes the continuation forms, the encoded forms, the empty slots, and
filename-- the parameter every one of these attacks travelled in --
without having to model RFC 2231 at all.That rule turned out to bind our own client too: it emitted a fixed
filename="asset". Nothing reads it, and the contract already forbids
deriving a response disposition filename from caller data, so the client
no longer sends one.Separately,
instanceofwas being used as provenance. The byte layer is
pluggable, so a byte store raising a same-namedAssetQuotaExceededError
orAssetNotFoundErrorinside the transaction was re-thrown as a logical
registry outcome, carrying the byte layer's own message to a direct
caller. The registry's checks now raise module-private sentinels, and
only those are converted -- at the boundary, into fresh public errors
with this package's fixed messages. Anything else, whatever its name,
collapses to the generic registry failure.Also pinned the advisory-lock key width. Reverting
hashtextextendedto
the 32-bithashtextleft every test green, so the emitted lock
statement is now asserted. A SQL-text assertion is deliberate here: the
behavioural difference is a collision between two particular keys under a
database-internal hash, which is not a stable thing to assert against.Verified against PostgreSQL 16 and a real S3-compatible server:
845 tests, none skipped.-
fix(storage): define multipart disposition grammar (#1007)
-
fix(storage): delegate multipart parsing to Fetch (#1007)
-
fix(storage): require both multipart parts to be files (#1007)
Delegating the framing to the platform parser cost two rules for a
metadata part sent without a filename: such a part comes back as a
string with its headers discarded, so itsapplication/jsontype could
no longer be checked, and its bytes had already been replacement-decoded
--{"x":"<0xFF>"}" was stored as{"x":"\uFFFD"}` instead of refused.The bytes part already had to be a file for binary safety. Metadata is
now symmetric: the client sends a fixed filename on both, the server
requires both to be files, and with the part preserved the media type is
checked and the bytes are decoded fatally before parsing. Neither
filename is read anywhere.The limits are also described honestly now.
maxParts,maxMetaBytes
andmaxAssetBytesare validated after the parser has materialized
every part, so they bound what is accepted rather than what is parsed,
andmaxRequestBytesis what caps memory. The contract saidmaxParts
bounded parser work; it does not. Restoring that would mean adopting a
streaming parser with its own grammar, which is the disagreement with
intermediaries that delegating to the platform exists to remove -- so
the trade is stated rather than reversed.Also corrected the media-type wording: the two branches are metadata
present versus absent, and retention of an untyped replacement's prior
type is a browser-backend behaviour the HTTP path cannot reproduce,
because a conforming parser supplies a default type for a file part
whose header omits one.Verified against PostgreSQL 16 and a real S3-compatible server:
853 tests, none skipped.- docs(storage): the asset server backend ships in this branch (#1007)
The contract still described the server backend as not yet shipped, which
this branch is what changes. The conformance server remains test-only.-
fix(storage): make asset HEAD bytes-free and pin reads (#1007)
-
fix(storage): harden asset write boundaries (#1007)
-
chore(storage): bump to 0.2.4 for the asset server backend (#1007)
main merged in a 0.2.3 of its own, so this branch's bump no longer
increased the version. Additive relative to the merged base -- new asset
registry, byte layers, collector, HTTP handler and client, with nothing
removed or narrowed -- so a patch bump under the pre-1.0 rule.
Co-authored-by: 杨慎 117187635+cosarah@users.noreply.github.com
下载附件