Skip to main content

haematite_cli/serve/
cli.rs

1//! Explicit operator startup. Reading configuration never installs a service.
2
3use std::path::PathBuf;
4use std::sync::Arc;
5use std::time::Duration;
6
7use haematite::store::NodeCacheBudget;
8
9use super::{
10    embed,
11    query::{Setting, probe},
12    service::Service,
13    storage::{Storage, UndatedGeneration},
14    transport::{Limits, Listener},
15};
16
17/// Checks ownership of a resolved default name endpoint without changing it.
18///
19/// The expected uid is supplied by the caller's validated OS runtime context.
20/// A successful probe lock is released before returning stopped; pids and
21/// socket connection results do not participate in this observation.
22///
23/// # Errors
24/// Returns `name_owner_probe_failed` with the anchor's cause when its location,
25/// owner, mode, identity or OS lock cannot be checked or released.
26pub fn name_is_live(default_endpoint: &std::path::Path, expected_uid: u32) -> Result<bool, String> {
27    super::socket_owner::name_is_live(default_endpoint, expected_uid).map_err(|error| {
28        format!(
29            "name_owner_probe_failed: {}: {error}",
30            default_endpoint.display()
31        )
32    })
33}
34
35const USAGE: &str = "usage: haematite serve --config <file.json> [--create] [--long-wait-interval <duration>] [--exit-on-stdin-close]\n\
36--create requires a new database directory; omit it when restarting.\n\
37An explicit socket's parent directory must already exist with mode 0700.\n\
38An omitted socket uses the private OS runtime namespace. No auto-launch is installed.";
39
40const DEFAULT_LONG_WAIT_INTERVAL: Duration = Duration::from_secs(30);
41
42struct ServeOptions {
43    path: PathBuf,
44    create: bool,
45    exit_on_stdin_close: bool,
46    long_wait_interval: Duration,
47}
48
49fn long_wait_help() -> String {
50    format!(
51        "--long-wait-interval <duration> is the interval between long-wait warnings, not a deadline. \
52         default: {}s; use an integer followed by ns, us, ms, s, m or h.",
53        DEFAULT_LONG_WAIT_INTERVAL.as_secs()
54    )
55}
56
57#[path = "cli_config.rs"]
58mod config;
59#[cfg(test)]
60use config::lys_gate;
61pub use config::{Config, Creation, LysConfig};
62use config::{creation_config, lys_parts, served_through_lys};
63
64fn claim_name(
65    config: &Config,
66    endpoint: &super::runtime::Endpoint,
67    create: bool,
68) -> Result<std::fs::File, String> {
69    let name_endpoint = if config.socket.is_some() {
70        #[cfg(test)]
71        let resolved = match endpoint_tests::input() {
72            Some((root, uid)) => super::runtime::resolve_input(
73                &config.data_dir,
74                Some(&endpoint.name),
75                None,
76                create,
77                &root,
78                uid,
79            ),
80            None => super::runtime::resolve(&config.data_dir, Some(&endpoint.name), None, create),
81        };
82        #[cfg(not(test))]
83        let resolved =
84            super::runtime::resolve(&config.data_dir, Some(&endpoint.name), None, create);
85        Some(resolved.map_err(|error| format!("bind service socket: {error}"))?)
86    } else {
87        None
88    };
89    let name_socket = name_endpoint
90        .as_ref()
91        .map_or(&endpoint.socket, |named| &named.socket);
92    let namespace = name_socket
93        .parent()
94        .ok_or("bind service socket: name endpoint needs a parent")?;
95    let name_uid = {
96        use std::os::unix::fs::MetadataExt;
97        std::fs::symlink_metadata(namespace)
98            .map_err(|error| format!("bind service socket: {error}"))?
99            .uid()
100    };
101    super::socket_owner::acquire_name(name_socket, name_uid).map_err(|error| {
102        // The name lock is held: another running serve registered a store of
103        // this name. Every other cause keeps the OS's own words.
104        if error.kind() == std::io::ErrorKind::WouldBlock {
105            format!(
106                "bind service socket: the store name \"{}\" is already served by another running haematite serve ({}); give this data folder another name",
107                endpoint.name,
108                name_socket.display()
109            )
110        } else {
111            format!("bind service socket: {error}")
112        }
113    })
114}
115
116/// The data directory and socket are absolute, the timeout fits the
117/// monotonic clock, and the socket's parent is one this user may bind in;
118/// the timeout is answered.
119fn check_paths_and_timeout(config: &Config) -> Result<Duration, String> {
120    if !config.data_dir.is_absolute()
121        || config
122            .socket
123            .as_ref()
124            .is_some_and(|socket| !socket.is_absolute())
125    {
126        return Err("data_dir and socket must be absolute paths".to_owned());
127    }
128    let timeout = Duration::from_millis(config.timeout_ms.get());
129    if std::time::Instant::now().checked_add(timeout).is_none() {
130        return Err("timeout cannot be represented by the monotonic clock".to_owned());
131    }
132    if let Some(socket) = config.socket.as_ref() {
133        super::socket_owner::check_parent(socket)
134            .map_err(|error| format!("bind service socket: {error}"))?;
135    }
136    Ok(timeout)
137}
138
139/// # Errors
140///
141/// Propagates any error from `parse`.
142pub fn run(args: impl Iterator<Item = String>) -> Result<(), String> {
143    let Some(options) = parse(args)? else {
144        println!("{USAGE}\n{}", long_wait_help());
145        return Ok(());
146    };
147    let config = read_config(&options.path)?;
148    // Resolve the complete group before opening a store or binding a socket.
149    let retention_limits = config.append_prune_limits()?;
150    let query_settings = config.query_settings()?;
151    let changes_settings = config.changes_settings()?;
152    let release = config.retention.settings()?;
153    // Read now, so a wrong key stops the service.
154    let embedding = config.embed_settings()?;
155    let (gate, placer) = lys_parts(config.lys.as_ref())?;
156    let restart = embed::settings::Restart::of(
157        config.embed_restart_ms,
158        config.embed_restart_most_ms,
159        embedding
160            .as_ref()
161            .map_or(Duration::ZERO, |settings| settings.ready),
162    )?;
163    let timeout = check_paths_and_timeout(&config)?;
164    let endpoint = config
165        .endpoint(options.create)
166        .map_err(|error| error.to_string())?;
167    let name_ownership = claim_name(&config, &endpoint, options.create)?;
168    // A worker that cannot start does not stop the service: every other verb
169    // is served, and the refusal is kept for `query.run` to answer by name.
170    let query = query_settings.map(|settings| probe::prepare(settings, config.max_frame));
171    if let Some(Err(refusal)) = query.as_ref().map(probe::Prepared::ready) {
172        eprintln!("haem/1 query unavailable: {refusal}");
173    }
174    // Only the leaf-log mark is refused here; a missing or unreadable config
175    // is left to the storage checks below, which answer as they always have.
176    if let Err(error @ haematite::DatabaseError::LeafLogStore { .. }) =
177        haematite::Database::check_ordinary_store(&config.data_dir)
178    {
179        return Err(error.to_string());
180    }
181    let branch_cache = NodeCacheBudget::Bytes(config.branch_cache_bytes_per_shard);
182    let kept = config.kept_limit()?;
183    let storage = if options.create {
184        let creation = config
185            .create
186            .ok_or("--create needs a create section in the configuration")?;
187        Storage::create_kept(
188            creation_config(config.data_dir, &creation),
189            branch_cache,
190            kept,
191            options.long_wait_interval,
192        )
193    } else {
194        Storage::open_kept(
195            &config.data_dir,
196            branch_cache,
197            kept,
198            options.long_wait_interval,
199        )
200    }
201    .map_err(|error| error.to_string())?;
202    if options.create {
203        endpoint
204            .check_created()
205            .map_err(|error| error.to_string())?;
206    }
207    let mut service =
208        Service::with_query(storage, config.max_page, query).map_err(|error| error.to_string())?;
209    service.changes = changes_settings;
210    service.append_prune_limits = retention_limits;
211    service = served_through_lys(service, gate, placer)?;
212    service
213        .start_release(release)
214        .map_err(|error| error.to_string())?;
215    // The worker starts on the embedding thread, off the serving path: until
216    // it answers, text search is refused as `embed_worker_starting`.
217    if let Some(settings) = embedding {
218        let bounds = super::embedding::bounds(&settings);
219        let writer_wait = settings.writer_wait;
220        let apart = !matches!(config.embed_question_worker, Setting::Present(false));
221        let questions = apart.then(|| super::embedding::embedder(settings.clone()));
222        service
223            .start_embedding_apart(
224                super::embedding::embedder(settings),
225                questions,
226                bounds,
227                writer_wait,
228                restart,
229            )
230            .map_err(|error| error.to_string())?;
231    }
232    let service = Arc::new(service);
233    service
234        .activate_reclaim()
235        .map_err(|error| format!("reclaim activation: {error}"))?;
236    let served = serve_listener(
237        &endpoint,
238        &service,
239        Limits {
240            max_frame: config.max_frame,
241            max_clients: config.max_clients,
242            frame_timeout: timeout,
243        },
244        options.exit_on_stdin_close,
245    );
246    drop(name_ownership);
247    served
248}
249
250fn serve_listener(
251    endpoint: &super::runtime::Endpoint,
252    service: &Arc<Service>,
253    limits: Limits,
254    exit_on_stdin_close: bool,
255) -> Result<(), String> {
256    let mut listener = Listener::bind(&endpoint.socket)
257        .map_err(|error| format!("bind service socket: {error}"))?;
258    listener
259        .publish(endpoint)
260        .map_err(|error| error.to_string())?;
261    let watch = exit_on_stdin_close
262        .then(super::stdin_close::Watch::start)
263        .transpose()
264        .map_err(|error| format!("stdin_watch_failed: {error}"))?;
265    eprintln!("haem/1 ready on {}", endpoint.socket.display());
266    let served = watch
267        .map_or_else(
268            || listener.run(service, limits),
269            |watch| listener.run_until(service, limits, watch),
270        )
271        .map_err(|error| format!("serve: {error}"));
272    let cleanup = listener.finish().map_err(|error| error.to_string());
273    // The name remains owned while registry and socket cleanup still run.
274    drop(listener);
275    match (served, cleanup) {
276        (Ok(()), Ok(())) => Ok(()),
277        (Err(error), Ok(())) | (Ok(()), Err(error)) => Err(error),
278        (Err(served), Err(cleanup)) => Err(format!("{served}; {cleanup}")),
279    }
280}
281
282const RELEASE_USAGE: &str = "usage: haematite release date --config <file.json> --branch <name> \
283--generation <created> --at <nanoseconds> [--long-wait-interval <duration>]\n\
284Dates a generation discarded before its marker was written, as discarded at --at, so release \
285takes its rows by keep.days from then. --generation is the record's creation time its rows are \
286keyed by. Offline: the service must be stopped, as the store has one writer.";
287
288struct DateOptions {
289    branch: String,
290    generation: u64,
291    at: u64,
292    long_wait_interval: Duration,
293}
294
295#[path = "cli/branch_rename.rs"]
296mod branch_rename;
297pub use branch_rename::{BRANCH_RENAME_USAGE, branch_rename};
298
299/// DR-12: `haematite release date`, which dates a generation discarded before DR-1.
300///
301/// The operator's way out ([`Storage::date_undated_generation`], DR-11).
302/// Offline: it opens the store as its one writer, so a running service holds
303/// it off. Prints the marker's last version, `generation.sequence`.
304///
305/// # Errors
306/// A usage error, the configuration's, the store's open refusal, or the
307/// request's refusal by name (`branch_live`, `generation_marked`,
308/// `generation_empty`).
309pub fn release(mut args: impl Iterator<Item = String>) -> Result<(), String> {
310    if args.next().as_deref() != Some("date") {
311        return Err(RELEASE_USAGE.to_owned());
312    }
313    let mut args = args.peekable();
314    if args.peek().is_some_and(|arg| arg == "--help") {
315        if args.count() != 1 {
316            return Err(RELEASE_USAGE.to_owned());
317        }
318        println!("{RELEASE_USAGE}\n{}", long_wait_help());
319        return Ok(());
320    }
321    let (path, options) = parse_date(args)?;
322    let config = read_config(&path)?;
323    let branch_cache = NodeCacheBudget::Bytes(config.branch_cache_bytes_per_shard);
324    let mut storage = Storage::open_kept(
325        &config.data_dir,
326        branch_cache,
327        config.kept_limit()?,
328        options.long_wait_interval,
329    )
330    .map_err(|error| error.to_string())?;
331    let request = UndatedGeneration {
332        branch: options.branch,
333        generation: options.generation,
334        at: options.at,
335    };
336    let last = storage
337        .date_undated_generation(&request)
338        .map_err(|failure| failure.to_string())?;
339    println!("{}.{}", last.generation, last.sequence);
340    Ok(())
341}
342
343/// The release request and its optional warning interval, each argument once;
344/// the two record times are decimal nanoseconds.
345fn parse_date(mut args: impl Iterator<Item = String>) -> Result<(PathBuf, DateOptions), String> {
346    let (mut path, mut branch, mut generation, mut at) = (None, None, None, None);
347    let mut long_wait_interval = None;
348    while let Some(arg) = args.next() {
349        let value = args.next().ok_or(RELEASE_USAGE)?;
350        match arg.as_str() {
351            "--config" if path.is_none() => path = Some(PathBuf::from(value)),
352            "--branch" if branch.is_none() => branch = Some(value),
353            "--generation" if generation.is_none() => generation = Some(decimal(&arg, &value)?),
354            "--at" if at.is_none() => at = Some(decimal(&arg, &value)?),
355            "--long-wait-interval" if long_wait_interval.is_none() => {
356                long_wait_interval = Some(parse_long_wait_interval(&value)?);
357            }
358            _ => {
359                return Err(format!(
360                    "unknown or repeated argument {arg:?}\n{RELEASE_USAGE}"
361                ));
362            }
363        }
364    }
365    let request = DateOptions {
366        long_wait_interval: long_wait_interval.unwrap_or(DEFAULT_LONG_WAIT_INTERVAL),
367        branch: branch.ok_or(RELEASE_USAGE)?,
368        generation: generation.ok_or(RELEASE_USAGE)?,
369        at: at.ok_or(RELEASE_USAGE)?,
370    };
371    Ok((path.ok_or(RELEASE_USAGE)?, request))
372}
373
374fn decimal(arg: &str, value: &str) -> Result<u64, String> {
375    value
376        .parse()
377        .map_err(|error| format!("{arg} {value:?}: {error}\n{RELEASE_USAGE}"))
378}
379
380fn read_config(path: &std::path::Path) -> Result<Config, String> {
381    let bytes = std::fs::read(path).map_err(|error| format!("read service config: {error}"))?;
382    serde_json::from_slice(&bytes).map_err(|error| format!("service config: {error}"))
383}
384
385fn parse_long_wait_interval(value: &str) -> Result<Duration, String> {
386    let invalid = || {
387        format!(
388            "invalid --long-wait-interval {value:?}: {}",
389            long_wait_help()
390        )
391    };
392    let end = value
393        .find(|character: char| !character.is_ascii_digit())
394        .ok_or_else(invalid)?;
395    let (number, unit) = value.split_at(end);
396    let amount: u64 = number.parse().map_err(|_| invalid())?;
397    let interval = match unit {
398        "ns" => Duration::from_nanos(amount),
399        "us" => Duration::from_micros(amount),
400        "ms" => Duration::from_millis(amount),
401        "s" => Duration::from_secs(amount),
402        "m" => Duration::from_secs(amount.checked_mul(60).ok_or_else(invalid)?),
403        "h" => Duration::from_secs(amount.checked_mul(3_600).ok_or_else(invalid)?),
404        _ => return Err(invalid()),
405    };
406    super::storage::runtime_options(interval).map_err(|error| error.to_string())?;
407    Ok(interval)
408}
409
410fn parse(args: impl Iterator<Item = String>) -> Result<Option<ServeOptions>, String> {
411    let mut args = args.peekable();
412    if args.peek().is_some_and(|arg| arg == "--help") {
413        return if args.count() == 1 {
414            Ok(None)
415        } else {
416            Err(USAGE.to_owned())
417        };
418    }
419    let mut path = None;
420    let mut create = false;
421    let mut exit_on_stdin_close = false;
422    let mut long_wait_interval = None;
423    while let Some(arg) = args.next() {
424        match arg.as_str() {
425            "--config" if path.is_none() => path = Some(PathBuf::from(args.next().ok_or(USAGE)?)),
426            "--create" if !create => create = true,
427            "--exit-on-stdin-close" if !exit_on_stdin_close => exit_on_stdin_close = true,
428            "--long-wait-interval" if long_wait_interval.is_none() => {
429                long_wait_interval = Some(parse_long_wait_interval(&args.next().ok_or(USAGE)?)?);
430            }
431            _ => return Err(format!("unknown or repeated argument {arg:?}\n{USAGE}")),
432        }
433    }
434    Ok(Some(ServeOptions {
435        path: path.ok_or(USAGE)?,
436        create,
437        exit_on_stdin_close,
438        long_wait_interval: long_wait_interval.unwrap_or(DEFAULT_LONG_WAIT_INTERVAL),
439    }))
440}
441
442#[cfg(test)]
443#[path = "cli_tests.rs"]
444mod tests;
445
446#[cfg(test)]
447#[path = "cli_lys_tests.rs"]
448mod lys_tests;
449
450#[cfg(test)]
451#[path = "cli_long_wait_tests.rs"]
452mod long_wait_tests;
453
454#[cfg(test)]
455#[path = "cli_endpoint_tests.rs"]
456pub(crate) mod endpoint_tests;