* 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>
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 1024creates one vector nameddense; 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 (defaultcosine).--sparse— add a sparse vector. Bare--sparsecreates one vector namedsparse;--sparse=NAMEnames it. The=is required —--sparse NAME(space-separated) is rejected, since it would be ambiguous with the trailingPATHpositional. Repeatable for multiple sparse vectors.--quantization <PRESET>— quantize every dense vector. One ofscalar,binary,product-x4,product-x8,product-x16,product-x32,product-x64,turbo1,turbo1.5,turbo2,turbo4.--payload-index NAME:TYPE— create a payload index.TYPEis one ofkeyword,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 byoptimize(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 underDESTINATIONbefore 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 emptyDESTINATION, which would match every object in the bucket.--include-wal— uploadwal/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 (default8).
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