Federation foundation + HNSW trial system + Postgres streaming + PRD reframe

Four shipped features and a PRD realignment, all measured end-to-end:

HNSW trial system (Phase 15 horizon item → complete)
- vectord: EmbeddingCache, harness (eval sets + brute-force ground truth),
  TrialJournal, parameterized HnswConfig on build_index_with_config
- /vectors/hnsw/trial, /hnsw/trials/{idx}, /hnsw/trials/{idx}/best,
  /hnsw/evals/{name}/autogen, /hnsw/cache/stats
- Measured on resumes_100k_v2 (100K × 768d): brute-force 44ms -> HNSW 873us
  at 100% recall@10. ec=80 es=30 locked as HnswConfig::default()
- Lower ec values trade recall for build time: 20/30 = 0.96 recall in 8s,
  80/30 = 1.00 recall in 230s

Catalog manifest repair
- catalogd: resync_from_parquet reads parquet footers to restore row_count
  and columns on drifted manifests
- POST /catalog/datasets/{name}/resync + POST /catalog/resync-missing
- All 7 staffing tables recovered to PRD-matching 2,469,278 rows

Federation foundation (ADR-017)
- shared::secrets: SecretsProvider trait + FileSecretsProvider (reads
  /etc/lakehouse/secrets.toml, enforces 0600 perms)
- storaged::registry::BucketRegistry — multi-bucket resolution with
  rescue_bucket read fallback and reachability probing
- storaged::error_journal — bucket op failures visible in one HTTP call
- storaged::append_log — write-once batched append pattern (fixes the RMW
  anti-pattern llms3.com calls out; errors and trial journals both use it)
- /storage/buckets, /storage/errors, /storage/bucket-health,
  /storage/errors/{flush,compact}
- Bucket-aware I/O at /storage/buckets/{bucket}/objects/{*key} with
  X-Lakehouse-Rescue-Used observability headers on fallback

Postgres streaming ingest
- ingestd::pg_stream: DSN parser, batched ORDER BY + LIMIT/OFFSET pagination
  into ArrowWriter, lineage redacts password
- POST /ingest/db — verified against live knowledge_base.team_runs
  (586 rows × 13 cols, 6 batches, 196ms end-to-end)

PRD realignment (2026-04-16)
- Dual use case: staffing analytics + local LLM knowledge substrate
- Removed "multi-tenancy (single-owner system)" from non-goals
- Added invariants 8-11: indexes hot-swappable, per-reader profiles,
  trials-as-data, operational failures findable in one HTTP call
- New phases 16 (hot-swap generations), 17 (model profiles + dataset
  bindings), 18 (Lance vs Parquet+sidecar evaluation)
- Known ceilings table documents the 5M vector wall and escape hatches
- ADR-017 (federation), ADR-018 (append-log pattern) added
- EXECUTION_PLAN.md sequences phases B-E with success gates and
  decision rules

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-04-16 01:50:05 -05:00
co-authored by Claude Opus 4.6
parent 84407eeb51
commit dbe00d018f
33 changed files with 3305 additions and 52 deletions
+1
View File
@@ -13,3 +13,4 @@ tracing = { workspace = true }
object_store = { workspace = true }
bytes = { workspace = true }
futures = { workspace = true }
chrono = { workspace = true }
+239
View File
@@ -0,0 +1,239 @@
/// Write-once batched append log.
///
/// Problem we're fixing: the error journal and HNSW trial journal both
/// previously did read-modify-write of their whole JSONL file on every
/// event. That's O(N²) cumulative work and generates huge churn at scale.
/// It's exactly the pattern llms3.com flags as the "small-file /
/// rewrite-amplification" anti-pattern.
///
/// This helper implements the pattern object storage actually wants:
///
/// - Events accumulate in an **in-memory buffer** (reads see them immediately).
/// - When the buffer hits a threshold, or `flush()` is called, the buffer is
/// written **as one new object** with a timestamp-sorted key.
/// - Existing objects are never rewritten.
/// - Reads enumerate all batch files, sort by key, and concat in order.
/// - An explicit `compact()` reads every batch file, writes one consolidated
/// file, and deletes the originals — the LSM-tree compaction idea applied
/// to small JSONL events.
///
/// Storage layout:
/// ```
/// {prefix}/
/// batch_0001776319628000123.jsonl
/// batch_0001776319745987654.jsonl
/// batch_compacted_00001776319800000000.jsonl (after compact())
/// ```
/// Key format: the zero-padded epoch microsecond of the write, so lexical
/// sort == chronological sort.
use bytes::Bytes;
use chrono::Utc;
use object_store::ObjectStore;
use std::sync::Arc;
use tokio::sync::Mutex;
use crate::ops;
const DEFAULT_FLUSH_THRESHOLD: usize = 32;
pub struct AppendLog {
store: Arc<dyn ObjectStore>,
prefix: String,
buffer: Mutex<Vec<Vec<u8>>>,
flush_threshold: usize,
}
impl AppendLog {
/// Create a new append log rooted at `prefix` in the given object store.
/// Events auto-flush when buffer reaches `flush_threshold` (default 32).
pub fn new(store: Arc<dyn ObjectStore>, prefix: impl Into<String>) -> Self {
Self {
store,
prefix: prefix.into(),
buffer: Mutex::new(Vec::new()),
flush_threshold: DEFAULT_FLUSH_THRESHOLD,
}
}
pub fn with_flush_threshold(mut self, threshold: usize) -> Self {
self.flush_threshold = threshold.max(1);
self
}
/// Add an event. The returned future completes either immediately
/// (buffered) or after a flush, depending on whether the buffer hit the
/// threshold. Callers don't need to care either way.
pub async fn append(&self, line: Vec<u8>) -> Result<(), String> {
let should_flush = {
let mut buf = self.buffer.lock().await;
buf.push(line);
buf.len() >= self.flush_threshold
};
if should_flush {
self.flush().await?;
}
Ok(())
}
/// Force-flush the in-memory buffer to object storage as a single new
/// batch file. Safe to call anytime (idempotent no-op when buffer empty).
pub async fn flush(&self) -> Result<(), String> {
let batch = {
let mut buf = self.buffer.lock().await;
if buf.is_empty() { return Ok(()); }
std::mem::take(&mut *buf)
};
let ts_us = Utc::now().timestamp_micros().max(0) as u128;
let key = format!("{}/batch_{:019}.jsonl", self.prefix, ts_us);
let mut body = Vec::with_capacity(batch.iter().map(|b| b.len() + 1).sum());
for line in batch {
body.extend_from_slice(&line);
if !body.ends_with(b"\n") { body.push(b'\n'); }
}
ops::put(&self.store, &key, Bytes::from(body)).await
}
/// Read every event across all batch files + unflushed in-memory buffer.
/// Events are returned in chronological order.
pub async fn read_all(&self) -> Result<Vec<Vec<u8>>, String> {
let mut keys = self.list_batch_keys().await?;
keys.sort();
let mut out = Vec::new();
for key in keys {
let bytes = ops::get(&self.store, &key).await?;
for line in bytes.split(|b| *b == b'\n') {
if !line.is_empty() {
out.push(line.to_vec());
}
}
}
// Include unflushed events so callers see the latest state
// whether or not someone ran flush() recently.
let buf = self.buffer.lock().await;
for line in buf.iter() {
out.push(line.clone());
}
Ok(out)
}
/// Consolidate all current batch files into one compacted file, then
/// delete the originals. Safe to call while appends are in flight:
/// new batches written during compaction get a higher timestamp and
/// survive. Fails closed — if anything goes wrong mid-delete, the
/// compacted file coexists with originals and next read sees duplicates
/// (which the dedup caller must handle) rather than data loss.
pub async fn compact(&self) -> Result<CompactStats, String> {
// Snapshot which files to compact BEFORE we write the new one.
let mut originals = self.list_batch_keys().await?;
originals.sort();
if originals.len() < 2 {
return Ok(CompactStats { merged_files: originals.len(), events: 0, new_key: None });
}
// Gather all existing events.
let mut events = Vec::new();
for key in &originals {
let bytes = ops::get(&self.store, key).await?;
for line in bytes.split(|b| *b == b'\n') {
if !line.is_empty() {
events.push(line.to_vec());
}
}
}
let total_events = events.len();
if total_events == 0 {
// Clean up empty files without writing a new one.
for key in &originals {
let _ = ops::delete(&self.store, key).await;
}
return Ok(CompactStats { merged_files: originals.len(), events: 0, new_key: None });
}
// Name the compacted file with the SAME `batch_{ts}` format so it
// sorts chronologically with future batches. Using a distinct prefix
// ("batch_compacted_") would break lex ordering: later `batch_N`
// files would sort BEFORE the compacted file because 'c' > digits.
// Timestamp = now, so any appends arriving during compaction (which
// get the current wall-clock time) sort AFTER this file.
let ts_us = Utc::now().timestamp_micros().max(0) as u128;
let new_key = format!("{}/batch_{:019}.jsonl", self.prefix, ts_us);
let mut body = Vec::new();
for line in &events {
body.extend_from_slice(line);
body.push(b'\n');
}
ops::put(&self.store, &new_key, Bytes::from(body)).await?;
// Only delete originals once the consolidated file is persisted.
let mut failures = 0;
for key in &originals {
if ops::delete(&self.store, key).await.is_err() {
failures += 1;
}
}
if failures > 0 {
tracing::warn!(
"compact '{}': {} original files failed to delete — consolidated file {} has the data",
self.prefix, failures, new_key,
);
}
Ok(CompactStats {
merged_files: originals.len(),
events: total_events,
new_key: Some(new_key),
})
}
/// How many batch files exist on disk right now.
pub async fn file_count(&self) -> Result<usize, String> {
Ok(self.list_batch_keys().await?.len())
}
async fn list_batch_keys(&self) -> Result<Vec<String>, String> {
let prefix_with_slash = format!("{}/", self.prefix);
// list the prefix then filter for keys that match our naming scheme;
// unrelated files at the same prefix won't be touched.
let raw = ops::list(&self.store, Some(&prefix_with_slash)).await?;
Ok(raw
.into_iter()
.filter(|k| {
let basename = k.rsplit('/').next().unwrap_or(k);
basename.starts_with("batch_") && basename.ends_with(".jsonl")
})
.collect())
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct CompactStats {
pub merged_files: usize,
pub events: usize,
pub new_key: Option<String>,
}
// Proactively flush on drop, best-effort.
// We can't `.await` in Drop; we spawn the flush on the tokio runtime if one
// is available. If the runtime is already shutting down the flush is lost —
// acceptable for these observability journals, which are hints not records.
impl Drop for AppendLog {
fn drop(&mut self) {
let buf_len = self.buffer.try_lock().map(|b| b.len()).unwrap_or(0);
if buf_len == 0 { return; }
// Can't spawn from sync Drop on every runtime shape; log + move on.
tracing::debug!(
"append_log '{}' dropping with {} unflushed events",
self.prefix, buf_len,
);
}
}
+200
View File
@@ -0,0 +1,200 @@
/// Bucket operation error journal.
///
/// Every bucket op failure and every rescue fallback lands here. Goal:
/// answering "is anything broken?" with one HTTP call.
///
/// Storage: batched write-once files under `_errors/bucket_errors/` in the
/// primary bucket. Uses the shared `AppendLog` helper so we never rewrite
/// existing files — see `append_log.rs` for the full pattern rationale.
///
/// In-memory ring buffer holds the last N events for fast response; on
/// startup `load_recent` hydrates it from all batch files.
use chrono::{DateTime, Utc};
use object_store::ObjectStore;
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::append_log::{AppendLog, CompactStats};
const JOURNAL_PREFIX: &str = "_errors/bucket_errors";
const RING_CAPACITY: usize = 2000;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BucketOp {
Read,
Write,
Delete,
List,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BucketErrorEvent {
pub ts: DateTime<Utc>,
pub op: BucketOp,
pub target: String,
pub key: String,
pub error: String,
#[serde(default)]
pub rescued: bool,
}
impl BucketErrorEvent {
pub fn new_read(target: &str, key: &str, error: &str) -> Self {
Self { ts: Utc::now(), op: BucketOp::Read, target: target.into(), key: key.into(), error: error.into(), rescued: false }
}
pub fn new_write(target: &str, key: &str, error: &str) -> Self {
Self { ts: Utc::now(), op: BucketOp::Write, target: target.into(), key: key.into(), error: error.into(), rescued: false }
}
pub fn new_delete(target: &str, key: &str, error: &str) -> Self {
Self { ts: Utc::now(), op: BucketOp::Delete, target: target.into(), key: key.into(), error: error.into(), rescued: false }
}
pub fn new_list(target: &str, prefix: &str, error: &str) -> Self {
Self { ts: Utc::now(), op: BucketOp::List, target: target.into(), key: prefix.into(), error: error.into(), rescued: false }
}
}
#[derive(Clone)]
pub struct ErrorJournal {
log: Arc<AppendLog>,
ring: Arc<RwLock<VecDeque<BucketErrorEvent>>>,
}
#[derive(Debug, Clone, Serialize)]
pub struct HealthReport {
pub period_minutes: i64,
pub total_errors: usize,
pub per_bucket: std::collections::HashMap<String, usize>,
pub unhealthy_buckets: Vec<String>,
}
impl ErrorJournal {
pub fn new(store: Arc<dyn ObjectStore>) -> Self {
// Keep flush threshold lowish — operators checking /storage/errors
// after a recent incident want to see fresh rows even without an
// explicit flush.
let log = Arc::new(
AppendLog::new(store, JOURNAL_PREFIX).with_flush_threshold(8),
);
Self {
log,
ring: Arc::new(RwLock::new(VecDeque::with_capacity(RING_CAPACITY))),
}
}
/// Hydrate the ring buffer from existing batch files. Call once at
/// startup. Tolerates malformed lines (skipped with a warning) and
/// missing files (returns 0).
pub async fn load_recent(&self) -> Result<usize, String> {
let lines = self.log.read_all().await.unwrap_or_default();
let mut ring = self.ring.write().await;
for line in lines {
match serde_json::from_slice::<BucketErrorEvent>(&line) {
Ok(ev) => {
if ring.len() >= RING_CAPACITY { ring.pop_front(); }
ring.push_back(ev);
}
Err(e) => tracing::warn!("error journal: skip malformed line ({e})"),
}
}
Ok(ring.len())
}
/// Append an event. In-memory ring updated immediately; persistence
/// happens in batches via the underlying AppendLog.
pub async fn append(&self, event: BucketErrorEvent) {
{
let mut ring = self.ring.write().await;
if ring.len() >= RING_CAPACITY { ring.pop_front(); }
ring.push_back(event.clone());
}
match serde_json::to_vec(&event) {
Ok(line) => {
if let Err(e) = self.log.append(line).await {
tracing::error!("error journal persist failed: {e}");
}
}
Err(e) => tracing::error!("error journal serialize failed: {e}"),
}
}
/// Mark the most recent matching in-memory event as rescued.
/// Only updates the ring buffer — the JSONL line already persisted
/// records the failure fact; rescue status travels in response headers.
pub async fn mark_rescued_last(&self, target: &str, key: &str) {
let mut ring = self.ring.write().await;
for ev in ring.iter_mut().rev() {
if matches!(ev.op, BucketOp::Read) && ev.target == target && ev.key == key && !ev.rescued {
ev.rescued = true;
break;
}
}
}
pub async fn recent(&self, limit: usize) -> Vec<BucketErrorEvent> {
let ring = self.ring.read().await;
let start = ring.len().saturating_sub(limit);
ring.iter().skip(start).cloned().collect()
}
pub async fn filter(
&self,
bucket: Option<&str>,
since: Option<DateTime<Utc>>,
limit: usize,
) -> Vec<BucketErrorEvent> {
let ring = self.ring.read().await;
ring.iter()
.rev()
.filter(|ev| bucket.map_or(true, |b| ev.target == b))
.filter(|ev| since.map_or(true, |s| ev.ts >= s))
.take(limit)
.cloned()
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect()
}
/// Summarize errors in the last N minutes.
pub async fn health(&self, period_minutes: i64) -> HealthReport {
use std::collections::HashMap;
let cutoff = Utc::now() - chrono::Duration::minutes(period_minutes);
let ring = self.ring.read().await;
let recent: Vec<_> = ring.iter().filter(|ev| ev.ts >= cutoff).collect();
let mut per_bucket: HashMap<String, usize> = HashMap::new();
for ev in &recent {
*per_bucket.entry(ev.target.clone()).or_insert(0) += 1;
}
let unhealthy_buckets: Vec<String> = per_bucket
.iter()
.filter(|(_, c)| **c >= 3)
.map(|(k, _)| k.clone())
.collect();
HealthReport {
period_minutes,
total_errors: recent.len(),
per_bucket,
unhealthy_buckets,
}
}
/// Force an immediate flush of buffered events to object storage.
pub async fn flush(&self) -> Result<(), String> {
self.log.flush().await
}
/// Consolidate all batch files into one. Operator cleanup.
pub async fn compact(&self) -> Result<CompactStats, String> {
self.log.compact().await
}
/// How many JSONL batch files currently exist.
pub async fn file_count(&self) -> Result<usize, String> {
self.log.file_count().await
}
}
+126
View File
@@ -0,0 +1,126 @@
/// HTTP surface for federation operator endpoints.
///
/// - `GET /buckets` — configured buckets with reachability status
/// - `GET /errors` — recent bucket op failures (filterable)
/// - `GET /bucket-health` — aggregated errors-per-bucket in the last 5 minutes
///
/// Mounted under `/storage` by the gateway. `/storage/health` is already
/// claimed by the existing storage router for service liveness, so the
/// aggregated bucket health lives at `/storage/bucket-health`.
use axum::{
Json, Router,
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::{get, put},
};
use chrono::{DateTime, Utc};
use serde::Deserialize;
use std::sync::Arc;
use crate::error_journal::BucketErrorEvent;
use crate::registry::{BucketInfo, BucketRegistry};
pub fn router(registry: Arc<BucketRegistry>) -> Router {
Router::new()
.route("/buckets", get(list_buckets))
.route("/errors", get(list_errors))
.route("/errors/flush", axum::routing::post(flush_errors))
.route("/errors/compact", axum::routing::post(compact_errors))
.route("/bucket-health", get(get_health))
.route("/buckets/{bucket}/objects/{*key}", put(put_bucket_object))
.route("/buckets/{bucket}/objects/{*key}", get(get_bucket_object))
.with_state(registry)
}
async fn list_buckets(State(reg): State<Arc<BucketRegistry>>) -> Json<Vec<BucketInfo>> {
Json(reg.list().await)
}
#[derive(Deserialize)]
struct ErrorQuery {
bucket: Option<String>,
since: Option<DateTime<Utc>>,
#[serde(default = "default_limit")]
limit: usize,
}
fn default_limit() -> usize { 50 }
async fn list_errors(
State(reg): State<Arc<BucketRegistry>>,
Query(q): Query<ErrorQuery>,
) -> Json<Vec<BucketErrorEvent>> {
Json(reg.journal().filter(q.bucket.as_deref(), q.since, q.limit).await)
}
#[derive(Deserialize)]
struct HealthQuery {
#[serde(default = "default_period")]
minutes: i64,
}
fn default_period() -> i64 { 5 }
async fn get_health(
State(reg): State<Arc<BucketRegistry>>,
Query(q): Query<HealthQuery>,
) -> impl IntoResponse {
Json(reg.journal().health(q.minutes).await)
}
/// Bucket-aware write. Hard-fails if the target bucket is unreachable;
/// never falls back to rescue for writes.
async fn put_bucket_object(
State(reg): State<Arc<BucketRegistry>>,
Path((bucket, key)): Path<(String, String)>,
body: bytes::Bytes,
) -> impl IntoResponse {
match reg.write_smart(&bucket, &key, body).await {
Ok(()) => (StatusCode::CREATED, format!("stored: {bucket}/{key}")).into_response(),
Err(e) => (StatusCode::SERVICE_UNAVAILABLE, e).into_response(),
}
}
/// Bucket-aware read. Falls through to the rescue bucket if the target
/// is unreachable or the key is missing; emits observability headers so
/// callers can detect the fallback.
async fn flush_errors(State(reg): State<Arc<BucketRegistry>>) -> impl IntoResponse {
match reg.journal().flush().await {
Ok(()) => (StatusCode::OK, "flushed").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
async fn compact_errors(State(reg): State<Arc<BucketRegistry>>) -> impl IntoResponse {
match reg.journal().compact().await {
Ok(stats) => Json(stats).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
async fn get_bucket_object(
State(reg): State<Arc<BucketRegistry>>,
Path((bucket, key)): Path<(String, String)>,
) -> impl IntoResponse {
match reg.read_smart(&bucket, &key).await {
Ok(outcome) => {
let mut resp = outcome.data.into_response();
if outcome.rescued {
let headers = resp.headers_mut();
headers.insert("x-lakehouse-rescue-used", "true".parse().unwrap());
headers.insert(
"x-lakehouse-original-bucket",
outcome.original_bucket.parse().unwrap(),
);
headers.insert(
"x-lakehouse-served-by",
outcome.served_by.parse().unwrap(),
);
}
resp
}
Err(e) => (StatusCode::NOT_FOUND, e).into_response(),
}
}
+4
View File
@@ -1,3 +1,7 @@
pub mod append_log;
pub mod backend;
pub mod error_journal;
pub mod federation_service;
pub mod ops;
pub mod registry;
pub mod service;
+283
View File
@@ -0,0 +1,283 @@
/// Multi-backend bucket registry — the federation foundation.
///
/// Federation rule: every `ObjectRef` belongs to exactly one named bucket.
/// The registry resolves bucket names to `object_store` backends, handles
/// rescue-bucket fallback on read failure, writes every failure to the
/// error journal, and exposes a health summary for operators.
///
/// Existing call sites can keep using `ops::*` with `registry.get(name)`.
/// New bucket-aware call sites use `registry.read_smart` / `write_smart`
/// which handle fallback + journaling automatically.
use object_store::ObjectStore;
use object_store::local::LocalFileSystem;
use serde::Serialize;
use shared::config::{BucketConfig, StorageConfig};
use shared::secrets::{BucketCredentials, SecretsProvider};
use std::collections::HashMap;
use std::sync::Arc;
use crate::error_journal::{BucketErrorEvent, ErrorJournal};
/// A registered bucket — the store handle + its configuration.
pub struct BucketEntry {
pub name: String,
pub backend: String,
pub store: Arc<dyn ObjectStore>,
pub config: BucketConfig,
}
/// Read outcome — may have been rescued.
#[derive(Debug, Clone)]
pub struct ReadOutcome {
pub data: bytes::Bytes,
pub rescued: bool,
pub original_bucket: String,
pub served_by: String,
}
/// Summary entry for GET /storage/buckets.
#[derive(Debug, Clone, Serialize)]
pub struct BucketInfo {
pub name: String,
pub backend: String,
pub reachable: bool,
pub role: BucketRole,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum BucketRole {
Primary,
Rescue,
Profile,
Tenant,
}
pub struct BucketRegistry {
buckets: HashMap<String, Arc<BucketEntry>>,
default: String,
rescue: Option<String>,
profile_root: String,
journal: ErrorJournal,
}
impl BucketRegistry {
/// Build the registry from storage config + secrets provider.
/// Back-compat: if `buckets` is empty, synthesize a `primary` bucket from
/// the legacy `root` field so pre-federation configs keep working.
pub async fn from_config(
cfg: &StorageConfig,
secrets: Arc<dyn SecretsProvider>,
) -> Result<Self, String> {
let mut buckets: HashMap<String, Arc<BucketEntry>> = HashMap::new();
let bucket_configs: Vec<BucketConfig> = if cfg.buckets.is_empty() {
vec![BucketConfig {
name: "primary".to_string(),
backend: "local".to_string(),
root: Some(cfg.root.clone()),
bucket: None,
region: None,
endpoint: None,
secret_ref: None,
}]
} else {
cfg.buckets.clone()
};
for bc in bucket_configs {
let store = build_store(&bc, secrets.as_ref()).await?;
let entry = Arc::new(BucketEntry {
name: bc.name.clone(),
backend: bc.backend.clone(),
store,
config: bc.clone(),
});
buckets.insert(bc.name.clone(), entry);
}
// Ensure `primary` always exists — it's where error journals live.
if !buckets.contains_key("primary") {
return Err("no bucket named 'primary' configured — required as error-journal home".into());
}
// Rescue bucket is optional but, if named, must exist.
if let Some(r) = &cfg.rescue_bucket {
if !buckets.contains_key(r) {
return Err(format!("rescue_bucket '{r}' not found among configured buckets"));
}
}
let journal = ErrorJournal::new(buckets.get("primary").unwrap().store.clone());
let _ = journal.load_recent().await;
Ok(Self {
buckets,
default: "primary".to_string(),
rescue: cfg.rescue_bucket.clone(),
profile_root: cfg.profile_root.clone(),
journal,
})
}
pub fn default_name(&self) -> &str { &self.default }
pub fn rescue_name(&self) -> Option<&str> { self.rescue.as_deref() }
pub fn journal(&self) -> &ErrorJournal { &self.journal }
/// Resolve a bucket name to its object store. Existing call sites use
/// this as a drop-in replacement for the old single-store pattern.
pub fn get(&self, bucket: &str) -> Result<Arc<dyn ObjectStore>, String> {
self.buckets
.get(bucket)
.map(|e| e.store.clone())
.ok_or_else(|| format!("unknown bucket: {bucket}"))
}
/// The default bucket's store — use for code paths that don't yet know
/// about buckets.
pub fn default_store(&self) -> Arc<dyn ObjectStore> {
self.buckets.get(&self.default).unwrap().store.clone()
}
/// List all registered buckets. Checks reachability by doing a trivial
/// `list` with limit 1 on each.
pub async fn list(&self) -> Vec<BucketInfo> {
let mut out = Vec::with_capacity(self.buckets.len());
for (name, entry) in &self.buckets {
let reachable = probe(&entry.store).await;
let role = self.classify(name);
out.push(BucketInfo {
name: name.clone(),
backend: entry.backend.clone(),
reachable,
role,
});
}
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
fn classify(&self, name: &str) -> BucketRole {
if name == self.default { BucketRole::Primary }
else if Some(name) == self.rescue.as_deref() { BucketRole::Rescue }
else if name.starts_with("profile:") { BucketRole::Profile }
else { BucketRole::Tenant }
}
/// Read with rescue-bucket fallback. If the target bucket fails and a
/// rescue is configured, retries against rescue. Records every failure
/// in the error journal.
pub async fn read_smart(&self, bucket: &str, key: &str) -> Result<ReadOutcome, String> {
let target = self.buckets.get(bucket)
.ok_or_else(|| format!("unknown bucket: {bucket}"))?;
match crate::ops::get(&target.store, key).await {
Ok(data) => Ok(ReadOutcome {
data, rescued: false,
original_bucket: bucket.to_string(),
served_by: bucket.to_string(),
}),
Err(err) => {
// Record failure regardless of what happens next.
self.journal.append(BucketErrorEvent::new_read(bucket, key, &err)).await;
// Try rescue, if any.
if let Some(rescue_name) = &self.rescue {
if rescue_name != bucket {
if let Some(rescue) = self.buckets.get(rescue_name) {
match crate::ops::get(&rescue.store, key).await {
Ok(data) => {
self.journal.mark_rescued_last(bucket, key).await;
return Ok(ReadOutcome {
data, rescued: true,
original_bucket: bucket.to_string(),
served_by: rescue_name.clone(),
});
}
Err(rescue_err) => {
return Err(format!(
"read '{key}' failed in '{bucket}' ({err}); rescue '{rescue_name}' also failed ({rescue_err})"
));
}
}
}
}
}
Err(format!("read '{key}' failed in '{bucket}': {err}"))
}
}
}
/// Write always goes to target. No rescue fallback for writes — writes
/// that silently vanish are the worst possible failure.
pub async fn write_smart(
&self,
bucket: &str,
key: &str,
data: bytes::Bytes,
) -> Result<(), String> {
let target = self.buckets.get(bucket)
.ok_or_else(|| format!("unknown bucket: {bucket}"))?;
match crate::ops::put(&target.store, key, data).await {
Ok(()) => Ok(()),
Err(err) => {
self.journal.append(BucketErrorEvent::new_write(bucket, key, &err)).await;
Err(format!("write '{key}' failed in '{bucket}': {err}"))
}
}
}
}
/// Trivial reachability check — try to list with limit 0.
async fn probe(store: &Arc<dyn ObjectStore>) -> bool {
use futures::StreamExt;
let mut stream = store.list(None);
// Pulling the first item confirms the store responds. Empty bucket = ok.
match stream.next().await {
Some(Ok(_)) => true,
None => true, // empty but reachable
Some(Err(_)) => false,
}
}
/// Build a concrete ObjectStore from a BucketConfig.
async fn build_store(
bc: &BucketConfig,
secrets: &dyn SecretsProvider,
) -> Result<Arc<dyn ObjectStore>, String> {
match bc.backend.as_str() {
"local" => {
let root = bc.root.as_deref()
.ok_or_else(|| format!("bucket '{}' is backend=local but has no root", bc.name))?;
std::fs::create_dir_all(root)
.map_err(|e| format!("create bucket dir '{root}': {e}"))?;
let fs = LocalFileSystem::new_with_prefix(root)
.map_err(|e| format!("init local bucket '{}': {e}", bc.name))?;
Ok(Arc::new(fs))
}
"s3" => {
let handle = bc.secret_ref.as_deref()
.ok_or_else(|| format!("s3 bucket '{}' has no secret_ref", bc.name))?;
let creds: BucketCredentials = secrets.resolve(handle).await?;
let s3_bucket = bc.bucket.as_deref()
.ok_or_else(|| format!("s3 bucket '{}' has no `bucket` name", bc.name))?;
let region = bc.region.as_deref().unwrap_or("us-east-1");
let mut builder = object_store::aws::AmazonS3Builder::new()
.with_bucket_name(s3_bucket)
.with_region(region)
.with_access_key_id(&creds.access_key)
.with_secret_access_key(&creds.secret_key);
if let Some(endpoint) = &bc.endpoint {
builder = builder.with_endpoint(endpoint);
}
let s3 = builder.build()
.map_err(|e| format!("init s3 bucket '{}': {e}", bc.name))?;
Ok(Arc::new(s3))
}
other => Err(format!("unknown backend '{other}' for bucket '{}'", bc.name)),
}
}