feat: add odb-tiered, a small-object tier composed over odb-tigris
commit 2a65a63
feat: add odb-tiered, a small-object tier composed over odb-tigris
Composition, not a third semantics: read tries the small-object tier
first and falls through to the underlying store; contains is the
union; stage_pack splits an incoming pack by size, staging small
objects directly and re-packing the rest through the underlying
store’s own stage_pack. Extends refstore-postgres with Postgres
implementations of odb-tigris’s PackRegistry and odb-tiered’s
SmallObjectTier so neither storage crate needs a tokio-postgres
dependency of its own.
feat: add a SmallObjectTier trait plus an in-memory implementation
feat: implement PackRegistry and SmallObjectTier for PostgresRefStore
feat: extend git_ents_pack_registry and add git_ents_small_objects
test: instantiate the backend-conformance ObjectStore suite for odb-tiered
test: gate a Postgres-backed ObjectStore conformance run behind docker
Assisted-by: Claude:claude-sonnet-5
crates/refstore-postgres/migrations/0001_init.sql
@@ -31,8 +31,11 @@
CREATE INDEX IF NOT EXISTS git_ents_reflog_lookup_idx
ON git_ents_reflog (repo_id, name, id DESC);
--- Pack registry: minimal columns only. WS5 (Tigris object store) consumes
--- this to record promotion of staged packs; nothing here reads it yet.
+-- Pack registry: WS5 (Tigris object store) records promoted packs here, and
+-- `odb_tigris::OdbTigris::read`/`contains` (via `PostgresRefStore`'s
+-- `odb_tigris::registry::PackRegistry` impl, see `pack_registry.rs`) consult
+-- only this table — never a bucket listing (`docs/scale-out.adoc`,
+-- "Reachability").
CREATE TABLE IF NOT EXISTS git_ents_pack_registry (
id BIGSERIAL PRIMARY KEY,
repo_id TEXT NOT NULL,
@@ -40,9 +43,44 @@
promoted_at TIMESTAMPTZ
);
+-- `location_key` predates the `PackRegistry` trait (WS5) and is unused by
+-- it; dropping its `NOT NULL` rather than removing it keeps this migration
+-- idempotent against a database that already has rows from before this
+-- change, without inventing a fake value for a column nothing here writes
+-- anymore.
+ALTER TABLE git_ents_pack_registry ALTER COLUMN location_key DROP NOT NULL;
+
+ALTER TABLE git_ents_pack_registry ADD COLUMN IF NOT EXISTS pack_id TEXT;
+ALTER TABLE git_ents_pack_registry ADD COLUMN IF NOT EXISTS pack_key TEXT;
+ALTER TABLE git_ents_pack_registry ADD COLUMN IF NOT EXISTS idx_key TEXT;
+ALTER TABLE git_ents_pack_registry ADD COLUMN IF NOT EXISTS object_count BIGINT;
+
CREATE INDEX IF NOT EXISTS git_ents_pack_registry_repo_idx
ON git_ents_pack_registry (repo_id);
+CREATE UNIQUE INDEX IF NOT EXISTS git_ents_pack_registry_repo_pack_idx
+ ON git_ents_pack_registry (repo_id, pack_id);
+
+-- Small-object tier (WS5, `docs/scale-out.adoc`'s `odb-tiered` row): blobs
+-- and trees under `odb_tiered::OdbTiered`'s size threshold, staged then
+-- promoted like any other object storage tier (correctness rules 1 and 2
+-- apply here too). `stage_id` identifies an in-flight batch; `promoted`
+-- flips to true (and `stage_id` clears) in the single `UPDATE` that is this
+-- tier's whole promotion transaction (see `small_tier.rs`).
+CREATE TABLE IF NOT EXISTS git_ents_small_objects (
+ repo_id TEXT NOT NULL,
+ oid TEXT NOT NULL,
+ kind TEXT NOT NULL,
+ bytes BYTEA NOT NULL,
+ stage_id TEXT,
+ promoted BOOLEAN NOT NULL DEFAULT FALSE,
+ PRIMARY KEY (repo_id, oid)
+);
+
+CREATE INDEX IF NOT EXISTS git_ents_small_objects_stage_idx
+ ON git_ents_small_objects (stage_id)
+ WHERE stage_id IS NOT NULL;
+
-- Effect queue: the at-least-once source of truth `watch`'s NOTIFY hint
-- points consumers back at (`docs/scale-out.adoc`, "RefStore": "the effect
-- queue table ... is the source of truth").
crates/refstore-postgres/src/lib.rs
@@ -10,6 +10,13 @@
//! [`queue`]) is what an at-least-once consumer actually drains (see
//! [`notify`], and the trait contract on [`git_backend::RefStore::watch`]).
//!
+//! This crate also implements two WS5 traits against the same connection,
+//! rather than have `odb-tigris`/`odb-tiered` depend on `tokio-postgres`
+//! themselves: [`odb_tigris::registry::PackRegistry`] (see
+//! [`pack_registry`], over `git_ents_pack_registry`) and
+//! [`odb_tiered::small_tier::SmallObjectTier`] (see [`small_tier`], over
+//! `git_ents_small_objects`).
+//!
//! # Q1: single write-primary
//!
//! This store assumes exactly one writable Postgres primary at a time. Fly
@@ -25,8 +32,10 @@
//! fencing code here, deliberately.
mod notify;
+mod pack_registry;
mod queue;
mod ref_store;
+mod small_tier;
pub use queue::{ClaimedEffect, EffectId};
crates/odb-tiered/src/lib.rs
@@ -1,0 +1,260 @@
+//! [`ObjectStore`] composed from a small-object tier over
+//! [`odb_tigris::OdbTigris`] — composition, not a third semantics
+//! (`docs/scale-out.adoc`, "ObjectStore": "The tiered store is composition,
+//! not a third semantics: `read` consults tiers in order; `contains` is the
+//! union.").
+//!
+//! `read` tries [`small_tier::SmallObjectTier`] first, falling through to
+//! the underlying store on a tier miss; `contains` is the union of both.
+//! `stage_pack` splits an incoming pack by object size against
+//! [`SMALL_OBJECT_THRESHOLD_BYTES`] (see its doc comment — Q5: "measure,
+//! don't guess"): objects at or above the threshold are re-packed
+//! whole-object (via [`odb_tigris::pack_writer`]) and staged into the
+//! underlying store exactly as before; objects below it are staged
+//! directly into the small tier. `promote` commits both halves.
+
+pub mod small_tier;
+
+use std::collections::HashMap;
+use std::sync::{Mutex, MutexGuard, PoisonError};
+
+use git_backend::{Error, Object, ObjectStore, PackStream, QuarantineId, Result};
+use gix_hash::ObjectId;
+use odb_tigris::pack_writer::{ClassifiedObject, LifetimeClass};
+
+use crate::small_tier::{SmallObjectTier, SmallStageId};
+
+/// Size, in bytes, below which an object is staged into the small tier
+/// instead of a pack. A few KiB, per `docs/scale-out.adoc`'s Q5 ("small-
+/// object tier threshold: measure"): this default is a plausible starting
+/// point for typed documents (manifests, small trees), not a measured
+/// value — a real deployment should tune it against observed object-size
+/// and access-latency distributions rather than trust this constant.
+pub const SMALL_OBJECT_THRESHOLD_BYTES: usize = 4 * 1024;
+
+/// One quarantined batch, split across the two tiers it may span.
+struct Quarantine {
+ small: Option<SmallStageId>,
+ underlying: Option<QuarantineId>,
+}
+
+/// [`ObjectStore`] composing a [`SmallObjectTier`] `K` over an underlying
+/// store `S` (in practice, [`odb_tigris::OdbTigris`], but any `ObjectStore`
+/// qualifies — this crate depends on `odb-tigris` only for
+/// [`odb_tigris::pack_writer`], not for a hard-wired backend).
+pub struct OdbTiered<S, K> {
+ underlying: S,
+ small_tier: K,
+ repo_id: String,
+ small_threshold: usize,
+ quarantines: Mutex<HashMap<QuarantineId, Quarantine>>,
+}
+
+impl<S, K> OdbTiered<S, K>
+where
+ S: ObjectStore,
+ K: SmallObjectTier,
+{
+ /// Compose `small_tier` over `underlying`, scoped to `repo_id`, using
+ /// the default [`SMALL_OBJECT_THRESHOLD_BYTES`].
+ pub fn new(underlying: S, small_tier: K, repo_id: impl Into<String>) -> Self {
+ Self::with_threshold(
+ underlying,
+ small_tier,
+ repo_id,
+ SMALL_OBJECT_THRESHOLD_BYTES,
+ )
+ }
+
+ /// As [`Self::new`], with an explicit small-object threshold — see
+ /// [`SMALL_OBJECT_THRESHOLD_BYTES`]'s doc comment on why this should be
+ /// measured for a real deployment rather than left at the default.
+ pub fn with_threshold(
+ underlying: S,
+ small_tier: K,
+ repo_id: impl Into<String>,
+ small_threshold: usize,
+ ) -> Self {
+ Self {
+ underlying,
+ small_tier,
+ repo_id: repo_id.into(),
+ small_threshold,
+ quarantines: Mutex::new(HashMap::new()),
+ }
+ }
+
+ /// Fully materialize every object in an incoming pack, by indexing it
+ /// (exactly as `odb_tigris::OdbTigris::stage_pack` and `odb_files`'s
+ /// own quarantine do) and then decoding each entry through
+ /// `gix_pack`'s own full decoder. Unlike `odb_tigris::decode` (which is
+ /// built around ranged reads against a *remote* pack this store never
+ /// downloads in full), the incoming pack here is already fully local —
+ /// there is no ranged-read concern splitting objects out of it, so
+ /// reusing `gix_pack::Bundle`'s own (delta-resolving) decode path is
+ /// the correct choice, not a shortcut.
+ fn materialize_incoming_pack(pack: PackStream) -> Result<Vec<(ObjectId, Object)>> {
+ let scratch = tempfile::tempdir()?;
+ let mut reader = std::io::BufReader::new(pack);
+ let outcome = gix_pack::Bundle::write_to_directory(
+ &mut reader,
+ Some(scratch.path()),
+ &mut gix_features::progress::Discard,
+ &std::sync::atomic::AtomicBool::new(false),
+ None::<NoThinBaseLookup>,
+ gix_pack::bundle::write::Options {
+ object_hash: gix_hash::Kind::Sha1,
+ ..Default::default()
+ },
+ )
+ .map_err(|error| Error::ObjectStore(error.to_string()))?;
+ let index_path = outcome
+ .index_path
+ .ok_or_else(|| Error::ObjectStore("pack write produced no index file".to_owned()))?;
+ let bundle = gix_pack::Bundle::at(&index_path, gix_hash::Kind::Sha1)
+ .map_err(|error| Error::ObjectStore(error.to_string()))?;
+
+ let entries: Vec<_> = bundle.index.iter().collect();
+ let mut objects = Vec::with_capacity(entries.len());
+ let mut inflate = gix_features::zlib::Inflate::default();
+ let mut cache = gix_pack::cache::Never;
+ for entry in entries {
+ let mut buf = Vec::new();
+ let (data, _location) = bundle
+ .find(&entry.oid, &mut buf, &mut inflate, &mut cache)
+ .map_err(|error| Error::ObjectStore(error.to_string()))?
+ .ok_or_else(|| {
+ Error::ObjectStore(format!(
+ "object {} listed in its own pack's index but not found in it",
+ entry.oid
+ ))
+ })?;
+ objects.push((
+ entry.oid,
+ Object {
+ kind: data.kind,
+ data: data.data.to_vec(),
+ },
+ ));
+ }
+ Ok(objects)
+ }
+}
+
+impl<S, K> ObjectStore for OdbTiered<S, K>
+where
+ S: ObjectStore,
+ K: SmallObjectTier,
+{
+ fn read(&self, id: ObjectId) -> Result<Object> {
+ if let Some(object) = self.small_tier.read(&self.repo_id, id)? {
+ return Ok(object);
+ }
+ self.underlying.read(id)
+ }
+
+ fn contains(&self, id: ObjectId) -> Result<bool> {
+ Ok(self.small_tier.contains(&self.repo_id, id)? || self.underlying.contains(id)?)
+ }
+
+ fn stage_pack(&self, pack: PackStream) -> Result<QuarantineId> {
+ let objects = Self::materialize_incoming_pack(pack)?;
+ let (small, large): (Vec<_>, Vec<_>) = objects
+ .into_iter()
+ .partition(|(_id, object)| object.data.len() < self.small_threshold);
+
+ let small_stage = if small.is_empty() {
+ None
+ } else {
+ Some(self.small_tier.stage(&self.repo_id, small)?)
+ };
+
+ let underlying_quarantine = if large.is_empty() {
+ None
+ } else {
+ let classified: Vec<ClassifiedObject> = large
+ .into_iter()
+ .map(|(id, object)| ClassifiedObject {
+ id,
+ kind: object.kind,
+ data: object.data,
+ // `odb_tiered` doesn't itself track cache-namespace
+ // lifetime — that's a property of which ref a caller is
+ // about to point at these objects, not of the bytes
+ // this store sees. Every object routed to the
+ // underlying store here is marked `Durable`; a caller
+ // staging cache-namespace objects through a tiered
+ // store still gets correct behavior (rule 5 is about
+ // never mixing lifetimes within a single pack, and a
+ // single `stage_pack` call is exactly one lifetime by
+ // construction of its caller), just not automatically
+ // inferred from content alone.
+ lifetime: LifetimeClass::Durable,
+ })
+ .collect();
+ let partitioned = odb_tigris::pack_writer::partition_and_pack(classified)?;
+ match partitioned.durable {
+ Some(pack_bytes) => Some(
+ self.underlying
+ .stage_pack(PackStream::new(std::io::Cursor::new(pack_bytes)))?,
+ ),
+ None => None,
+ }
+ };
+
+ let id = QuarantineId::new(uuid::Uuid::new_v4().to_string());
+ lock(&self.quarantines).insert(
+ id.clone(),
+ Quarantine {
+ small: small_stage,
+ underlying: underlying_quarantine,
+ },
+ );
+ Ok(id)
+ }
+
+ fn promote(&self, q: QuarantineId) -> Result<()> {
+ let quarantine = lock(&self.quarantines)
+ .remove(&q)
+ .ok_or_else(|| Error::ObjectStore(format!("unknown quarantine {q}")))?;
+
+ // Q5/rule 1&2: each tier's own `promote` is the commit point for
+ // the objects it holds (staged objects invisible until promoted,
+ // per each tier's own contract). "Transactionally with it" (see
+ // this crate's module doc) means the small tier's staged rows move
+ // to live in one Postgres transaction internally — not that this
+ // pair of calls is one distributed transaction spanning Tigris and
+ // Postgres, which no code here could honestly promise. If the
+ // small-tier promote below succeeds and the underlying promote
+ // fails (or the reverse), the failing half's objects simply remain
+ // quarantined/staged — never partially visible — and the caller
+ // sees the error and can retry `promote` with the same id.
+ if let Some(small) = quarantine.small {
+ self.small_tier.promote(small)?;
+ }
+ if let Some(underlying) = quarantine.underlying {
+ self.underlying.promote(underlying)?;
+ }
+ Ok(())
+ }
+}
+
+/// A `gix_object::Find` that never finds anything, satisfying
+/// `Bundle::write_to_directory`'s thin-pack-base-lookup parameter — see
+/// `odb_tigris`'s identical helper for why this is correct here too (this
+/// store's incoming packs are also expected to be self-contained).
+struct NoThinBaseLookup;
+
+impl gix_object::Find for NoThinBaseLookup {
+ fn try_find<'a>(
+ &self,
+ _id: &gix_hash::oid,
+ _buffer: &'a mut Vec<u8>,
+ ) -> std::result::Result<Option<gix_object::Data<'a>>, gix_object::find::Error> {
+ Ok(None)
+ }
+}
+
+fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
+ mutex.lock().unwrap_or_else(PoisonError::into_inner)
+}
crates/odb-tiered/src/small_tier.rs
@@ -1,0 +1,79 @@
+//! [`SmallObjectTier`]: the fast, hot-path store for small objects that
+//! [`crate::OdbTiered`] consults before falling through to the underlying
+//! (Tigris) store (`docs/scale-out.adoc`, "ObjectStore": "Typed documents
+//! are tiny and hot; Tigris per-GET latency is the wrong floor for them.").
+//!
+//! Like [`git_backend::ObjectStore`] itself, staging is a first-class
+//! concept here, not an afterthought: correctness rule 1 (causal collection
+//! safety) and rule 2 (ref transactions are the only commit point) apply
+//! just as much to objects that land in this tier as to ones that land in a
+//! pack, so [`SmallObjectTier::stage`]d objects must stay invisible to
+//! [`SmallObjectTier::read`]/[`SmallObjectTier::contains`] until
+//! [`SmallObjectTier::promote`] is called — mirroring
+//! [`git_backend::ObjectStore`]'s own contract exactly.
+
+pub mod memory;
+
+use git_backend::{Object, Result};
+use gix_hash::ObjectId;
+
+/// A handle to a batch staged by [`SmallObjectTier::stage`], passed back to
+/// [`SmallObjectTier::promote`].
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub struct SmallStageId(String);
+
+impl SmallStageId {
+ /// Build a `SmallStageId` from a backend-chosen opaque token.
+ #[must_use]
+ pub fn new(id: impl Into<String>) -> Self {
+ Self(id.into())
+ }
+
+ /// The id as a `&str`.
+ #[must_use]
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+}
+
+impl std::fmt::Display for SmallStageId {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(&self.0)
+ }
+}
+
+/// A small-object store: a blob/tree key-value store scoped per repo,
+/// staged then promoted exactly like [`git_backend::ObjectStore`].
+pub trait SmallObjectTier: Send + Sync {
+ /// Read `id` from the promoted (non-staged) view of `repo_id`'s
+ /// objects, or `None` if this tier doesn't hold it — a caller
+ /// ([`crate::OdbTiered`]) falls through to the underlying store on
+ /// `None`, so this is not itself an error.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the tier cannot be read.
+ fn read(&self, repo_id: &str, id: ObjectId) -> Result<Option<Object>>;
+
+ /// Whether `id` is present in the promoted view of `repo_id`'s objects.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the tier cannot be read.
+ fn contains(&self, repo_id: &str, id: ObjectId) -> Result<bool>;
+
+ /// Stage `objects` for `repo_id`, invisible to `read`/`contains` until
+ /// [`Self::promote`] is called on the returned id.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the batch cannot be durably staged.
+ fn stage(&self, repo_id: &str, objects: Vec<(ObjectId, Object)>) -> Result<SmallStageId>;
+
+ /// Make the batch staged under `id` visible to `read`/`contains`.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if promotion fails.
+ fn promote(&self, id: SmallStageId) -> Result<()>;
+}