Files
Andrey VasnetsovandClaude Fable 5 06ffcb881f Add CachedBlobFile: cached reads + write-through appends for object stores (#10206)
* Add CachedBlobFile: cached reads + write-through appends for object stores

Combine a DiskCache mirror (reads) with a BlobFile remote handle (appends)
into CachedBlobFile/CachedBlobFs, the appendable universal-IO citizen for
object stores. Appends perform the remote mutation inline and are durable
at Ok: a native write-offset append in AppendMode::Native (with a soft
limit on appends per object), or a whole-object rewrite in
AppendMode::Rewrite for stores without native append. After a successful
append the mirror length is advanced without extra IO; appended blocks
fault in from the remote on first read.

The multipart UploadPartCopy rewrite path (prefix >= 5 MiB) and the
rewrite-required error classification are left as todo!() pending the
AsyncRewrite backend capability.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Backend-advertised AppendMethod; reactive appended-block cap recovery

Replace CachedBlobFile's stored AppendMode with AsyncAppend::supported_append:
the backend advertises Native or PartialUpload, and append takes a matching
AppendRequest variant, rejecting the ones it does not support. The multipart
UploadPartCopy todo moves into the S3 backend's PartialUpload arm.

Drop the native_appends soft-limit counter: it is per-handle in-memory state
that resets on every restart, so it can never be the correctness mechanism
and persisting it would not make it authoritative either. The store is the
authority: hitting its appended-block cap now surfaces as the new
UniversalIoError::AppendRewriteRequired (S3 400 TooManyParts), and
CachedBlobFile recovers with a whole-object rewrite. Unrecognized errors
stay hard errors instead of silently triggering rewrites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Per-store append strategies; server-side rewrites for plain S3 and GCS

Replace the single AppendContext struct with an enum of strategy objects,
one per store capability, each owning its append logic:

- NativeAppend: the signed write-offset PutObject (S3 Express, MinIO
  AiStor; AwsConfig::native_append declares it for AiStor-like endpoints,
  s3_express implies it).
- PartCopyAppend: plain S3 — appends land as one atomic multipart rewrite
  whose prefix parts are server-side UploadPartCopy requests; nothing but
  the appended data crosses the network. object_store keeps such
  provider-specific calls out of its portable surface, so the requests are
  hand-signed like the native append.
- ComposeAppend: GCS — the appended data is uploaded as a temporary
  neighbor object and composed onto the destination server-side,
  conditional on the observed generation (a real compare-and-swap).

AppendMethod is replaced by AppendSupport, which tells the caller the only
thing it needs: when the store takes a direct append. Always (native, and
compose: no part minimums, no block cap), AboveThreshold (part-copy: the
copied prefix lands as non-last multipart parts, >= 5 MiB each), or Never.
CachedBlobFile drops its hardcoded MIN_COPY_PREFIX and rewrites locally
only below the backend-advertised threshold; AppendRequest::Rewrite now
means only "append and rebuild as a single blob" — the appended-block cap
recovery.

The append module is split one file per strategy, with a shared
SignedRequestContext transport and a test-only HTTP stub.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* DiskCache tracks the remote object's etag

Seeded from the new known_etag open extra (OpenExtra::with_known_etag),
refreshed from FileInfo on schedule_reopen, and settable directly for
callers that mutate the remote out of band.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Remove AppendRequest enum; appended-block cap recovery moves into the backend

AsyncAppend::append takes plain (path, offset, data). A native S3 store
that rejects an append with TooManyParts now falls back to the part-copy
rewrite inside the dispatcher, instead of surfacing AppendRewriteRequired
to CachedBlobFile for a second Rewrite request. The Rewrite variant was
handled identically to Append everywhere except that one native path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Escalate to download+rewrite when the store rejects a part-copy rewrite

The cap-recovery rewrite is chosen by the store's returned error, not a
client-side threshold: a part-copy attempt rejected with EntityTooSmall
(typed as UniversalIoError::AppendEntityTooSmall, parsed from the S3
error <Code>) falls back to downloading the sub-part-minimum prefix and
PUTting the whole object back, guarded by a prefix-length offset check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix S3 Express appends: zonal endpoint + s3express SigV4 service

Hand-issued appends targeted the standard endpoint and signed as "s3",
so every append to a directory bucket got 404 NoSuchBucket, masked as
AppendOffsetConflict by the 404 mapping. Derive the zonal
{bucket}.s3express-{az}.{region} base from the mandatory --{az}--x-s3
bucket suffix (mirroring object_store's private derivation), carry the
SigV4 service name in SignedRequestContext, and treat a 404 as a
conflict only for NoSuchKey or bodiless responses — NoSuchBucket stays
a loud error guarding the endpoint derivation. extract_xml_tag moves up
to the context module and now tolerates tag attributes and
pretty-printed bodies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Server-side etag precondition on appends; BlobFile loses UniversalAppend

AsyncAppend::append carries an expected_etag that S3 part-copy rewrites
attach as x-amz-copy-source-if-match (412 -> AppendEtagMismatch, a new
typed error) and download_rewrite checks against the GET's own etag;
native write-offset PUTs and GCS compose ignore it. BlobFile appends
only through the inherent etag-aware append_bytes now — CachedBlobFile
calls it directly with its DiskCache-tracked etag — and BlobFs's
mutating ops become inherent, delegated from CachedBlobFs, per the
standing TODOs. The append conformance battery runs over the
CachedBlobFs stack, via new direct constructors that share one backend.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Drop unfulfilled too_many_arguments expectation

rewrite_parts has exactly seven parameters — at the clippy threshold,
not over it — so the lint never fires and the expect fails CI under
-D unfulfilled-lint-expectations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 18:26:49 +02:00
..

edge-tool

Create, seed, optimize, and upload minimal local Qdrant edge collections — for benchmarking and manual testing of the edge write/read/optimize paths without a running Qdrant server.

create and upsert write through edge::EdgeShard directly (the full read+write local shard, not the read-only or update-only edge variants used by edge-shard-query/edge-shard-update), so the resulting directory is a real, self-contained edge collection: readable by edge-shard-query, promotable to object storage with upload, or opened by a real Qdrant edge deployment.

Subcommands

cargo run -p edge-tool -- create --dense 1024 --sparse --quantization turbo4 ./collection
cargo run -p edge-tool -- upsert -n 1000 ./collection
cargo run -p edge-tool -- optimize ./collection
cargo run -p edge-tool -- upload --bucket my-bucket ./collection my_collection/0

create — build a minimal collection on disk

edge-tool create [OPTIONS] <PATH>
  • --dense <SIZE> — add a dense vector. A single bare --dense 1024 creates one vector named dense; with more than one dense vector, every one must be named: --dense text:768 --dense image:512. Repeatable.
  • --distance <cosine|euclid|dot|manhattan> — distance metric for every dense vector (default cosine).
  • --sparse — add a sparse vector. Bare --sparse creates one vector named sparse; --sparse=NAME names it. The = is required — --sparse NAME (space-separated) is rejected, since it would be ambiguous with the trailing PATH positional. Repeatable for multiple sparse vectors.
  • --quantization <PRESET> — quantize every dense vector. One of scalar, binary, product-x4, product-x8, product-x16, product-x32, product-x64, turbo1, turbo1.5, turbo2, turbo4.
  • --payload-index NAME:TYPE — create a payload index. TYPE is one of keyword, integer, float, text, geo. Comma-separated and/or repeatable, e.g. --payload-index city:keyword,age:integer.
  • --indexing-threshold-kb <N> — indexing threshold in KB: segments larger than this get an HNSW index built for them by optimize (omit to use the built-in default).
  • --on-disk-payload — store payload on disk (mmap) instead of RAM.

PATH must not already contain segment data.

upsert — seed the collection with random points

edge-tool upsert -n 1000 <PATH>

Generates -n/--num random points shaped to match the collection's live schema — every dense/sparse vector in its config, one random value per payload field currently indexed (read from EdgeShard::info().payload_schema, so it reflects reality even if the shard was not created by this tool). Point ids are sequential and default to starting at the collection's current (approximate) point count, so repeated upsert calls append rather than overwrite; override with --start-id. --seed controls the RNG (default 42).

Points are written --batch-size at a time (default 1000), one update operation per batch. Each batch is generated, written and dropped before the next one starts, so peak memory follows the batch rather than --num. Batching pays off twice, because an update operation is also CBOR-serialized into one WAL record: writing everything in one operation holds the points once as structs and again as that record.

The batches are not one transaction. A failure partway through leaves the batches before it applied — re-run to continue, since --start-id defaults to the collection's current point count.

optimize — run the shard optimizers

edge-tool optimize <PATH>

Calls EdgeShard::optimize() once, which itself loops (merge/indexing/vacuum) until no further optimization plan is produced. Logs the segment/point counts before and after.

upload — push the collection to object storage

edge-tool upload [OPTIONS] <SOURCE> <DESTINATION>

Recursively uploads every file under the local SOURCE directory to DESTINATION (a key prefix inside the bucket, e.g. my_collection/0), preserving the relative directory structure — so the result is byte-for-byte the same layout edge-shard-query/edge-shard-update expect via --prefix. Every file is streamed through object_store's multipart upload API (even small ones, as a single final part), at most four parts of a file in flight at a time, so upload memory use stays bounded regardless of segment size.

The wal/ directory is skipped: the read paths open segments and the config only, EdgeShard recreates an empty WAL, and WAL segments are preallocated to their full capacity and never truncated — a freshly created collection is 72 KB of segments next to 64 MB of WAL.

  • --clean — delete every existing object under DESTINATION before uploading, so a re-upload doesn't leave stale files behind from a previous run with a different shape (e.g. a collection re-created with a different segment UUID). Refused for an empty DESTINATION, which would match every object in the bucket.
  • --include-wal — upload wal/ as well.
  • --aws (default) — AWS S3 or an S3-compatible store (MinIO, RustFS, ...).
  • --gcs — Google Cloud Storage.
  • --bucket [BLOB_BUCKET] — required.
  • --endpoint [S3_ENDPOINT] — custom S3 endpoint (MinIO/RustFS/LocalStack; omit for real AWS).
  • --region [S3_REGION] — required for real AWS, optional for S3-compatible endpoints.
  • --access-key [S3_ACCESS_KEY] / --secret-key [S3_SECRET_KEY] — must be given together; if both are omitted, the AWS default credential chain is used.
  • --session-token [S3_SESSION_TOKEN], --s3-express [S3_EXPRESS].
  • --gcs-service-account-path [GCS_SERVICE_ACCOUNT_PATH] / --gcs-service-account-key [GCS_SERVICE_ACCOUNT_KEY] — GCS credentials; the path form takes precedence, ADC is used if neither is set.
  • --concurrency <N> — files uploaded in parallel (default 8).

Example — end to end against a local S3-compatible store

Using ../s3_proxy to serve a local directory as S3:

../s3_proxy/s3_proxy.sh up

cargo run -p edge-tool -- create --dense 128 --sparse --quantization turbo4 \
    --payload-index city:keyword,age:integer ./collection
cargo run -p edge-tool -- upsert -n 1000 ./collection
cargo run -p edge-tool -- optimize ./collection
cargo run -p edge-tool -- upload \
    --endpoint http://localhost:9000 --bucket test-bucket --region us-east-1 \
    --access-key test --secret-key test \
    ./collection my_collection/0

# Read it back with no Qdrant server involved:
cargo run -p edge-shard-query -- \
    --backend aws --endpoint http://localhost:9000 --bucket test-bucket --region us-east-1 \
    --access-key test --secret-key test --prefix my_collection/0 \
    scroll --limit 10