mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-07 20:47:46 +01:00
* sw-client: the filer and volume client library The client the Enterprise repository's VFS and vector engine use, with the same API: filer gRPC for entries, listings, volume locations, assignments and remote caching; volume HTTP, plain or TLS, for needle bytes; chunk resolution for sparse, overlapping and manifest chunks; a location cache; and the write side. Enterprise-only protocol fields (license, conditional mutations, hard links, KV listing) are left out. Compiled against weed/pb/filer.proto like the other worker crates. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-lance-worker: reads straight from the filers and volume servers direct_read::DirectReads wraps the object store Lance opens so that entries, listings and bytes come from the filers (gRPC) and the volume servers (HTTP, signed with the read JWT), whatever the entry holds: inline content, chunk manifests, sparse or overlapping chunks, the filer's volume encryption, the gateway's SSE-S3 (sse.rs, with the KEK from security.toml or the filer) and objects tiered to remote storage. Writes still go to the inner store. The code and its tests are those of the Enterprise repository's engine. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-vector: read tables from the filers and volume servers The engine asks the masters for the filers (and follows the list), takes jwt.signing.read.key, grpc.client, https.client and the SSE-S3 keys from security.toml, and opens every table with DirectReads innermost and the read cache outside it. --masters is required: the gateway finds the engine through them and the engine finds the filers. Writes still go to the gateway. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * sse: chunks out of a manifest keep their own SSE-S3 keys A chunk expanded from a manifest carries its id as fid, so keying the per-chunk key cache by file_id gave every such chunk the first one's key and IV. Keyed by the chunk's id however it is carried. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * direct reads: a silent filer is passed over like an unreachable one Each filer attempt is bounded, so a filer that accepts the connection and never answers does not hold the read or the startup lookup. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * sw-client: carry the assignment's fsync to the volume upload The filer's storage rule may want a needle on disk before the volume answers; the assignment says so and the upload now asks for it, as the Go uploader does. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-vector: argument tests pass the required masters; connect timeout for filers Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * direct reads: a failed read is tried once more against fresh locations A fetch that fails forgets the volume locations it was made against; the request now looks them up again and retries instead of failing on a volume that moved. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * sw-client: an exclusive create that finds its entry, and needle deletion create_entry marks ENTRY_ALREADY_EXISTS with EntryExists, so a caller can tell a lost conditional create from a failure; the transport deletes a needle whose entry was never created. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * sse: encode chunks as the gateway stores them EntryEncoder writes SSE-S3 under a fresh object key wrapped by the KEK, serialized with the IV, key id and commitment the gateway's reader expects, each chunk under the IV at its offset; and the filer's volume encryption under a per-chunk key. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * direct storage: writes, deletes and table removal without the gateway A put uploads its chunks to the volume servers the filer assigns, at the filer's chunk size, encoded as the bucket and cluster ask, and creates the entry the gateway would: md5, mime, size, the ETag attribute, the SSE-S3 attributes. A conditional create is the filer's exclusive create, so Lance's manifest commit conflicts the same way; the needles of a create that failed are discarded. A multipart upload turns parts into chunks and derives its ETag from them. Deletes remove the entry with its data, and remove_table a table directory. Copy and rename, which Lance does not use on S3, are not offered. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * vector.proto: Table says whether its bucket encrypts with SSE-S3 The engine writes the table's files itself, so it needs what the S3 write path knew: whether the bucket declares SSE-S3. storage_options go: the engine reaches no S3 store. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * s3vectors: send the engine the bucket's SSE-S3 setting The rule the S3 write path applies to a Lance bucket's files, factored out of DeclaredBucketEncryption, decides it from the vector bucket's record. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-vector: every table operation goes to the filers and volume servers Opens, index creation and drops all go through the direct store, so the engine has no S3 client of its own left to protect: --storage-option, the endpoint pin and the request-option allowlist go, and lance's own S3 store is built unsigned and never dialed. The bucket's SSE-S3 setting comes with the table; -encryptVolumeData encrypts chunks for the volume servers as the gateway's flag does. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * s3vectors tests: the engine takes no credentials Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * s3vectors e2e: the engine writes what the gateway would have Every leg checks that the entries the engine wrote carry the gateway's ETag attribute, the hardened leg that their chunks are encrypted for the volume servers, and a new test that an SSE-S3 bucket's files are written under keys the gateway unwraps when it serves them over S3. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * cluster plumbing moves to the worker crate for the engine, worker and CLI security.toml, the gRPC TLS connector and acceptor, the master dialer, the filer discovery and the storage connection built from them now live in weed-lance-worker (cluster, security, grpc_tls), where the worker and the CLI can use them too. The server crate re-exports security and tls under their old paths. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-worker, weed-vector-cli: tables through the filers and volume servers dataset::Storage replaces the S3 credentials every job and the CLI carried: a table is opened and written through the cluster's storage, encrypted as its bucket declares (read from the bucket's record on the filer), and dropped there. Both take --masters and security.toml like the engine; --access-key and --secret-key go, and with them the catalog's x-api-key credential, which the cluster token replaces. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * lance catalog: the cluster token alone authenticates a cluster process The engine, the maintenance worker and the CLI hold the cluster's filer signing key and reach a table's files themselves. A request presenting the cluster token and no other credential is theirs: it is authenticated by the token, runs through the trusted manager, and is vended no S3 credentials. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * worker chart, README and test design follow the worker's new arguments Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * s3vectors tests: the worker and the CLI reach the cluster through the masters The cluster gets a filer signing key, which the catalog authenticates the worker and the CLI by; both take --masters in place of S3 keys. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * direct storage: encrypted chunks each start their own CTR stream The gateway encrypts a multipart part from the IV derived for its offset, from position zero; the encoder encrypted from the exact byte offset of the object's stream instead, which agreed only at block boundaries. A part of 3,000 bytes read back wrong; now tested. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * direct storage: keep what a lost reply saved, discard what a failed put left A create the filer saved but did not answer, or an exclusive create retried onto this very upload, keeps its chunks and counts as written; the needles of a put that failed midway are discarded. The report prints reads as reads. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-worker: the security.toml it is given also holds its signing key Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * worker chart: the Lance worker mounts grpc.client's certificate Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * direct reads: the filer deadline bounds a listing page, not the listing Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-vector: security.toml booleans read as viper reads them Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-vector: a filer the master lists badly does not hide the others Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-vector: register with the masters only once ready to serve Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * sse: every chunk after the first is written under a key of its own Chunks of one object shared a key with IVs derived from their offsets, so two chunks meeting inside a cipher block shared keystream. The first chunk stays under the object's key and IV, which is how the gateway reads an object of one chunk; every other chunk gets a fresh key and IV, as the gateway's multipart parts have and as it reads an object of several. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * worker chart: the Lance worker is given the filer group Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * s3vectors e2e: the gateway serves engine-written SSE-S3 files of one chunk and of several Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * sw-client: the HTTP transport asks the volume to fsync when the assignment does write_synced fell back to the trait default, which refuses fsync, so every upload under a fsync storage rule failed before it was sent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * sw-client: https.client.ca alone vouches for volume servers With a CA configured, reqwest still trusted the system roots next to it; weed processes trust only the configured pool. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * sw-client: an empty volume range is read without a request A zero length underflowed the Range end, and a range past u64::MAX overflowed it; the first now returns no bytes, the second an error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * sw-client: a whole-needle answer to a ranged read is cut to the range A server or proxy that ignores Range answers 200 with the whole needle; the read returned its first bytes in place of the ones asked for. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * sw-client: a read size past u64::MAX reads to the end of the file offset + size overflowed before the clamp to the file size. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * sw-client: an SSE-encrypted manifest is refused without a decoder Only filer-encrypted manifests were; an SSE one went to the protobuf decoder as ciphertext. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * sw-client: an empty write leaves the staged file size alone A zero-byte write past the end grew the size to its offset, as a zero-byte pwrite does not. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * sw-client: the chunk cache keeps no empty chunks They add no bytes, so nothing evicted them and the map grew with every distinct empty needle, even at capacity zero. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * sw-client: flushing with a zero chunk size is an error, not a panic Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * weed-vector: an empty master list fails at startup "" split into one empty address that passed the check, and the engine then waited two minutes for a filer before a generic error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * lance cluster: an empty master list fails at startup Cluster::new took "" as one master, so weed-vector, weed-worker and the CLI waited two minutes for a filer before a generic error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * direct storage: a failed create keeps its chunks when the filer cannot be asked The lookup after a failed create is what tells a lost reply from a refusal; when it fails too, the entry may be saved, so its chunks stay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * weed-vector: a gateway-described table takes the shared unsigned store options Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * vector.proto: give sse_s3 a new field number and reserve storage_options Field 4 held the storage_options map on master; a gateway from that build would send a length-delimited value the engine cannot decode as a bool. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * weed-vector-cli: open s3:// URIs on the cluster's storage A table named by its s3:// location skipped the store wrapper and opened Lance's own S3 store, which has no credentials since the fallback flags were dropped. It now opens like a namespace table and needs --masters; local paths keep the plain builder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * vector: an index id is plain path components, and drop trusts describe() `resolved()` now refuses an id whose part is empty, `.`, `..` or holds a `/`, since the colocated path would land elsewhere on the filer; and `drop_index` no longer repeats the colocation check for the location describe() already enforced for the configured schemes, which made a drop on a memory:// table fail in the scheme test configuration. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * vector: a file delete takes only its entry; a table path is plain names The store's delete of one file no longer asks the filer for a recursive delete, so a path that names a directory does not take its content with it, and `remove_table` refuses a location whose bucket or path component is empty, `.`, `..` or holds a separator, which the filer would resolve outside the table. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * vector: chunks are assigned to the bucket's collection The filer assigns a chunk's volume from the collection the caller names; the gateway names the bucket, prefixed by the filer group when the cluster has one, so a plain assign landed the engine's chunks in the default collection. The store now asks for `<bucket>` or `<group>_<bucket>`, with the group taken from the cluster connection. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * vector: a put's content type becomes the entry's mime Lance sets no content type today, so every entry was written as application/octet-stream; a put that does carry one now creates the entry with it, as the gateway does. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * vector: an upload whose create went unanswered is done with A create the filer took and never answered (a timeout, or a transport that reports the filer unavailable) may still be applied, so its chunks are kept, as they already were for a create whose reply was lost. The upload then records that it is done: a second completion is refused instead of creating an empty entry, and an abort discards nothing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * vector: an upload dropped unfinished discards its chunks A request cancelled mid-write drops its multipart upload with neither complete nor abort called, leaving its needles on the volume servers. Dropping an upload that still holds chunks now discards them on the runtime; a completed upload, or one whose completion is in doubt, holds none. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * s3: a cluster process registers and deregisters in the vector namespace The Lance catalog ran a cluster-token caller through the trusted manager for every operation but the vector-namespace write check of register and deregister, which still used the caller-checked manager and answered 403 for an index of a LANCE bucket. One `manager(r)` now picks the manager for all three. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * vector: the worker's flags take two dashes The worker parses its flags with clap and, unlike the engine, does not accept Go-style single-dash long flags, so the harness's hardened worker leg and the README's `-encryptVolumeData` did not start it. The README's commands also gain the `--masters` the worker now requires. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * vector: the CLI takes --encryptVolumeData An index build or optimize from the CLI wrote its chunks unencrypted for the volume servers unless the filer itself asked for the cipher; the CLI now takes the flag the engine and the worker have. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * vector: a bucket's encryption is read when a table is opened The setting was cached for a minute, so a bucket recreated with SSE-S3 within that minute had its first tables written plain, and a record that could not be decoded was read as "no encryption". A table open now reads the bucket's record from the filer, once per open, and an unreadable record is an error. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * vector: a removed table's files leave the object cache `remove_table` asked the cache to forget everything under the table, but the cache was built without invalidation closures, so the call was refused and its error dropped: a data file read before the drop was still served afterwards. The cache now supports the invalidation, and a refusal is an error. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * vector: an abort keeps the chunks it could not discard `discard_all` stopped at the first chunk a volume server would not give up and dropped the rest from the upload's state, so a retried abort found nothing to do and the needles stayed. Every chunk is now attempted, the ones left are kept for the next abort or the drop, and the upload can no longer be completed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * vector: a chunk write that fails gives its needle back When the volume server took the chunk but its answer was lost, the needle stayed under a file id nothing would ever reference. The assignment is known before the write, so a failed write now discards it, best effort, before the error is returned. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * vector: the live worker tests require the cluster's masters With WEED_LANCE_NAMESPACE set and WEED_MASTERS not, the tests seeded their tables through lance's own S3 store, which reaches nothing, and failed before a job ran. The missing variable is now named outright. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * direct storage: only the filer's missing-entry error means not found Matching any "not found" text read failures such as an unresolved remote storage as a missing entry, so a delete or a listing reported success over data that was still there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * direct storage test: compare the chunks' SSE-S3 IVs Each chunk's metadata wraps its key under a fresh nonce, so the metadata differed even if the key and IV were reused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * direct storage: a filer passed over unanswered keeps the outcome open When one filer timed out on a create and the next refused it, the refusal came back without the no-answer mark, so the put discarded chunks the first filer may still reference. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * lance test: start every concurrent writer before awaiting them The lazy map spawned each writer only after the previous one finished, so the commits never overlapped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * direct storage: count failed uploads and deletes The storage report counted failed reads and lookups but not a volume upload, an object delete or a table removal that failed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * S3 Vectors tests: run weed-vector-cli as the cluster's processes run The CLI ran without the fixture's environment and outside its directory, so under VECTOR_READ_JWT it could not read the volumes and under VECTOR_TLS it found no security.toml. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * direct storage: a filer that fell away leaves the outcome open too An unavailable error (connection reset, deadline, transport failure) can arrive after the filer took the request, so a refusal from the next filer no longer settles a create the first one may still apply: the chunks are kept, as after a timeout. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * direct storage: a chunk write abandoned in flight gives its needle back The needle is assigned before the write, so a request cancelled while the write is in flight left it on the volume server with nothing to name it. `Source::upload` is now `assign` and `write`, and the upload holds the assignment across the write in a guard: a write that fails discards it and one abandoned discards it on the runtime, while a write that returns hands it to the entry. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * lance tests: sign for the namespace as the worker does The live tests reached the namespace anonymously once the S3 keys went, and an anonymous caller lists no tables, so detection proposed nothing. They now take the cluster key from where the worker reads it (WEED_JWT_FILER_SIGNING_KEY or security.toml) and log at info, so a table a handler skips says why in the harness output. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * s3tables lifecycle test: run the Lance worker against the cluster The worker leg of TestLanceTableLifecycle still handed the Rust tests S3 credentials and no masters. The mini cluster now has a cluster key, and the tests get WEED_MASTERS and that key; passes with WEED_LANCE_MAINTENANCE=worker (3 fragments became 1, 4 versions removed). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * direct storage: name no collection outside a filer group, as the gateway Without a filer group the gateway leaves the collection to the filer, so a filer.conf rule for the bucket's path picks it; naming the bucket overrode that rule, and the chunks landed where a bucket delete does not look. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * direct storage: a read reports the entry's content type A put now stores its ContentType as the entry's mime, but get_opts returned no attributes, so the type did not come back on a read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
538 lines
19 KiB
Rust
538 lines
19 KiB
Rust
//! Drives the compaction handler against a live namespace.
|
|
//!
|
|
//! Skipped unless WEED_LANCE_NAMESPACE names one, the way the Go integration
|
|
//! tests skip without Docker: compaction rewrites real files, and there is
|
|
//! nothing to learn from it against a fake.
|
|
|
|
use anyhow::Result;
|
|
use seaweed_worker_core::pb::{
|
|
ExecuteJobRequest, JobSpec, RunDetectionRequest, config_value::Kind,
|
|
};
|
|
use seaweed_worker_core::{JobHandler, PreviewProvider};
|
|
use weed_lance_worker::jobs::cleanup::CleanupVersionsHandler;
|
|
use weed_lance_worker::jobs::compact::{CompactHandler, JOB_TYPE};
|
|
use weed_lance_worker::jobs::indices::OptimizeIndicesHandler;
|
|
use weed_lance_worker::preview::LancePreview;
|
|
|
|
mod common;
|
|
use common::{Recorder, client, int_config, namespace_url, signing_key, storage};
|
|
|
|
/// These tests drive one live gateway and one shared catalog: `list_all_tables`
|
|
/// sweeps everything, so a table another test is writing shows up in this test's
|
|
/// detection. Rust runs a binary's tests concurrently, so take a lock.
|
|
static GATEWAY: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
|
|
|
/// Declares a table through the namespace and writes `fragments` one-row
|
|
/// appends into it, so a test brings its own state instead of depending on
|
|
/// whatever a previous run left behind.
|
|
async fn seed_fragmented_table(url: &str, name: &str, fragments: usize) -> Result<String> {
|
|
seed_table(url, name, fragments, 1, false).await
|
|
}
|
|
|
|
/// Writes `batches` appends of `rows_each` into a freshly declared table, and
|
|
/// optionally builds a vector index after the first batch so the later ones are
|
|
/// rows no index covers.
|
|
async fn seed_table(
|
|
url: &str,
|
|
name: &str,
|
|
batches: usize,
|
|
rows_each: usize,
|
|
with_index: bool,
|
|
) -> Result<String> {
|
|
use arrow_array::{
|
|
FixedSizeListArray, Float32Array, Int64Array, RecordBatch, RecordBatchIterator,
|
|
};
|
|
use arrow_schema::{DataType, Field, Schema};
|
|
use lance::dataset::{Dataset, WriteMode, WriteParams};
|
|
use std::sync::Arc;
|
|
|
|
// Declaring is the namespace's job, not the worker's, so the test asks for
|
|
// it directly rather than widening the client the worker uses. The bucket and
|
|
// namespace come first: a table cannot be declared under a parent that does
|
|
// not exist, and a test that assumes one is a test that only passes twice.
|
|
let http = reqwest::Client::new();
|
|
for parent in ["vec", "vec$ml"] {
|
|
http.post(format!("{url}/v1/namespace/{parent}/create"))
|
|
.json(&serde_json::json!({"mode": "EXIST_OK"}))
|
|
.send()
|
|
.await?
|
|
.error_for_status()?;
|
|
}
|
|
let encoded = format!("vec$ml${name}");
|
|
http.post(format!("{url}/v1/table/{encoded}/declare"))
|
|
.json(&serde_json::json!({}))
|
|
.send()
|
|
.await?
|
|
.error_for_status()?;
|
|
|
|
let client = client(url);
|
|
let id = vec!["vec".to_string(), "ml".to_string(), name.to_string()];
|
|
let description = client.describe_table(&id).await?;
|
|
|
|
let wrapper = storage().await.wrapper(&description.location).await?;
|
|
const DIM: i32 = 16;
|
|
let schema = Arc::new(Schema::new(vec![
|
|
Field::new("id", DataType::Int64, false),
|
|
Field::new(
|
|
"vec",
|
|
DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), DIM),
|
|
false,
|
|
),
|
|
]));
|
|
|
|
for i in 0..batches {
|
|
let ids: Vec<i64> = (0..rows_each).map(|r| (i * rows_each + r) as i64).collect();
|
|
let values: Vec<f32> = ids
|
|
.iter()
|
|
.flat_map(|id| (0..DIM).map(move |d| (*id as f32) + d as f32))
|
|
.collect();
|
|
let vectors = FixedSizeListArray::new(
|
|
Arc::new(Field::new("item", DataType::Float32, true)),
|
|
DIM,
|
|
Arc::new(Float32Array::from(values)),
|
|
None,
|
|
);
|
|
let batch = RecordBatch::try_new(
|
|
schema.clone(),
|
|
vec![Arc::new(Int64Array::from(ids)), Arc::new(vectors)],
|
|
)?;
|
|
let reader = RecordBatchIterator::new(vec![Ok(batch)], schema.clone());
|
|
let params = WriteParams {
|
|
mode: if i == 0 {
|
|
WriteMode::Overwrite
|
|
} else {
|
|
WriteMode::Append
|
|
},
|
|
store_params: Some(weed_lance_worker::dataset::store_params(wrapper.clone())),
|
|
..Default::default()
|
|
};
|
|
let dataset = Dataset::write(reader, description.location.as_str(), Some(params)).await?;
|
|
|
|
// The index is built after the first batch, so everything appended
|
|
// afterwards is a row it does not cover.
|
|
if with_index && i == 0 {
|
|
use lance::index::DatasetIndexExt;
|
|
use lance::index::vector::VectorIndexParams;
|
|
use lance_index::IndexType;
|
|
use lance_index::vector::{ivf::IvfBuildParams, pq::PQBuildParams};
|
|
|
|
let mut dataset = dataset;
|
|
let params = VectorIndexParams::with_ivf_pq_params(
|
|
lance_linalg::distance::MetricType::L2,
|
|
IvfBuildParams::new(1),
|
|
PQBuildParams::new(4, 8),
|
|
);
|
|
dataset
|
|
.create_index(&["vec"], IndexType::Vector, None, ¶ms, true)
|
|
.await?;
|
|
}
|
|
}
|
|
Ok(encoded)
|
|
}
|
|
|
|
/// A table with more fragments than the policy allows is proposed, and running
|
|
/// the proposal leaves it with fewer than it started with.
|
|
#[tokio::test]
|
|
async fn compacts_a_fragmented_table() {
|
|
let _gateway = GATEWAY.lock().await;
|
|
let Some(url) = namespace_url() else {
|
|
eprintln!("WEED_LANCE_NAMESPACE is unset, skipping");
|
|
return;
|
|
};
|
|
// Seeded here rather than by a script, so the test is repeatable: a previous
|
|
// run compacts the table it depended on.
|
|
let encoded = seed_fragmented_table(&url, "compactme", 12)
|
|
.await
|
|
.expect("seed a fragmented table");
|
|
let handler = CompactHandler::new(url)
|
|
.with_storage(storage().await)
|
|
.with_signing_key(signing_key());
|
|
let recorder = Recorder::default();
|
|
|
|
let request = RunDetectionRequest {
|
|
request_id: "detect-1".to_string(),
|
|
job_type: JOB_TYPE.to_string(),
|
|
worker_config_values: int_config("min_fragments", 4),
|
|
..Default::default()
|
|
};
|
|
handler
|
|
.detect(&request, &recorder)
|
|
.await
|
|
.expect("detection failed");
|
|
|
|
let proposals = recorder.proposals.lock().unwrap().clone();
|
|
assert!(
|
|
!proposals.is_empty(),
|
|
"expected a proposal for the fragmented table"
|
|
);
|
|
let proposal = proposals
|
|
.iter()
|
|
.find(|p| p.summary.contains(encoded.as_str()))
|
|
.expect("no proposal for the seeded table");
|
|
|
|
// Detection opened the dataset to decide, so it reports what it saw. This is
|
|
// the only description of a Lance table anything outside the format can give.
|
|
let observations = recorder.observations.lock().unwrap().clone();
|
|
let observed = observations
|
|
.iter()
|
|
.find(|o| o.object_id.last().map(String::as_str) == Some("compactme"))
|
|
.expect("detection reported no observation for the seeded table");
|
|
assert_eq!(observed.format, "LANCE");
|
|
for attribute in ["fragments", "rows", "versions", "schema"] {
|
|
assert!(
|
|
observed.attributes.contains_key(attribute),
|
|
"observation is missing {attribute}: {:?}",
|
|
observed.attributes.keys().collect::<Vec<_>>()
|
|
);
|
|
}
|
|
|
|
let execute = ExecuteJobRequest {
|
|
request_id: "execute-1".to_string(),
|
|
job: Some(JobSpec {
|
|
job_id: "job-1".to_string(),
|
|
job_type: JOB_TYPE.to_string(),
|
|
parameters: proposal.parameters.clone(),
|
|
..Default::default()
|
|
}),
|
|
worker_config_values: int_config("target_rows_per_fragment", 1_048_576),
|
|
..Default::default()
|
|
};
|
|
handler
|
|
.execute(&execute, &recorder)
|
|
.await
|
|
.expect("execution failed");
|
|
|
|
let completed = recorder.completed.lock().unwrap().clone();
|
|
let result = completed.first().expect("no completion reported");
|
|
assert!(
|
|
result.success,
|
|
"compaction reported failure: {}",
|
|
result.error_message
|
|
);
|
|
let summary = result
|
|
.result
|
|
.as_ref()
|
|
.map(|r| r.summary.clone())
|
|
.unwrap_or_default();
|
|
assert!(
|
|
summary.contains("became"),
|
|
"completion carried no fragment counts: {summary}"
|
|
);
|
|
eprintln!("compaction result: {summary}");
|
|
}
|
|
|
|
/// A table with more versions than the floor is proposed, and running the job
|
|
/// reports what it removed. The compaction test above leaves one behind.
|
|
#[tokio::test]
|
|
async fn cleans_up_old_versions() {
|
|
let _gateway = GATEWAY.lock().await;
|
|
let Some(url) = namespace_url() else {
|
|
eprintln!("WEED_LANCE_NAMESPACE is unset, skipping");
|
|
return;
|
|
};
|
|
let encoded = seed_table(&url, "cleanme", 6, 4, false)
|
|
.await
|
|
.expect("seed a table with versions to clean");
|
|
|
|
let handler = CleanupVersionsHandler::new(url)
|
|
.with_storage(storage().await)
|
|
.with_signing_key(signing_key());
|
|
let recorder = Recorder::default();
|
|
|
|
let request = RunDetectionRequest {
|
|
request_id: "detect-cleanup".to_string(),
|
|
job_type: "lance_cleanup_versions".to_string(),
|
|
worker_config_values: int_config("min_versions_to_keep", 2),
|
|
..Default::default()
|
|
};
|
|
handler
|
|
.detect(&request, &recorder)
|
|
.await
|
|
.expect("detection failed");
|
|
let proposals = recorder.proposals.lock().unwrap().clone();
|
|
let proposal = proposals
|
|
.iter()
|
|
.find(|p| p.summary.contains(encoded.as_str()))
|
|
.cloned()
|
|
.expect("no cleanup proposal for the seeded table");
|
|
|
|
// Retain nothing, so every version outside the current one is fair game and
|
|
// the job has something to report rather than a no-op.
|
|
let execute = ExecuteJobRequest {
|
|
request_id: "execute-cleanup".to_string(),
|
|
job: Some(JobSpec {
|
|
job_id: "job-cleanup".to_string(),
|
|
job_type: "lance_cleanup_versions".to_string(),
|
|
parameters: proposal.parameters.clone(),
|
|
..Default::default()
|
|
}),
|
|
worker_config_values: int_config("retain_hours", 0),
|
|
..Default::default()
|
|
};
|
|
handler
|
|
.execute(&execute, &recorder)
|
|
.await
|
|
.expect("cleanup failed");
|
|
|
|
let completed = recorder.completed.lock().unwrap().clone();
|
|
let result = completed.last().expect("no completion reported");
|
|
assert!(
|
|
result.success,
|
|
"cleanup reported failure: {}",
|
|
result.error_message
|
|
);
|
|
eprintln!(
|
|
"cleanup result: {}",
|
|
result.result.as_ref().unwrap().summary
|
|
);
|
|
}
|
|
|
|
/// A table with no indices has nothing to optimize, so detection proposes
|
|
/// nothing rather than queueing work that would do nothing.
|
|
#[tokio::test]
|
|
async fn skips_tables_without_indices() {
|
|
let _gateway = GATEWAY.lock().await;
|
|
let Some(url) = namespace_url() else {
|
|
eprintln!("WEED_LANCE_NAMESPACE is unset, skipping");
|
|
return;
|
|
};
|
|
let encoded = seed_table(&url, "noindex", 2, 8, false)
|
|
.await
|
|
.expect("seed a table without an index");
|
|
|
|
let handler = OptimizeIndicesHandler::new(url)
|
|
.with_storage(storage().await)
|
|
.with_signing_key(signing_key());
|
|
let recorder = Recorder::default();
|
|
|
|
let request = RunDetectionRequest {
|
|
request_id: "detect-indices".to_string(),
|
|
job_type: "lance_optimize_indices".to_string(),
|
|
worker_config_values: int_config("max_unindexed_rows", 1),
|
|
..Default::default()
|
|
};
|
|
handler
|
|
.detect(&request, &recorder)
|
|
.await
|
|
.expect("detection failed");
|
|
// Judge this table only: the catalog holds every other test's tables too,
|
|
// and an indexed one with uncovered rows is supposed to be proposed.
|
|
assert!(
|
|
!recorder
|
|
.proposals
|
|
.lock()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|p| p.summary.contains(encoded.as_str())),
|
|
"a table with no indices must not be proposed for reindexing"
|
|
);
|
|
}
|
|
|
|
/// The job with no Iceberg equivalent: rows appended after an index was built
|
|
/// are invisible to a search of it until this runs. Needs a table with an index
|
|
/// and rows outside it, which `indexed.py` in the scratchpad seeds.
|
|
#[tokio::test]
|
|
async fn reindexes_rows_an_index_does_not_cover() {
|
|
let _gateway = GATEWAY.lock().await;
|
|
let Some(url) = namespace_url() else {
|
|
eprintln!("WEED_LANCE_NAMESPACE is unset, skipping");
|
|
return;
|
|
};
|
|
let encoded = seed_table(&url, "reindexme", 2, 512, true)
|
|
.await
|
|
.expect("seed an indexed table with uncovered rows");
|
|
let handler = OptimizeIndicesHandler::new(url)
|
|
.with_storage(storage().await)
|
|
.with_signing_key(signing_key());
|
|
let recorder = Recorder::default();
|
|
|
|
let request = RunDetectionRequest {
|
|
request_id: "detect-reindex".to_string(),
|
|
job_type: "lance_optimize_indices".to_string(),
|
|
worker_config_values: int_config("max_unindexed_rows", 100),
|
|
..Default::default()
|
|
};
|
|
handler
|
|
.detect(&request, &recorder)
|
|
.await
|
|
.expect("detection failed");
|
|
let proposals = recorder.proposals.lock().unwrap().clone();
|
|
let proposal = proposals
|
|
.iter()
|
|
.find(|p| p.summary.contains(encoded.as_str()))
|
|
.cloned()
|
|
.expect("the seeded indexed table was not proposed");
|
|
|
|
let execute = ExecuteJobRequest {
|
|
request_id: "execute-reindex".to_string(),
|
|
job: Some(JobSpec {
|
|
job_id: "job-reindex".to_string(),
|
|
job_type: "lance_optimize_indices".to_string(),
|
|
parameters: proposal.parameters.clone(),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
handler
|
|
.execute(&execute, &recorder)
|
|
.await
|
|
.expect("reindex failed");
|
|
|
|
let completed = recorder.completed.lock().unwrap().clone();
|
|
let result = completed.last().expect("no completion reported");
|
|
assert!(
|
|
result.success,
|
|
"reindex reported failure: {}",
|
|
result.error_message
|
|
);
|
|
let output = &result.result.as_ref().unwrap().output_values;
|
|
let after = match output
|
|
.get("unindexed_rows_after")
|
|
.and_then(|v| v.kind.as_ref())
|
|
{
|
|
Some(Kind::Int64Value(value)) => *value,
|
|
other => panic!("no unindexed_rows_after in {other:?}"),
|
|
};
|
|
assert_eq!(
|
|
after, 0,
|
|
"rows are still outside the index after optimizing"
|
|
);
|
|
eprintln!(
|
|
"reindex result: {}",
|
|
result.result.as_ref().unwrap().summary
|
|
);
|
|
}
|
|
|
|
/// The UI's whole reason for asking a worker: admin cannot read a Lance table,
|
|
/// so the rows have to come back already rendered.
|
|
#[tokio::test]
|
|
async fn previews_rows_of_a_table() {
|
|
let _gateway = GATEWAY.lock().await;
|
|
let Some(url) = namespace_url() else {
|
|
eprintln!("set WEED_LANCE_NAMESPACE to run this test");
|
|
return;
|
|
};
|
|
seed_table(&url, "previewme", 2, 3, false)
|
|
.await
|
|
.expect("seed a table to preview");
|
|
|
|
let provider = LancePreview::new(url, storage().await);
|
|
let id = vec!["vec".to_string(), "ml".to_string(), "previewme".to_string()];
|
|
let preview = provider.preview(&id, 4).await.expect("preview the table");
|
|
|
|
assert_eq!(preview.columns, vec!["id".to_string(), "vec".to_string()]);
|
|
assert_eq!(preview.total_rows, 6, "total is the table, not the sample");
|
|
assert_eq!(preview.rows.len(), 4, "row_limit bounds the sample");
|
|
assert!(
|
|
preview.rows[0][1].starts_with('['),
|
|
"a vector column should render as a list, got {:?}",
|
|
preview.rows[0][1]
|
|
);
|
|
}
|
|
|
|
/// The claim that removed managed versioning is not "one writer wins the
|
|
/// conditional PUT" - that is only the mechanism. It is that concurrent writers
|
|
/// lose nothing: the loser sees the conflict, rebases, and commits again. Eight
|
|
/// writers appending at once must leave all eight batches in the table.
|
|
#[tokio::test]
|
|
async fn concurrent_writers_keep_every_commit() {
|
|
let _gateway = GATEWAY.lock().await;
|
|
let Some(url) = namespace_url() else {
|
|
eprintln!("set WEED_LANCE_NAMESPACE to run this test");
|
|
return;
|
|
};
|
|
const WRITERS: i64 = 8;
|
|
const ROWS_EACH: i64 = 4;
|
|
|
|
seed_table(&url, "racers", 1, ROWS_EACH as usize, false)
|
|
.await
|
|
.expect("seed the table the writers will append to");
|
|
|
|
let client = client(&url);
|
|
let id = vec!["vec".to_string(), "ml".to_string(), "racers".to_string()];
|
|
let description = client.describe_table(&id).await.expect("describe");
|
|
let wrapper = storage()
|
|
.await
|
|
.wrapper(&description.location)
|
|
.await
|
|
.expect("storage for the table");
|
|
|
|
let writes: Vec<_> = (0..WRITERS)
|
|
.map(|writer| {
|
|
let location = description.location.clone();
|
|
let wrapper = wrapper.clone();
|
|
tokio::spawn(
|
|
async move { append_rows(&location, wrapper, writer * 1000, ROWS_EACH).await },
|
|
)
|
|
})
|
|
.collect();
|
|
for (writer, handle) in writes.into_iter().enumerate() {
|
|
handle
|
|
.await
|
|
.expect("writer panicked")
|
|
.unwrap_or_else(|err| panic!("writer {writer} failed to commit: {err:#}"));
|
|
}
|
|
|
|
let table = weed_lance_worker::dataset::open(&client, &id, &storage().await)
|
|
.await
|
|
.expect("reopen the table");
|
|
let rows = table.dataset.count_rows(None).await.expect("count rows");
|
|
let expected = (ROWS_EACH + WRITERS * ROWS_EACH) as usize;
|
|
assert_eq!(
|
|
rows, expected,
|
|
"concurrent commits lost data: {rows} rows, want {expected}"
|
|
);
|
|
}
|
|
|
|
/// Appends one batch to an existing dataset, the way an independent writer would.
|
|
async fn append_rows(
|
|
location: &str,
|
|
wrapper: Option<std::sync::Arc<dyn lance::io::WrappingObjectStore>>,
|
|
first_id: i64,
|
|
rows: i64,
|
|
) -> Result<()> {
|
|
use arrow_array::{
|
|
FixedSizeListArray, Float32Array, Int64Array, RecordBatch, RecordBatchIterator,
|
|
};
|
|
use arrow_schema::{DataType, Field, Schema};
|
|
use lance::dataset::{Dataset, WriteMode, WriteParams};
|
|
use std::sync::Arc;
|
|
|
|
const DIM: i32 = 16;
|
|
let schema = Arc::new(Schema::new(vec![
|
|
Field::new("id", DataType::Int64, false),
|
|
Field::new(
|
|
"vec",
|
|
DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), DIM),
|
|
false,
|
|
),
|
|
]));
|
|
let ids: Vec<i64> = (0..rows).map(|r| first_id + r).collect();
|
|
let values: Vec<f32> = ids
|
|
.iter()
|
|
.flat_map(|id| (0..DIM).map(move |d| (*id as f32) + d as f32))
|
|
.collect();
|
|
let vectors = FixedSizeListArray::new(
|
|
Arc::new(Field::new("item", DataType::Float32, true)),
|
|
DIM,
|
|
Arc::new(Float32Array::from(values)),
|
|
None,
|
|
);
|
|
let batch = RecordBatch::try_new(
|
|
schema.clone(),
|
|
vec![Arc::new(Int64Array::from(ids)), Arc::new(vectors)],
|
|
)?;
|
|
let params = WriteParams {
|
|
mode: WriteMode::Append,
|
|
store_params: Some(weed_lance_worker::dataset::store_params(wrapper)),
|
|
..Default::default()
|
|
};
|
|
Dataset::write(
|
|
RecordBatchIterator::new(vec![Ok(batch)], schema.clone()),
|
|
location,
|
|
Some(params),
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|