Skip to main content

haematite_cli/serve/
cli.rs

1//! Explicit operator startup. Reading configuration never installs a service.
2
3use std::num::{NonZeroU32, NonZeroU64, NonZeroUsize};
4use std::path::PathBuf;
5use std::sync::Arc;
6use std::time::Duration;
7
8use haematite::branch::KeptLimit;
9use haematite::db::DatabaseConfig;
10use haematite::store::NodeCacheBudget;
11use serde::Deserialize;
12
13use super::{
14    changes, embed,
15    live::LysLive,
16    pass::Gate,
17    placement,
18    query::{Options, Setting, Settings, probe},
19    releaser,
20    service::Service,
21    storage::{KEPT_VERSIONS_DEFAULT, KEPT_VERSIONS_MOST, Storage, UndatedGeneration},
22    transport::{Limits, Listener},
23};
24
25/// Checks ownership of a resolved default name endpoint without changing it.
26///
27/// The expected uid is supplied by the caller's validated OS runtime context.
28/// A successful probe lock is released before returning stopped; pids and
29/// socket connection results do not participate in this observation.
30///
31/// # Errors
32/// Returns `name_owner_probe_failed` with the anchor's cause when its location,
33/// owner, mode, identity or OS lock cannot be checked or released.
34pub fn name_is_live(default_endpoint: &std::path::Path, expected_uid: u32) -> Result<bool, String> {
35    super::socket_owner::name_is_live(default_endpoint, expected_uid).map_err(|error| {
36        format!(
37            "name_owner_probe_failed: {}: {error}",
38            default_endpoint.display()
39        )
40    })
41}
42
43const USAGE: &str = "usage: haematite serve --config <file.json> [--create] [--long-wait-interval <duration>]\n\
44--create requires a new database directory; omit it when restarting.\n\
45An explicit socket's parent directory must already exist with mode 0700.\n\
46An omitted socket uses the private OS runtime namespace. No auto-launch is installed.";
47
48const DEFAULT_LONG_WAIT_INTERVAL: Duration = Duration::from_secs(30);
49
50struct ServeOptions {
51    path: PathBuf,
52    create: bool,
53    long_wait_interval: Duration,
54}
55
56fn long_wait_help() -> String {
57    format!(
58        "--long-wait-interval <duration> is the interval between long-wait warnings, not a deadline. \
59         default: {}s; use an integer followed by ns, us, ms, s, m or h.",
60        DEFAULT_LONG_WAIT_INTERVAL.as_secs()
61    )
62}
63
64#[derive(Debug, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct Config {
67    pub mcp_http: Option<crate::mcp::http::Config>,
68    pub data_dir: PathBuf,
69    pub socket: Option<PathBuf>,
70    pub name: Option<String>,
71    pub branch_cache_bytes_per_shard: NonZeroUsize,
72    pub max_frame: NonZeroU32,
73    pub max_clients: NonZeroUsize,
74    pub max_page: NonZeroU32,
75    #[serde(default = "append_prune_keep_ceiling")]
76    pub append_prune_keep_ceiling: u32,
77    #[serde(default = "append_prune_removal_cap")]
78    pub append_prune_removal_cap: u32,
79    pub timeout_ms: NonZeroU64,
80    pub create: Option<Creation>,
81    /// `changes.list`'s limits, each defaulted when absent.
82    #[serde(default = "changes_scan_records")]
83    pub changes_scan_records: NonZeroU64,
84    #[serde(default = "changes_node_budget")]
85    pub changes_node_budget: NonZeroUsize,
86    #[serde(default = "changes_max_page")]
87    pub changes_max_page: NonZeroU32,
88    #[serde(default, deserialize_with = "super::query::settings::time_ms")]
89    pub query_time_ms: Setting<u64>,
90    #[serde(default, deserialize_with = "super::query::settings::memory_bytes")]
91    pub query_memory_bytes: Setting<u64>,
92    #[serde(
93        default,
94        deserialize_with = "super::query::settings::outputs_per_record"
95    )]
96    pub query_outputs_per_record: Setting<u64>,
97    #[serde(default, deserialize_with = "super::query::settings::scan_records")]
98    pub query_scan_records: Setting<u64>,
99    #[serde(default, deserialize_with = "super::query::settings::max_running")]
100    pub query_max_running: Setting<u64>,
101    #[serde(default, deserialize_with = "super::query::settings::worker")]
102    pub query_worker: Setting<PathBuf>,
103    #[serde(default)]
104    pub embed_worker: Setting<PathBuf>,
105    #[serde(default)]
106    pub embed_model_dir: Setting<PathBuf>,
107    #[serde(default)]
108    pub embed_model_sha256: Setting<String>,
109    #[serde(default)]
110    pub embed_memory_bytes: Setting<u64>,
111    #[serde(default)]
112    pub embed_threads: Setting<u64>,
113    #[serde(default)]
114    pub embed_batch_items: Setting<u64>,
115    #[serde(default)]
116    pub embed_input_bytes: Setting<u64>,
117    #[serde(default)]
118    pub embed_tokens: Setting<u64>,
119    #[serde(default)]
120    pub embed_frame_bytes: Setting<u32>,
121    #[serde(default)]
122    pub embed_ready_ms: Setting<u64>,
123    #[serde(default)]
124    pub embed_call_ms: Setting<u64>,
125    #[serde(default)]
126    pub embed_waiting: Setting<u64>,
127    #[serde(default)]
128    pub embed_request_items: Setting<u64>,
129    #[serde(default)]
130    pub embed_page_records: Setting<u64>,
131    #[serde(default)]
132    pub embed_writer_wait_ms: Setting<u64>,
133    /// EMB-E2: embed a text longer than the model reads whole, as windows.
134    #[serde(default)]
135    pub embed_windows: Setting<bool>,
136    /// EMB-E2: the most windows one text is embedded as; 32 when absent.
137    #[serde(default)]
138    pub embed_max_windows: Setting<u64>,
139    /// EMB-E2: the tokens consecutive windows share; 64 when absent.
140    #[serde(default)]
141    pub embed_window_overlap_tokens: Setting<u64>,
142    /// EWR1: the wait before the second start of an embedding worker that
143    /// died, doubled for each later one; one second when absent.
144    #[serde(default)]
145    pub embed_restart_ms: Setting<u64>,
146    /// EWR1: the longest wait between two starts of the embedding worker;
147    /// five minutes when absent.
148    #[serde(default)]
149    pub embed_restart_most_ms: Setting<u64>,
150    /// SL1: whether text search asks its questions of a worker of its own,
151    /// started on the first search, so a search never waits behind the
152    /// embedding walk. On when absent. It holds its own copy of the model:
153    /// with it the two workers may use twice `embed_memory_bytes`.
154    #[serde(default)]
155    pub embed_question_worker: Setting<bool>,
156    /// Number of previous versions each branch record keeps,
157    /// a whole number from 1 to [`KEPT_VERSIONS_MOST`].
158    #[serde(default = "kept_versions", deserialize_with = "kept_versions_within")]
159    pub kept_versions: usize,
160    /// STOR-003 HR4: when the release thread runs and what it keeps.
161    #[serde(default)]
162    pub retention: releaser::Config,
163    /// ACCESS-001 R2: decide every request through this Lys. Absent, the
164    /// service is its owner's alone, reached only by its 0700 socket, and
165    /// decides nothing.
166    #[serde(default)]
167    pub lys: Option<LysConfig>,
168}
169
170/// The Lys a served database decides every request through. Nothing here
171/// grants anything: every right comes from a pass or a live answer.
172#[derive(Debug, Deserialize)]
173#[serde(deny_unknown_fields)]
174pub struct LysConfig {
175    /// The issuer origin whose passes are trusted and whose live check
176    /// deliberate routes ask.
177    pub issuer: String,
178    /// This database's id as Lys registers it, a `haematite.database`.
179    pub database: String,
180    /// The absolute path of a file holding haematite's Lys app credential,
181    /// `lys-app.haematite.<secret>`. Given, every collection and branch is
182    /// placed in Lys under this database, at start and after the write that
183    /// makes each one commits, so a grant on the database reaches it
184    /// (serve/placement.rs). Absent, nothing is placed, and a new collection
185    /// or branch is reached only by a grant on itself until someone places it.
186    #[serde(default)]
187    pub placement_credential_file: Option<PathBuf>,
188}
189
190/// The Lys gate and placer a configuration asks for, both made before a
191/// store opens or a socket binds. Neither waits on Lys: the gate reads its
192/// keys on a thread of its own and refuses every decided act as
193/// `lys_keys_pending` until they are read, so the service listens at once
194/// and never serves undecided. A wrong credential file stops it by name.
195fn lys_parts(lys: Option<&LysConfig>) -> Result<(Option<Gate>, Option<placement::Placer>), String> {
196    let gate = lys.map(lys_gate).transpose()?;
197    let placer = lys
198        .and_then(|lys| {
199            lys.placement_credential_file.as_deref().map(|file| {
200                placement::Lys::new(&lys.issuer, &lys.database, file)
201                    .and_then(placement::Placer::start)
202            })
203        })
204        .transpose()?;
205    Ok((gate, placer))
206}
207
208/// `service` deciding through `gate` and placing through `placer`, each
209/// when given.
210fn served_through_lys(
211    mut service: Service,
212    gate: Option<Gate>,
213    placer: Option<placement::Placer>,
214) -> Result<Service, String> {
215    if let Some(gate) = gate {
216        service = service.with_lys(gate);
217    }
218    if let Some(placer) = placer {
219        service = service
220            .with_placer(placer)
221            .map_err(|error| error.to_string())?;
222    }
223    Ok(service)
224}
225
226/// The gate for `config`, its keys read from Lys in the background.
227///
228/// # Errors
229///
230/// Refuses an empty or placed database id, or an issuer that is not an HTTP
231/// origin, by name.
232fn lys_gate(config: &LysConfig) -> Result<Gate, String> {
233    if config.database.is_empty()
234        || !config
235            .database
236            .bytes()
237            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
238    {
239        return Err(format!(
240            "lys database id {:?} must be letters, digits, _ or - only",
241            config.database
242        ));
243    }
244    let mut live = LysLive::new(&config.issuer)
245        .map_err(|error| format!("{}: the lys issuer {}", error.name(), config.issuer))?;
246    if let Some(file) = config.placement_credential_file.as_deref() {
247        live = live.asking_as(super::lys_schema::read_credential(file)?);
248    }
249    let live = Arc::new(live);
250    let asking = Arc::clone(&live);
251    Gate::fetching(
252        move || asking.keys(),
253        &config.issuer,
254        &config.database,
255        live,
256    )
257    .map_err(|error| format!("the thread reading Lys's keys could not start: {error}"))
258}
259
260const fn kept_versions() -> usize {
261    KEPT_VERSIONS_DEFAULT
262}
263
264/// `kept_versions` as read, refused by name outside 1 to the most.
265fn kept_versions_within<'de, D>(deserializer: D) -> Result<usize, D::Error>
266where
267    D: serde::Deserializer<'de>,
268{
269    let value = u64::deserialize(deserializer)?;
270    usize::try_from(value)
271        .ok()
272        .filter(|kept| (1..=KEPT_VERSIONS_MOST).contains(kept))
273        .ok_or_else(|| {
274            serde::de::Error::custom(format!(
275                "kept_versions must be a whole number from 1 to {KEPT_VERSIONS_MOST}, not {value}"
276            ))
277        })
278}
279
280const fn append_prune_keep_ceiling() -> u32 {
281    4_096
282}
283
284const fn append_prune_removal_cap() -> u32 {
285    64
286}
287
288fn changes_scan_records() -> NonZeroU64 {
289    changes::Settings::default().scan_records
290}
291
292fn changes_node_budget() -> NonZeroUsize {
293    changes::Settings::default().node_budget
294}
295
296fn changes_max_page() -> NonZeroU32 {
297    changes::Settings::default().max_page
298}
299
300impl Config {
301    /// Resolves the endpoint when a caller needs it; deserialization remains pure.
302    ///
303    /// # Errors
304    /// Returns the runtime resolver's named input, ownership and OS-query refusals.
305    pub fn endpoint(
306        &self,
307        create: bool,
308    ) -> Result<super::runtime::Endpoint, super::runtime::Refusal> {
309        #[cfg(test)]
310        if let Some((root, uid)) = endpoint_tests::input() {
311            return super::runtime::resolve_input(
312                &self.data_dir,
313                self.name.as_deref(),
314                self.socket.as_deref(),
315                create,
316                &root,
317                uid,
318            );
319        }
320        super::runtime::resolve(
321            &self.data_dir,
322            self.name.as_deref(),
323            self.socket.as_deref(),
324            create,
325        )
326    }
327
328    /// The kept window every branch record keeps.
329    ///
330    /// # Errors
331    /// Only for a `kept_versions` of zero, which loading refuses first.
332    pub fn kept_limit(&self) -> Result<KeptLimit, String> {
333        KeptLimit::new(self.kept_versions).map_err(|error| error.to_string())
334    }
335
336    /// # Errors
337    /// Refuses either zero retention setting before opening a store.
338    pub fn append_prune_limits(&self) -> Result<super::storage::AppendPruneLimits, String> {
339        super::storage::AppendPruneLimits::new(
340            self.append_prune_keep_ceiling,
341            self.append_prune_removal_cap,
342        )
343        .map_err(|failure| failure.to_string())
344    }
345
346    /// `changes.list`'s limits; a page must be able to move past a change.
347    ///
348    /// # Errors
349    /// Refuses `changes_scan_records` below two, which cannot advance a page.
350    pub fn changes_settings(&self) -> Result<changes::Settings, String> {
351        if self.changes_scan_records.get() < 2 {
352            return Err("changes_scan_records must be at least 2".to_owned());
353        }
354        Ok(changes::Settings {
355            scan_records: self.changes_scan_records,
356            node_budget: self.changes_node_budget,
357            max_page: self.changes_max_page,
358        })
359    }
360
361    /// Resolve the optional query settings as one complete group.
362    ///
363    /// # Errors
364    /// Refuses missing, zero, out-of-range or incompatible settings and a worker path that is not absolute.
365    pub fn query_settings(&self) -> Result<Option<Settings>, String> {
366        Options {
367            query_time_ms: self.query_time_ms,
368            query_memory_bytes: self.query_memory_bytes,
369            query_outputs_per_record: self.query_outputs_per_record,
370            query_scan_records: self.query_scan_records,
371            query_max_running: self.query_max_running,
372            query_worker: self.query_worker.clone(),
373        }
374        .resolve()
375    }
376
377    /// The required embedding settings, plus the optional writer wait.
378    ///
379    /// # Errors
380    ///
381    /// As [`embed::Options::resolve`].
382    pub fn embed_settings(&self) -> Result<Option<embed::Settings>, String> {
383        embed::Options {
384            worker: self.embed_worker.clone(),
385            model_dir: self.embed_model_dir.clone(),
386            model_sha256: self.embed_model_sha256.clone(),
387            memory_bytes: self.embed_memory_bytes,
388            threads: self.embed_threads,
389            batch_items: self.embed_batch_items,
390            input_bytes: self.embed_input_bytes,
391            tokens: self.embed_tokens,
392            frame_bytes: self.embed_frame_bytes,
393            ready_ms: self.embed_ready_ms,
394            call_ms: self.embed_call_ms,
395            waiting: self.embed_waiting,
396            request_items: self.embed_request_items,
397            page_records: self.embed_page_records,
398            writer_wait_ms: self.embed_writer_wait_ms,
399            windows: self.embed_windows,
400            max_windows: self.embed_max_windows,
401            overlap_tokens: self.embed_window_overlap_tokens,
402        }
403        .resolve()
404    }
405}
406
407#[derive(Debug, Deserialize)]
408#[serde(deny_unknown_fields)]
409pub struct Creation {
410    pub shards: NonZeroUsize,
411    pub cache_bytes_total: NonZeroUsize,
412    pub executor_threads: NonZeroUsize,
413}
414
415/// The engine configuration of a store the service creates: the one place it
416/// creates one. The store reclaims what nothing keeps, under the default
417/// marking bound (STOR-003 H1a).
418const fn creation_config(data_dir: PathBuf, creation: &Creation) -> DatabaseConfig {
419    DatabaseConfig {
420        data_dir,
421        shard_count: creation.shards.get(),
422        distributed: None,
423        executor_threads: Some(creation.executor_threads.get()),
424        node_cache_budget: Some(NodeCacheBudget::Bytes(creation.cache_bytes_total)),
425        retention: haematite::Retention::reclaim(0),
426    }
427}
428
429fn claim_name(
430    config: &Config,
431    endpoint: &super::runtime::Endpoint,
432    create: bool,
433) -> Result<std::fs::File, String> {
434    let name_endpoint = if config.socket.is_some() {
435        #[cfg(test)]
436        let resolved = match endpoint_tests::input() {
437            Some((root, uid)) => super::runtime::resolve_input(
438                &config.data_dir,
439                Some(&endpoint.name),
440                None,
441                create,
442                &root,
443                uid,
444            ),
445            None => super::runtime::resolve(&config.data_dir, Some(&endpoint.name), None, create),
446        };
447        #[cfg(not(test))]
448        let resolved =
449            super::runtime::resolve(&config.data_dir, Some(&endpoint.name), None, create);
450        Some(resolved.map_err(|error| format!("bind service socket: {error}"))?)
451    } else {
452        None
453    };
454    let name_socket = name_endpoint
455        .as_ref()
456        .map_or(&endpoint.socket, |named| &named.socket);
457    let namespace = name_socket
458        .parent()
459        .ok_or("bind service socket: name endpoint needs a parent")?;
460    let name_uid = {
461        use std::os::unix::fs::MetadataExt;
462        std::fs::symlink_metadata(namespace)
463            .map_err(|error| format!("bind service socket: {error}"))?
464            .uid()
465    };
466    super::socket_owner::acquire_name(name_socket, name_uid)
467        .map_err(|error| format!("bind service socket: {error}"))
468}
469
470/// The data directory and socket are absolute, the timeout fits the
471/// monotonic clock, and the socket's parent is one this user may bind in;
472/// the timeout is answered.
473fn check_paths_and_timeout(config: &Config) -> Result<Duration, String> {
474    if !config.data_dir.is_absolute()
475        || config
476            .socket
477            .as_ref()
478            .is_some_and(|socket| !socket.is_absolute())
479    {
480        return Err("data_dir and socket must be absolute paths".to_owned());
481    }
482    let timeout = Duration::from_millis(config.timeout_ms.get());
483    if std::time::Instant::now().checked_add(timeout).is_none() {
484        return Err("timeout cannot be represented by the monotonic clock".to_owned());
485    }
486    if let Some(socket) = config.socket.as_ref() {
487        super::socket_owner::check_parent(socket)
488            .map_err(|error| format!("bind service socket: {error}"))?;
489    }
490    Ok(timeout)
491}
492
493/// # Errors
494///
495/// Propagates any error from `parse`.
496pub fn run(args: impl Iterator<Item = String>) -> Result<(), String> {
497    let Some(options) = parse(args)? else {
498        println!("{USAGE}\n{}", long_wait_help());
499        return Ok(());
500    };
501    let config = read_config(&options.path)?;
502    // Resolve the complete group before opening a store or binding a socket.
503    let retention_limits = config.append_prune_limits()?;
504    let query_settings = config.query_settings()?;
505    let changes_settings = config.changes_settings()?;
506    let release = config.retention.settings()?;
507    // Read now, so a wrong key stops the service.
508    let embedding = config.embed_settings()?;
509    let (gate, placer) = lys_parts(config.lys.as_ref())?;
510    let restart = embed::settings::Restart::of(
511        config.embed_restart_ms,
512        config.embed_restart_most_ms,
513        embedding
514            .as_ref()
515            .map_or(Duration::ZERO, |settings| settings.ready),
516    )?;
517    let timeout = check_paths_and_timeout(&config)?;
518    let endpoint = config
519        .endpoint(options.create)
520        .map_err(|error| error.to_string())?;
521    let name_ownership = claim_name(&config, &endpoint, options.create)?;
522    // A worker that cannot start does not stop the service: every other verb
523    // is served, and the refusal is kept for `query.run` to answer by name.
524    let query = query_settings.map(|settings| probe::prepare(settings, config.max_frame));
525    if let Some(Err(refusal)) = query.as_ref().map(probe::Prepared::ready) {
526        eprintln!("haem/1 query unavailable: {refusal}");
527    }
528    // Only the leaf-log mark is refused here; a missing or unreadable config
529    // is left to the storage checks below, which answer as they always have.
530    if let Err(error @ haematite::DatabaseError::LeafLogStore { .. }) =
531        haematite::Database::check_ordinary_store(&config.data_dir)
532    {
533        return Err(error.to_string());
534    }
535    let branch_cache = NodeCacheBudget::Bytes(config.branch_cache_bytes_per_shard);
536    let kept = config.kept_limit()?;
537    let storage = if options.create {
538        let creation = config
539            .create
540            .ok_or("--create needs a create section in the configuration")?;
541        Storage::create_kept(
542            creation_config(config.data_dir, &creation),
543            branch_cache,
544            kept,
545            options.long_wait_interval,
546        )
547    } else {
548        Storage::open_kept(
549            &config.data_dir,
550            branch_cache,
551            kept,
552            options.long_wait_interval,
553        )
554    }
555    .map_err(|error| error.to_string())?;
556    if options.create {
557        endpoint
558            .check_created()
559            .map_err(|error| error.to_string())?;
560    }
561    let mut service =
562        Service::with_query(storage, config.max_page, query).map_err(|error| error.to_string())?;
563    service.changes = changes_settings;
564    service.append_prune_limits = retention_limits;
565    service = served_through_lys(service, gate, placer)?;
566    service
567        .start_release(release)
568        .map_err(|error| error.to_string())?;
569    // The worker starts on the embedding thread, off the serving path: until
570    // it answers, text search is refused as `embed_worker_starting`.
571    if let Some(settings) = embedding {
572        let bounds = super::embedding::bounds(&settings);
573        let writer_wait = settings.writer_wait;
574        let apart = !matches!(config.embed_question_worker, Setting::Present(false));
575        let questions = apart.then(|| super::embedding::embedder(settings.clone()));
576        service
577            .start_embedding_apart(
578                super::embedding::embedder(settings),
579                questions,
580                bounds,
581                writer_wait,
582                restart,
583            )
584            .map_err(|error| error.to_string())?;
585    }
586    let service = Arc::new(service);
587    service
588        .activate_reclaim()
589        .map_err(|error| format!("reclaim activation: {error}"))?;
590    let served = serve_listener(
591        &endpoint,
592        &service,
593        Limits {
594            max_frame: config.max_frame,
595            max_clients: config.max_clients,
596            frame_timeout: timeout,
597        },
598    );
599    drop(name_ownership);
600    served
601}
602
603fn serve_listener(
604    endpoint: &super::runtime::Endpoint,
605    service: &Arc<Service>,
606    limits: Limits,
607) -> Result<(), String> {
608    let mut listener = Listener::bind(&endpoint.socket)
609        .map_err(|error| format!("bind service socket: {error}"))?;
610    listener
611        .publish(endpoint)
612        .map_err(|error| error.to_string())?;
613    eprintln!("haem/1 ready on {}", endpoint.socket.display());
614    let served = listener
615        .run(service, limits)
616        .map_err(|error| format!("serve: {error}"));
617    let cleanup = listener.finish().map_err(|error| error.to_string());
618    // The name remains owned while registry and socket cleanup still run.
619    drop(listener);
620    match (served, cleanup) {
621        (Ok(()), Ok(())) => Ok(()),
622        (Err(error), Ok(())) | (Ok(()), Err(error)) => Err(error),
623        (Err(served), Err(cleanup)) => Err(format!("{served}; {cleanup}")),
624    }
625}
626
627const RELEASE_USAGE: &str = "usage: haematite release date --config <file.json> --branch <name> \
628--generation <created> --at <nanoseconds> [--long-wait-interval <duration>]\n\
629Dates a generation discarded before its marker was written, as discarded at --at, so release \
630takes its rows by keep.days from then. --generation is the record's creation time its rows are \
631keyed by. Offline: the service must be stopped, as the store has one writer.";
632
633struct DateOptions {
634    branch: String,
635    generation: u64,
636    at: u64,
637    long_wait_interval: Duration,
638}
639
640#[path = "cli/branch_rename.rs"]
641mod branch_rename;
642pub use branch_rename::{BRANCH_RENAME_USAGE, branch_rename};
643
644/// DR-12: `haematite release date`, which dates a generation discarded before DR-1.
645///
646/// The operator's way out ([`Storage::date_undated_generation`], DR-11).
647/// Offline: it opens the store as its one writer, so a running service holds
648/// it off. Prints the marker's last version, `generation.sequence`.
649///
650/// # Errors
651/// A usage error, the configuration's, the store's open refusal, or the
652/// request's refusal by name (`branch_live`, `generation_marked`,
653/// `generation_empty`).
654pub fn release(mut args: impl Iterator<Item = String>) -> Result<(), String> {
655    if args.next().as_deref() != Some("date") {
656        return Err(RELEASE_USAGE.to_owned());
657    }
658    let mut args = args.peekable();
659    if args.peek().is_some_and(|arg| arg == "--help") {
660        if args.count() != 1 {
661            return Err(RELEASE_USAGE.to_owned());
662        }
663        println!("{RELEASE_USAGE}\n{}", long_wait_help());
664        return Ok(());
665    }
666    let (path, options) = parse_date(args)?;
667    let config = read_config(&path)?;
668    let branch_cache = NodeCacheBudget::Bytes(config.branch_cache_bytes_per_shard);
669    let mut storage = Storage::open_kept(
670        &config.data_dir,
671        branch_cache,
672        config.kept_limit()?,
673        options.long_wait_interval,
674    )
675    .map_err(|error| error.to_string())?;
676    let request = UndatedGeneration {
677        branch: options.branch,
678        generation: options.generation,
679        at: options.at,
680    };
681    let last = storage
682        .date_undated_generation(&request)
683        .map_err(|failure| failure.to_string())?;
684    println!("{}.{}", last.generation, last.sequence);
685    Ok(())
686}
687
688/// The release request and its optional warning interval, each argument once;
689/// the two record times are decimal nanoseconds.
690fn parse_date(mut args: impl Iterator<Item = String>) -> Result<(PathBuf, DateOptions), String> {
691    let (mut path, mut branch, mut generation, mut at) = (None, None, None, None);
692    let mut long_wait_interval = None;
693    while let Some(arg) = args.next() {
694        let value = args.next().ok_or(RELEASE_USAGE)?;
695        match arg.as_str() {
696            "--config" if path.is_none() => path = Some(PathBuf::from(value)),
697            "--branch" if branch.is_none() => branch = Some(value),
698            "--generation" if generation.is_none() => generation = Some(decimal(&arg, &value)?),
699            "--at" if at.is_none() => at = Some(decimal(&arg, &value)?),
700            "--long-wait-interval" if long_wait_interval.is_none() => {
701                long_wait_interval = Some(parse_long_wait_interval(&value)?);
702            }
703            _ => {
704                return Err(format!(
705                    "unknown or repeated argument {arg:?}\n{RELEASE_USAGE}"
706                ));
707            }
708        }
709    }
710    let request = DateOptions {
711        long_wait_interval: long_wait_interval.unwrap_or(DEFAULT_LONG_WAIT_INTERVAL),
712        branch: branch.ok_or(RELEASE_USAGE)?,
713        generation: generation.ok_or(RELEASE_USAGE)?,
714        at: at.ok_or(RELEASE_USAGE)?,
715    };
716    Ok((path.ok_or(RELEASE_USAGE)?, request))
717}
718
719fn decimal(arg: &str, value: &str) -> Result<u64, String> {
720    value
721        .parse()
722        .map_err(|error| format!("{arg} {value:?}: {error}\n{RELEASE_USAGE}"))
723}
724
725fn read_config(path: &std::path::Path) -> Result<Config, String> {
726    let bytes = std::fs::read(path).map_err(|error| format!("read service config: {error}"))?;
727    serde_json::from_slice(&bytes).map_err(|error| format!("service config: {error}"))
728}
729
730fn parse_long_wait_interval(value: &str) -> Result<Duration, String> {
731    let invalid = || {
732        format!(
733            "invalid --long-wait-interval {value:?}: {}",
734            long_wait_help()
735        )
736    };
737    let end = value
738        .find(|character: char| !character.is_ascii_digit())
739        .ok_or_else(invalid)?;
740    let (number, unit) = value.split_at(end);
741    let amount: u64 = number.parse().map_err(|_| invalid())?;
742    let interval = match unit {
743        "ns" => Duration::from_nanos(amount),
744        "us" => Duration::from_micros(amount),
745        "ms" => Duration::from_millis(amount),
746        "s" => Duration::from_secs(amount),
747        "m" => Duration::from_secs(amount.checked_mul(60).ok_or_else(invalid)?),
748        "h" => Duration::from_secs(amount.checked_mul(3_600).ok_or_else(invalid)?),
749        _ => return Err(invalid()),
750    };
751    super::storage::runtime_options(interval).map_err(|error| error.to_string())?;
752    Ok(interval)
753}
754
755fn parse(args: impl Iterator<Item = String>) -> Result<Option<ServeOptions>, String> {
756    let mut args = args.peekable();
757    if args.peek().is_some_and(|arg| arg == "--help") {
758        return if args.count() == 1 {
759            Ok(None)
760        } else {
761            Err(USAGE.to_owned())
762        };
763    }
764    let mut path = None;
765    let mut create = false;
766    let mut long_wait_interval = None;
767    while let Some(arg) = args.next() {
768        match arg.as_str() {
769            "--config" if path.is_none() => path = Some(PathBuf::from(args.next().ok_or(USAGE)?)),
770            "--create" if !create => create = true,
771            "--long-wait-interval" if long_wait_interval.is_none() => {
772                long_wait_interval = Some(parse_long_wait_interval(&args.next().ok_or(USAGE)?)?);
773            }
774            _ => return Err(format!("unknown or repeated argument {arg:?}\n{USAGE}")),
775        }
776    }
777    Ok(Some(ServeOptions {
778        path: path.ok_or(USAGE)?,
779        create,
780        long_wait_interval: long_wait_interval.unwrap_or(DEFAULT_LONG_WAIT_INTERVAL),
781    }))
782}
783
784#[cfg(test)]
785#[path = "cli_tests.rs"]
786mod tests;
787
788#[cfg(test)]
789#[path = "cli_lys_tests.rs"]
790mod lys_tests;
791
792#[cfg(test)]
793mod long_wait_tests {
794    use std::error::Error;
795    use std::time::Duration;
796
797    use super::{DEFAULT_LONG_WAIT_INTERVAL, long_wait_help, parse, parse_date};
798
799    #[test]
800    fn serve_default_is_the_named_thirty_second_interval() -> Result<(), Box<dyn Error>> {
801        let options = parse(["--config", "/config.json"].map(str::to_owned).into_iter())?
802            .ok_or("serve options")?;
803        assert_eq!(DEFAULT_LONG_WAIT_INTERVAL, Duration::from_secs(30));
804        assert_eq!(options.long_wait_interval, DEFAULT_LONG_WAIT_INTERVAL);
805        assert!(long_wait_help().contains("default: 30s"));
806        Ok(())
807    }
808
809    #[test]
810    fn serve_explicit_interval_is_carried_into_the_engine_options() -> Result<(), Box<dyn Error>> {
811        let options = parse(
812            ["--config", "/config.json", "--long-wait-interval", "125ms"]
813                .map(str::to_owned)
814                .into_iter(),
815        )?
816        .ok_or("serve options")?;
817        let runtime = super::super::storage::runtime_options(options.long_wait_interval)?;
818        assert_eq!(runtime.long_wait_interval, Duration::from_millis(125));
819        Ok(())
820    }
821
822    #[test]
823    fn release_date_keeps_request_fields_and_carries_the_interval() -> Result<(), Box<dyn Error>> {
824        let arguments = [
825            "--config",
826            "/config.json",
827            "--branch",
828            "work",
829            "--generation",
830            "7",
831            "--at",
832            "9",
833        ];
834        let (path, options) = parse_date(arguments.map(str::to_owned).into_iter())?;
835        assert_eq!(path, std::path::PathBuf::from("/config.json"));
836        assert_eq!(
837            (options.branch.as_str(), options.generation, options.at),
838            ("work", 7, 9)
839        );
840        assert_eq!(options.long_wait_interval, DEFAULT_LONG_WAIT_INTERVAL);
841        let explicit = arguments
842            .into_iter()
843            .chain(["--long-wait-interval", "2m"])
844            .map(str::to_owned);
845        let (_, options) = parse_date(explicit)?;
846        let runtime = super::super::storage::runtime_options(options.long_wait_interval)?;
847        assert_eq!(runtime.long_wait_interval, Duration::from_secs(120));
848        Ok(())
849    }
850
851    #[test]
852    fn invalid_intervals_are_refused_at_the_cli_boundary() {
853        for value in [
854            "0s",
855            "-1s",
856            "1",
857            "1.5s",
858            "1fortnight",
859            "18446744073709551615h",
860        ] {
861            let parsed = parse(
862                ["--config", "/config.json", "--long-wait-interval", value]
863                    .map(str::to_owned)
864                    .into_iter(),
865            );
866            assert!(parsed.is_err(), "interval {value}");
867        }
868        let refused = parse(
869            ["--config", "/config.json", "--long-wait-interval", "0s"]
870                .map(str::to_owned)
871                .into_iter(),
872        );
873        assert!(
874            matches!(refused, Err(message) if message.contains("long-wait interval 0ns is refused"))
875        );
876        let repeated = parse(
877            [
878                "--config",
879                "/config.json",
880                "--long-wait-interval",
881                "1s",
882                "--long-wait-interval",
883                "2s",
884            ]
885            .map(str::to_owned)
886            .into_iter(),
887        );
888        assert!(repeated.is_err());
889    }
890
891    #[test]
892    fn help_does_not_require_a_configuration_or_open_a_store() -> Result<(), String> {
893        super::run(["--help".to_owned()].into_iter())?;
894        super::release(["date", "--help"].map(str::to_owned).into_iter())?;
895        Ok(())
896    }
897}
898
899#[cfg(test)]
900#[path = "cli_endpoint_tests.rs"]
901pub(crate) mod endpoint_tests;