mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-07 04:27:49 +01:00
* seaweed-vector: build and extend the vector index weed_lance_worker::vector_index builds an IVF_FLAT index in the metric the table was created with, sizes partitions from the row count, and extends an index with a new segment or a retrain; the core crate re-exports it and adds the probes a query spends. Tests cover coverage after build, append, retrain and compaction. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-vector-cli: index build and index optimize Logs go to stderr so stdout stays the JSON a caller parses. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * test/s3vectors: PERF_INDEX builds the index before the timed queries The cluster fixture hands out its catalog URL, the Makefile builds weed-vector-cli, and the perf suite builds the index after ingest and reports the build time and segment count. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * seaweed-vector: probe a tenth of the index's partitions, more under a filter A query that names no probe count gets one from the index's partition count. Lance searches at least that many partitions, and under a prefilter keeps going until it has k rows; without one it stops there. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-vector: --nprobes overrides the probe policy Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * seaweed-vector: test that a build takes the table's metric Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * test/s3vectors: make clean removes weed-vector-cli Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * seaweed-vector: measure recall with vectors the table does not hold Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-vector: keep the read cache's doc comment on with_read_cache Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * seaweed-vector: optimize only the indices on the vector column Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * seaweed-vector: resolve the query metric with index::distance_metric Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * seaweed-vector: test the L2 metric under the name CreateIndex stores Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * weed-worker: open a table inside a caller's Lance session Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-vector: keep opened indices between queries in one session Every open used a fresh Lance session, so the index and metadata caches died with the request and each query loaded the IVF index again. --index-cache-mb sizes the shared cache, 512 MiB by default. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * test/s3vectors: PERF_CLUSTERS draws the perf suite's vectors around shared centres Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * seaweed-vector: test that compaction drops the deleted key from tied hits Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * seaweed-vector: test that an append-only optimize serves the new row Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test/s3vectors: record PERF_NOISE in the perf report config Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * seaweed-vector: a filtered query widens to four times the probes, not without bound With no upper bound Lance prepares every partition for the late search even when the minimum probes already hold k rows, and a filtered query through the index cost as much as the exact scan. A query that names its own probe count gets exactly that count. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * test/s3vectors: report recall through an index without asserting it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test/s3vectors: count a hit tying the k-th best score toward recall Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test/s3vectors: allow float32 rounding at the recall cutoff Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test/s3vectors: count a repeated hit key once toward recall Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test/s3vectors: credit cutoff ties only up to the slots the top-k leaves Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test/s3vectors: assert every filtered hit passes the filter, indexed or not Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * weed-vector: forget the shared session when a table was dropped and created again The session keys index lists and transactions by dataset version, and a table created again at the same location restarts its versions. An open that finds an older version than the newest seen at that URI, or the same version committed later, replaces the session and opens again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * weed-vector: test an open after another engine created the table again Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * weed-vector: replace the session in the same lock that records a recreated table A second open of the recreated table could see the new version recorded and the old session still shared, and keep the dropped table's index list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * seaweed-vector: size default probes from the largest index on the column A sharded build gives the column one index name per shard. The first name alone could be a small shard, and its probe count would then be spent on a larger one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * seaweed-vector: skip the probe lookup when a query is given no segments Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-vector: open every table in its own Lance session again One session shared by every open keeps a table's index loaded between queries, but Lance keys what it caches by location and version, so a table dropped and created again at the same location could be served the old table's index list. Telling the two apart from version and commit time needs a map that only grows, a retry loop and clocks that agree across engines. A private session per open, as before, has no such state. Measured at 50k x 384 through an index, eight clients: 164 queries/s with the shared session, 137 without; one client is unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-vector: reject --nprobes 0 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * seaweed-vector: read the partition count from the opened index Lance's index statistics took 3.6 ms a query on a 200k-row table with 447 partitions. Opening the index and asking it takes 0.4 ms, and the scan that follows finds the index already open. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
212 lines
8.5 KiB
Rust
212 lines
8.5 KiB
Rust
use std::path::PathBuf;
|
|
|
|
use anyhow::{Context, Result};
|
|
use clap::Parser;
|
|
use tokio_stream::wrappers::TcpListenerStream;
|
|
use tonic::transport::Server;
|
|
use weed_lance_worker::catalog::{NamespaceClient, SigningKey};
|
|
use weed_lance_worker::dataset::FallbackOptions;
|
|
use weed_vector_server::pb::vector::vector_service_server::VectorServiceServer;
|
|
use weed_vector_server::read_cache::ReadCache;
|
|
use weed_vector_server::security::Security;
|
|
use weed_vector_server::service::VectorServer;
|
|
use weed_vector_server::tls::{self, ServerTls};
|
|
|
|
/// The VectorService the S3 gateway fans vector calls out to.
|
|
#[derive(Parser)]
|
|
struct Args {
|
|
/// Lance Namespace base URL: the S3 gateway's Lance port (-s3.port.lance).
|
|
#[arg(
|
|
long,
|
|
env = "WEED_VECTOR_NAMESPACE",
|
|
default_value = "http://localhost:9101"
|
|
)]
|
|
namespace: String,
|
|
/// gRPC listen address.
|
|
#[arg(long, env = "WEED_VECTOR_LISTEN", default_value = "127.0.0.1:8090")]
|
|
listen: String,
|
|
/// object_store option fallback for deployments that vend no credentials:
|
|
/// key=value, repeatable.
|
|
#[arg(long = "storage-option", alias = "storage.option", value_parser = parse_option)]
|
|
storage_options: Vec<(String, String)>,
|
|
/// Comma-separated master addresses to register this server with; the
|
|
/// gateway discovers it via ListClusterNodes(client_type=vector).
|
|
#[arg(long, alias = "master", env = "WEED_VECTOR_MASTERS")]
|
|
masters: Option<String>,
|
|
/// Memory for caching reads of write-once table files, in MiB; 0 disables.
|
|
#[arg(long, env = "WEED_VECTOR_READ_CACHE_MB", default_value_t = 512)]
|
|
read_cache_mb: u64,
|
|
/// Partitions each query probes in a table's vector index. Default: a
|
|
/// tenth of the index's partitions, at least 8; more under a filter.
|
|
#[arg(long, env = "WEED_VECTOR_NPROBES")]
|
|
nprobes: Option<std::num::NonZeroUsize>,
|
|
/// The `jwt.filer_signing.key` of the cluster. The Lance namespace vends
|
|
/// credentials that write index data only to a caller holding it.
|
|
#[arg(long, env = "WEED_FILER_SIGNING_KEY")]
|
|
filer_signing_key: Option<String>,
|
|
/// The cluster's security.toml, for gRPC TLS: this server's identity is
|
|
/// [grpc.vector], the masters are dialed with [grpc.client]. By default
|
|
/// the file weed processes find in -config_dir, ., ~/.seaweedfs and
|
|
/// /etc/seaweedfs.
|
|
#[arg(long, env = "WEED_VECTOR_SECURITY_TOML")]
|
|
security_toml: Option<PathBuf>,
|
|
/// The directory weed's -config_dir names, searched for security.toml
|
|
/// first.
|
|
#[arg(long, alias = "config_dir", env = "WEED_VECTOR_CONFIG_DIR")]
|
|
config_dir: Option<PathBuf>,
|
|
}
|
|
|
|
fn parse_option(s: &str) -> Result<(String, String), String> {
|
|
s.split_once('=')
|
|
.map(|(k, v)| (k.to_string(), v.to_string()))
|
|
.ok_or_else(|| "expected key=value".to_string())
|
|
}
|
|
|
|
// weed tools take Go-style single-dash long flags; normalize_args converts
|
|
// any -flag to --flag — the same normalization weed-volume's config.rs does —
|
|
// so -master and --masters parse the same. The masters field carries the
|
|
// singular alias clap-side.
|
|
fn normalize_args(args: Vec<String>) -> Vec<String> {
|
|
let mut args = args;
|
|
let mut i = 1; // skip args[0] (binary name)
|
|
while i < args.len() {
|
|
let arg = &args[i];
|
|
if arg == "--" {
|
|
break;
|
|
}
|
|
if arg.starts_with("--") || !arg.starts_with('-') {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
let without_dash = &arg[1..];
|
|
if without_dash.len() > 1 && !without_dash.starts_with(|c: char| c.is_ascii_digit()) {
|
|
args[i] = format!("-{}", arg);
|
|
}
|
|
i += 1;
|
|
}
|
|
args
|
|
}
|
|
|
|
/// A wildcard listen address is not reachable for peers; derive the outbound
|
|
/// IP toward the first master instead — the address the master sees anyway.
|
|
fn advertised_addr(listen: &str, masters: &[String]) -> String {
|
|
let Some((host, _)) = listen.rsplit_once(':') else {
|
|
return listen.to_string();
|
|
};
|
|
if !matches!(host, "0.0.0.0" | "::" | "" | "[::]") {
|
|
return listen.to_string();
|
|
}
|
|
let port = listen.rsplit_once(':').map(|(_, p)| p).unwrap_or_default();
|
|
if let Some(master) = masters.first() {
|
|
// Masters may arrive as host:http.grpc or scheme://host:http —
|
|
// the UDP probe only needs a dialable host:port.
|
|
let probe = master.rsplit_once("://").map(|(_, r)| r).unwrap_or(master);
|
|
let probe = match probe.rsplit_once(':') {
|
|
Some((h, p)) => match p.split_once('.') {
|
|
Some((_, g)) => format!("{h}:{g}"),
|
|
None => probe.to_string(),
|
|
},
|
|
None => probe.to_string(),
|
|
};
|
|
if let Ok(sock) = std::net::UdpSocket::bind((host, 0u16)) {
|
|
if sock.connect(&probe).is_ok() {
|
|
if let Ok(local) = sock.local_addr() {
|
|
return format!("{}:{}", local.ip(), port);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
listen.to_string()
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<()> {
|
|
tracing_subscriber::fmt::init();
|
|
let args = Args::parse_from(normalize_args(std::env::args().collect()));
|
|
weed_lance_worker::tls::install_default_crypto_provider();
|
|
let security = Security::load(args.security_toml.as_deref(), args.config_dir.as_deref())?;
|
|
if let Some(path) = security.path() {
|
|
tracing::info!("security settings from {}", path.display());
|
|
}
|
|
let master_tls = match &args.masters {
|
|
Some(_) => security.grpc_client_tls("grpc.client", "grpc.master")?,
|
|
None => None,
|
|
};
|
|
let server_tls = security
|
|
.grpc_server_tls("grpc.vector")?
|
|
.map(ServerTls::load)
|
|
.transpose()?;
|
|
// Bind before registering, so the master never lists an engine that is
|
|
// about to exit because its port is taken.
|
|
let listener = tokio::net::TcpListener::bind(&args.listen)
|
|
.await
|
|
.with_context(|| format!("listen on {}", args.listen))?;
|
|
if let Some(masters) = &args.masters {
|
|
let masters: Vec<String> = masters.split(',').map(|s| s.trim().to_string()).collect();
|
|
for master in &masters {
|
|
weed_vector_server::master_client::grpc_addr(master)?;
|
|
}
|
|
let advertise = advertised_addr(&args.listen, &masters);
|
|
tokio::spawn(weed_vector_server::master_client::keep_connected(
|
|
masters,
|
|
advertise,
|
|
master_tls.clone(),
|
|
));
|
|
}
|
|
let fallback: FallbackOptions = args.storage_options.into_iter().collect();
|
|
let signing_key = SigningKey::new(args.filer_signing_key);
|
|
let client = NamespaceClient::new(args.namespace.clone())
|
|
.with_fallback(&fallback)
|
|
.with_signing_key(signing_key.as_ref());
|
|
let mut service = VectorServer::new(client, fallback)
|
|
.with_nprobes(args.nprobes.map(std::num::NonZeroUsize::get));
|
|
if args.read_cache_mb > 0 {
|
|
service = service.with_read_cache(ReadCache::new(args.read_cache_mb << 20));
|
|
}
|
|
tracing::info!(
|
|
namespace = %args.namespace,
|
|
listen = %args.listen,
|
|
"weed-vector; serving gRPC {}, dialing masters {}",
|
|
match &server_tls {
|
|
Some(tls) if tls.files().allowed_common_names.is_empty()
|
|
&& tls.files().allowed_wildcard_domain.is_empty() =>
|
|
"over TLS with client certificates required",
|
|
Some(_) => "over TLS with allowed client certificates required",
|
|
None => "in plaintext",
|
|
},
|
|
if master_tls.is_some() { "over TLS" } else { "in plaintext" },
|
|
);
|
|
let router = Server::builder()
|
|
// Match the gateway's 1 GiB cap — put batches over the tonic 4 MiB
|
|
// default otherwise get rejected before they reach the handler.
|
|
.add_service(
|
|
VectorServiceServer::new(service)
|
|
.max_decoding_message_size(1 << 30)
|
|
.max_encoding_message_size(1 << 30),
|
|
);
|
|
match server_tls {
|
|
Some(tls) => {
|
|
tokio::spawn(tls.clone().refresh(tls::refresh_interval()));
|
|
router.serve_with_incoming(tls.incoming(listener)).await?;
|
|
}
|
|
None => {
|
|
router
|
|
.serve_with_incoming(TcpListenerStream::new(listener))
|
|
.await?
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn nprobes_must_be_positive() {
|
|
assert!(Args::try_parse_from(["weed-vector", "--nprobes", "0"]).is_err());
|
|
let args = Args::try_parse_from(["weed-vector", "--nprobes", "12"]).unwrap();
|
|
assert_eq!(args.nprobes.map(std::num::NonZeroUsize::get), Some(12));
|
|
}
|
|
}
|