mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-08 15:41:15 +02:00
* lance: a maintenance job that sorts a table by its declared fields Lance appends fragments in write order and has no notion of a sorted table, so nothing but a rewrite establishes one, and nothing but another rewrite restores it once rows have been appended. lance_sort reads the order from the dataset's own configuration, falls back to the worker's, and rewrites the table in it. The spec and the marker live in crates/sort rather than in the job, because weed/worker/tasks/iceberg sorts too: two jobs that disagreed about what "id desc nulls-first" means would be two features wearing one name. The sort spills. lance builds its DataFusion runtime with a FairSpillPool and a disk manager, but only when LanceExecutionOptions::use_spilling is set, and that struct derives Default over a plain bool — so Scanner::try_into_stream, which fills its options with ..Default::default(), is precisely the path that does not spill. The job builds the plan with create_plan and executes it with spilling on and the operator's memory budget. The marker rides in the same commit as the data: Operation::Overwrite is the one operation carrying config values alongside fragments, so a sorted table and the record of its sorting cannot disagree. It records the version the sort read, not the one it wrote, which is not knowable while the marker is being assembled. Detection treats anything committed after the sort's own commit as data the sort did not produce — row counts alone cannot see a rewrite that leaves the count where it was, and such a table would look sorted forever. Claude-Session: https://claude.ai/code/session_015SZkLTUvd1svDu4xdr6Q3y * lance: identify a sorted table by the files it wrote, not its version Review found three ways the version-based marker misjudges a table, and they share a cause: the version a sort produces is not knowable while the marker is being assembled, so the marker recorded the version it read and detection inferred the rest. A commit that rebases past a conflict lands on a different number, and the inference then reads a rewrite into an ordinary table — a full re-sort, and its indices, for nothing. Data file names do not have that problem. They are chosen before the commit, so the commit can carry them, and they do not change with the version it lands on. The marker now records how many files the sort wrote and a digest of their names, and detection asks whether the table still holds them: the same files means untouched, the same files followed by more means appended, anything else means the data was replaced. That also closes the hole the row threshold left. A replacement that grew the table by fewer rows than min_unsorted_rows read as sorted, however many rows had actually moved; the threshold now applies only where the sorted files are still in place, which is what it was for. A marker without a row count is stale rather than a zero to compare against, and deletes stop forcing a re-sort — they write a deletion file beside the data rather than rewriting it, and removing rows does not unsort the ones that remain. Sort fields are also compared exactly rather than case-folded. Arrow schemas are case-sensitive, so `id` and `ID` are two columns, and folding them together rejected a valid order. Claude-Session: https://claude.ai/code/session_015SZkLTUvd1svDu4xdr6Q3y * lance: count the rows appended after a sort, not the table's net growth Review found that rows deleted from the sorted fragments hide appended rows one for one: the threshold compared the live row count against the count recorded at sort time, so 800 deletions and 300 appends read as a table that shrank, and a table where deletions keep pace with appends stays "sorted" with an unsorted tail forever. The fragments say it directly. The marker already records how many fragments the sort wrote, so the ones after that prefix are exactly what arrived since, and the manifest carries each fragment's live row count — physical rows less its deletions. Counting those is the arithmetic the threshold was always meant to do, and it needs no row count from the marker at all. A fragment whose length the manifest does not record cannot be counted, and a table that cannot be judged is one to sort rather than one to leave alone forever, so an uncountable appended fragment reads as stale. Claude-Session: https://claude.ai/code/session_015SZkLTUvd1svDu4xdr6Q3y
119 lines
4.0 KiB
Rust
119 lines
4.0 KiB
Rust
//! One module per job type. Each declares its capability and the settings form
|
|
//! admin renders for it, then does the work.
|
|
//!
|
|
//! Detection opens each table and reads its manifest rather than its data, so a
|
|
//! sweep across a catalog stays cheap. Execution re-resolves the table instead
|
|
//! of trusting what detection saw: it may have been repointed, and the vended
|
|
//! credentials expire.
|
|
|
|
pub mod cleanup;
|
|
pub mod compact;
|
|
pub mod indices;
|
|
pub mod sort;
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
|
|
use seaweed_worker_core::pb::{config_value::Kind, ConfigValue, ObjectObservation, StringList};
|
|
use seaweed_worker_core::JobHandler;
|
|
|
|
use crate::catalog::parse_id;
|
|
|
|
/// A table identifier travels in a proposal's parameters and comes back on the
|
|
/// job, so both sides agree on one encoding.
|
|
pub(crate) fn string_list(parts: &[String]) -> ConfigValue {
|
|
ConfigValue {
|
|
kind: Some(Kind::StringList(StringList {
|
|
values: parts.to_vec(),
|
|
})),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn table_id(parameters: &HashMap<String, ConfigValue>) -> Option<Vec<String>> {
|
|
match parameters.get("table_id")?.kind.as_ref()? {
|
|
Kind::StringList(list) => Some(list.values.clone()),
|
|
Kind::StringValue(encoded) => Some(parse_id(encoded)),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Every handler this worker serves. A worker process may serve several job
|
|
/// types, which is why WorkerHello carries a list.
|
|
pub fn handlers(
|
|
namespace_url: String,
|
|
fallback: crate::dataset::FallbackOptions,
|
|
metrics: Option<crate::metrics::LanceMetrics>,
|
|
) -> Vec<Arc<dyn JobHandler>> {
|
|
vec![
|
|
Arc::new(
|
|
compact::CompactHandler::new(namespace_url.clone())
|
|
.with_fallback(fallback.clone())
|
|
.with_metrics(metrics.clone()),
|
|
),
|
|
Arc::new(
|
|
indices::OptimizeIndicesHandler::new(namespace_url.clone())
|
|
.with_fallback(fallback.clone())
|
|
.with_metrics(metrics.clone()),
|
|
),
|
|
Arc::new(
|
|
sort::SortHandler::new(namespace_url.clone())
|
|
.with_fallback(fallback.clone())
|
|
.with_metrics(metrics.clone()),
|
|
),
|
|
Arc::new(
|
|
cleanup::CleanupVersionsHandler::new(namespace_url)
|
|
.with_fallback(fallback)
|
|
.with_metrics(metrics),
|
|
),
|
|
]
|
|
}
|
|
|
|
/// Holds a configured value to the range its form offers. A value from outside
|
|
/// it is one the UI could not have produced, and every one of these is cast to
|
|
/// an unsigned type: a negative arrives as an enormous number, which silently
|
|
/// turns a threshold into "never" rather than failing loudly.
|
|
pub(crate) fn clamp(value: i64, low: i64, high: i64) -> i64 {
|
|
value.max(low).min(high)
|
|
}
|
|
|
|
/// The format the catalog records for the tables this worker maintains.
|
|
pub const FORMAT: &str = "LANCE";
|
|
|
|
/// Builds the observation a detection sweep reports for one table. Detection
|
|
/// has already opened the dataset to decide whether it needs work, so saying
|
|
/// what it saw costs nothing, and for a format the cluster cannot read this is
|
|
/// the only description of the table anything can produce.
|
|
pub(crate) fn observation(
|
|
id: &[String],
|
|
format: &str,
|
|
attributes: HashMap<String, ConfigValue>,
|
|
) -> ObjectObservation {
|
|
ObjectObservation {
|
|
object_id: id.to_vec(),
|
|
object_kind: "table".to_string(),
|
|
format: format.to_string(),
|
|
attributes,
|
|
observed_at: Some(std::time::SystemTime::now().into()),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
// Every configured threshold is cast to an unsigned type before use. A
|
|
// negative one would arrive as an enormous number and quietly mean "never",
|
|
// which looks exactly like a worker with nothing to do.
|
|
#[test]
|
|
fn clamp_keeps_a_negative_from_wrapping() {
|
|
assert_eq!(clamp(-1, 2, 4096) as usize, 2);
|
|
assert_eq!(clamp(i64::MIN, 0, 100_000_000) as u64, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn clamp_holds_the_ceiling_and_passes_the_middle() {
|
|
assert_eq!(clamp(i64::MAX, 0, 8760), 8760);
|
|
assert_eq!(clamp(168, 0, 8760), 168);
|
|
}
|
|
}
|