Skip to content

Allow concurrent writers with Caterva2 - #703

Merged
FrancescAlted merged 14 commits into
mainfrom
cat2-concurrent-writers
Aug 22, 2026
Merged

Allow concurrent writers with Caterva2#703
FrancescAlted merged 14 commits into
mainfrom
cat2-concurrent-writers

Conversation

@FrancescAlted

Copy link
Copy Markdown
Member

This implements the necessary changes to allow concurrent writers with Caterva2. Still preliminary.

FrancescAlted and others added 9 commits August 21, 2026 00:19
Pre-sized uninit arrays written once per chunk, with the offsets block
carrying the completion record.  Measured what the format allows first:
a chunk whose old content was special appends rather than compacting, so
a first write costs ~0.5 ms and moves no other chunk's offset.

Records why the frame length cannot serve as a validator (a zeros write
can leave it unchanged) and why the .b2lock generation counter can, and
why chunk writes do not generalise to fsspec/S3 the way the reads did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A pre-sized array is filled a chunk at a time, by as many writers as
there are chunks: `C2Array.update_chunk` posts one, and the subscriber
refuses a slot that already holds anything.  `written_chunks` reads what
the fill recorded in the frame's own offsets, so progress costs one range
read and no endpoint of its own.

Reading the index turned out not to be the same question as reading
blocks of a chunk: `serves_blocks` also weighs whether splitting a chunk
would pay, which is nothing to do with whether the frame has offsets to
read.  Split the geometry half out, so a frame of small chunks can still
say which of them were written.

`invalidate_index` drops what was read of a frame that has since been
written to -- the header as well as the offsets, since a write moves the
frame's length and its payload extent, and the offsets are found through
both.  Nothing is read until the next lookup asks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phases 1, 2, 3 and 5.  Phase 4 stays open with what was learned about it
written down: the ETag is the freshness half of the stamp question and
does not answer the identity half, which is what tells an array that was
appended to from one that was replaced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The stand-in accepts chunk writes now, so --write lays out an empty array
of the dataset's geometry, fills it with the dataset's own chunks, and
times the three things the design rests on: the fill serial and with
several writers, what the server pays to store a chunk into an empty
slot against over a live one, and what reading the progress costs from
the offsets against walking the chunks.

Two of those numbers came out wrong before they came out right, and both
were the measurement's fault.  Rewriting a live chunk with bytes of its
own length is the case the frame skips the move for, so it has to carry
a chunk of a different compressed size or it measures nothing; and the
first FsspecNDSource of a process pays for fsspec's own import, which is
77 ms of nothing to do with reading offsets.

The ratio a rewrite costs is the dataset's own -- it is whatever payload
follows the chunk -- so the row says how many bytes were moved rather
than leaving a bare multiple to be read as a constant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two questions wanted different answers, and mtime:cbytes was answering
neither well.  Which array this is now comes from the nonce a subscriber
writes into vlmeta on the first chunk written: a size and an mtime can
both be repeated by a different array at the same path, and a cache
served against that one is wrong in every chunk without saying so.

Whether it has changed since keeps the mtime and the size -- but only
while the array is still being filled.  A complete array is stamped by
its nonce and size alone, since every slot is claimed and every write to
it refused, so a cache of it stands where before an mtime that moved for
reasons of its own threw the whole thing away.

The plan wanted the stamp to hold still *during* a fill, on the grounds
that a cache's chunks are all still where they were.  They are not: a
cache built while a chunk was unwritten holds the zeros an unwritten
chunk reads as, and the run-length offset it had, and both are wrong the
moment a writer fills that slot.  A frozen stamp serves those zeros for
good, which a test now pins by freezing one and watching it happen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review found three ways the write path could serve wrong data, and all
three are fixed here with a test apiece.

A handle reads api/info once when it opens the array, and `stamp` is
built from exactly the fields a write moves -- so a handle used to fill
an array kept its pre-fill stamp for good, and a Proxy given that handle
adopted a cache built while the chunks were still empty.  A write marks
the metadata stale now, and whatever asks reads it again.

`serves_blocks` weighs whether splitting a chunk into blocks would pay,
which is nothing to do with whether the frame has an index worth
reading -- but the two shared one memoized source, so touching the block
path first (max_ranges is a public property, and Proxy reads it) cached
None and left written_chunks raising NotRanged ever after.  The
judgement belongs at the call, not in the slot.

A complete array stamps `n<nonce>:<cbytes>` and a filling one whose
subscriber reports no mtime stamped the same string, which is the one
collision the whole design exists to prevent.  Both carry a marker now.

Also from the review: invalidating the index no longer empties what
index_state hands a cache, nor drops layouts a written chunk can never
invalidate; _forget_index takes the lock a source is built under; the
run-length tag is read in the host's byte order rather than always
little-endian; and the fill bench built a C2Array per chunk rather than
per writer, which charged every timed write an untallied api/info.  The
numbers move little -- 7.6x rather than 7.0x for eight writers over a
simulated WAN -- and the plan is corrected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@FrancescAlted
FrancescAlted force-pushed the cat2-concurrent-writers branch from cdd56d0 to f4a4dc1 Compare August 21, 2026 08:11
FrancescAlted and others added 5 commits August 21, 2026 12:42
A review of the branch found the freshness only ever reached the handle that
did the writing.  What that left, and what it is now:

* `meta` is read when a handle is opened and never again of itself, so a
  reader that outlived someone else's chunks handed a `Proxy` the stamp of the
  array as it was -- which its cache matched and the bytes no longer did.
  `refresh_stamp()` looks again, and a `Proxy` calls it before judging a cache;
  a complete array costs nothing there, since nothing can write to one.
* `index_state` handed a cache the offsets a write had already moved, and the
  stamp guarding them said the array had not changed -- true of the array,
  false of the offsets.  Nothing stale is handed over now.
* Reading `api/info` again is a round trip, and a write of the handle's own
  could land inside it; that answer is dropped rather than stored as current.
* Every property built on `meta` refreshes, not just `stamp` and `vlmeta`, so
  `cbytes` and `serves_blocks` no longer depend on what was read first.
* `written_chunks()` reads the frame rather than an index a `Proxy` cache left
  behind, and no longer spends an `api/info` it has no use for.
* A write that was refused still forgets what the handle believed: the refusal
  is the one answer that proves another writer moved the frame.
* `invalidate_index` drops the block layouts too.  Where a chunk is says
  nothing about the bytes at that position still being the ones its blocks were
  mapped from, and the method promises nothing against a rewrite in place.
* An offset coding a run-length kind nothing can rebuild is refused when the
  index is read, rather than mid-fetch where there is no fallback for it.
* `aupdate_chunk` invalidates off the event loop, instead of parking it on the
  lock an in-flight source open holds.
* One request helper for both ways of posting a chunk, one URL builder, one
  vectorized reader of the run-length tag, and offsets out of a cache brought
  back into the host's byte order.
* The `update_chunk` example built a chunk without the array's blocksize, which
  is one the subscriber refuses.

Tests for each of the above, including the block path, which nothing reached
before: `__getitem__` goes through `api/fetch` and never touches the index.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`index_state` and `adopt_index` are how a `Proxy` carries a frame's offsets
into its cache and takes them up again on the next run.  Nothing else calls
them, and nothing outside `ByteRangeNDSource` implements them: a source over a
Blosc2 frame inherits both, and a source over anything else has no index to
keep.  Published, they read as a caller's API, and their contract is not one --
what `index_state` hands back is only safe against a stamp that says the bytes
have not moved, which is a precondition nothing in the signature carries.

So: `_index_state` and `_adopt_index`, which autodoc leaves out on its own.
`written_chunks` and `invalidate_index` stay as they are -- how far a fill has
got is a question a caller does ask.  Neither name has shipped in a tag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`is_fsspec_url` excluded `http(s)://` as reserved for Caterva2.  Nothing was
holding the reservation: a subscriber names its datasets by root and path, not
by URL, so `C2Array` is entered through a `blosc2.URLPath`, which `open`
dispatches on by type long before a string is inspected.  What the exclusion
bought was `blosc2.open("https://host/big.b2nd")` falling through to the local
path branch and reporting a URL as a missing file.

fsspec reads http(s) in ranges wherever the server answers them, which is what
`FsspecNDSource` wants and no more than what it wants of `s3://`.  So a frame
behind nginx, a CDN or an S3 website endpoint now opens whole, through
`cache_storage=`, or a piece at a time with `lazy=True`, like every other URL.

The write guards gain by it too: saving to an `https://` URL says so now,
instead of quietly creating a local file with the URL for a name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reading a remote array is spread across four reference pages and a tutorial,
and the three things this cycle added -- block-granular reads, a stamp that
says a cache has gone stale, and filling an array from several writers -- have
no home outside the release notes.  So: one page, task-first.

Which of the three ways in to use, what the cache holds and when it is thrown
away, what blocks buy (5-17x on S3, 0.14 s against 1.01 s on cat2.cloud), how a
fill is coordinated by the array refusing a second write (7.6x with 8 writers),
and the fifteen lines it takes to give a transport of your own the lot.  The
figures are the published ones, not re-measured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The guide said "upload it with your Caterva2 client", which is the one step of
the fill a reader cannot work out for themselves.  It is `cat2-client upload`,
which ships with Caterva2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@FrancescAlted
FrancescAlted merged commit 0a58cc0 into main Aug 22, 2026
36 checks passed
@FrancescAlted
FrancescAlted deleted the cat2-concurrent-writers branch August 22, 2026 10:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant