Add durable manifest-rooted result inventories

This commit is contained in:
ms2k 2026-07-17 03:26:57 +09:00
commit 14988e7632
5 changed files with 1152 additions and 0 deletions

View file

@ -0,0 +1,62 @@
use remora_durable::DurableError;
use remora_target::TargetError;
#[derive(Debug, thiserror::Error)]
pub enum ResultError {
#[error("database operation failed")]
Database(#[source] sqlx::Error),
#[error("durable substrate operation failed")]
Durable(#[source] DurableError),
#[error("target operation failed")]
Target(#[source] TargetError),
#[error("manifest streaming write failed")]
ManifestWrite(#[source] remora_stream::StreamError),
#[error("invalid result member path")]
InvalidMemberPath,
#[error("invalid exact object identity")]
InvalidObjectIdentity,
#[error("result inventory already exists")]
InventoryExists,
#[error("result inventory is missing")]
InventoryNotFound,
#[error("result inventory is not open")]
InventoryNotOpen,
#[error("result inventory is empty")]
EmptyInventory,
#[error("duplicate result member identity")]
DuplicateMember,
#[error("result member path collides after canonicalization")]
PathCollision,
#[error("result aggregate integrity validation failed")]
Integrity,
#[error("result descriptor belongs to a different attempt")]
StaleAttempt,
#[error("unsupported result manifest schema version {0}")]
UnsupportedManifestVersion(u32),
#[error("result is not sealed")]
NotSealed,
#[error("result member is missing")]
MemberNotFound,
#[error("invalid page size")]
InvalidPageSize,
#[error("result size exceeds supported durable range")]
SizeOverflow,
}
impl From<sqlx::Error> for ResultError {
fn from(value: sqlx::Error) -> Self {
Self::Database(value)
}
}
impl From<DurableError> for ResultError {
fn from(value: DurableError) -> Self {
Self::Durable(value)
}
}
impl From<TargetError> for ResultError {
fn from(value: TargetError) -> Self {
Self::Target(value)
}
}

View file

@ -0,0 +1,483 @@
use futures_util::TryStreamExt;
use remora_domain::{MemberPath, OpaqueId};
use remora_stream::TargetBody;
use remora_target::{
ContentSha256, CreateObjectRequest, ObjectIdentity, StoredObject, TargetStore,
validate_attempt_key,
};
use serde::Serialize;
use sqlx::PgPool;
use crate::ResultError;
use crate::types::{
CommittedResult, MANIFEST_SCHEMA_VERSION, PageSize, PublicationInput, ResultMember,
ResultMemberPage,
};
#[derive(Clone)]
pub struct ResultInventory {
pool: PgPool,
}
impl ResultInventory {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
pub async fn create(&self, attempt_id: &OpaqueId) -> Result<(), ResultError> {
let result = sqlx::query("INSERT INTO result_inventories (attempt_id) VALUES ($1)")
.bind(attempt_id.as_str())
.execute(&self.pool)
.await;
match result {
Ok(_) => Ok(()),
Err(error) if is_unique_violation(&error, "result_inventories_pkey") => {
Err(ResultError::InventoryExists)
}
Err(error) => Err(error.into()),
}
}
pub async fn add_member(
&self,
attempt_id: &OpaqueId,
member: &ResultMember,
) -> Result<(), ResultError> {
validate_member_identity(attempt_id, member)?;
let size = i64::try_from(member.size()).map_err(|_| ResultError::SizeOverflow)?;
let mut tx = self.pool.begin().await?;
let state: Option<String> = sqlx::query_scalar(
"SELECT state FROM result_inventories WHERE attempt_id = $1 FOR UPDATE",
)
.bind(attempt_id.as_str())
.fetch_optional(tx.as_mut())
.await?;
match state.as_deref() {
None => return Err(ResultError::InventoryNotFound),
Some("open") => {}
Some(_) => return Err(ResultError::InventoryNotOpen),
}
let insert = sqlx::query(
"INSERT INTO result_inventory_members (
attempt_id, member_id, path, size, object_store, object_bucket,
object_key, object_version_id, object_sha256
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
)
.bind(attempt_id.as_str())
.bind(member.id().as_str())
.bind(member.path())
.bind(size)
.bind(member.object().store())
.bind(member.object().bucket())
.bind(member.object().key())
.bind(member.object().version_id())
.bind(member.content_sha256().as_str())
.execute(tx.as_mut())
.await;
if let Err(error) = insert {
return match error
.as_database_error()
.and_then(|error| error.constraint())
{
Some("result_inventory_members_pkey") => Err(ResultError::DuplicateMember),
Some("result_inventory_members_attempt_id_path_key") => {
Err(ResultError::PathCollision)
}
_ => Err(error.into()),
};
}
sqlx::query(
"UPDATE result_inventories
SET member_count = member_count + 1, total_bytes = total_bytes + $2
WHERE attempt_id = $1",
)
.bind(attempt_id.as_str())
.bind(size)
.execute(tx.as_mut())
.await?;
tx.commit().await?;
Ok(())
}
pub async fn close(&self, attempt_id: &OpaqueId) -> Result<(), ResultError> {
let mut tx = self.pool.begin().await?;
let state: Option<(String, i64)> = sqlx::query_as(
"SELECT state, member_count FROM result_inventories WHERE attempt_id = $1 FOR UPDATE",
)
.bind(attempt_id.as_str())
.fetch_optional(tx.as_mut())
.await?;
let Some((state, count)) = state else {
return Err(ResultError::InventoryNotFound);
};
if count == 0 {
return Err(ResultError::EmptyInventory);
}
if state == "open" {
sqlx::query("UPDATE result_inventories SET state = 'closed' WHERE attempt_id = $1")
.bind(attempt_id.as_str())
.execute(tx.as_mut())
.await?;
}
tx.commit().await?;
Ok(())
}
pub async fn create_manifest(
&self,
attempt_id: &OpaqueId,
manifest_id: &OpaqueId,
target: &TargetStore,
) -> Result<PublicationInput, ResultError> {
self.close(attempt_id).await?;
if let Some(existing) = self.publication_input(attempt_id).await? {
return if existing.manifest_id == *manifest_id {
Ok(existing)
} else {
Err(ResultError::NotSealed)
};
}
self.validate_members(attempt_id, target).await?;
let (member_count, total_bytes) = self.inventory_totals(attempt_id).await?;
let request = CreateObjectRequest::for_attempt_manifest(attempt_id, manifest_id);
let mut body = target.open_create_only(&request).await?;
body.write_all(&manifest_header(attempt_id))
.await
.map_err(ResultError::ManifestWrite)?;
let mut rows = sqlx::query_as::<_, MemberRow>(MEMBER_SELECT_ORDERED)
.bind(attempt_id.as_str())
.fetch(&self.pool);
let mut first = true;
while let Some(row) = rows.try_next().await? {
if !first {
body.write_all(b",")
.await
.map_err(ResultError::ManifestWrite)?;
}
first = false;
let member = row.into_member()?;
let encoded = serde_json::to_vec(&member).map_err(|_| ResultError::Integrity)?;
body.write_all(&encoded)
.await
.map_err(ResultError::ManifestWrite)?;
}
body.write_all(&manifest_footer(member_count, total_bytes))
.await
.map_err(ResultError::ManifestWrite)?;
let written = body.finish_object().await?;
let manifest = StoredObject {
identity: written.identity,
content_sha256: written.content_sha256,
size: written.bytes_written,
};
let input = PublicationInput {
attempt_id: attempt_id.clone(),
manifest_id: manifest_id.clone(),
schema_version: MANIFEST_SCHEMA_VERSION,
manifest,
member_count,
total_bytes,
};
self.persist_sealed(&input).await?;
Ok(input)
}
pub async fn validate_publication_input(
&self,
attempt_id: &OpaqueId,
input: &PublicationInput,
) -> Result<(), ResultError> {
if input.attempt_id != *attempt_id {
return Err(ResultError::StaleAttempt);
}
require_supported_version(input.schema_version)?;
let expected_key = remora_target::attempt_manifest_key(attempt_id, &input.manifest_id);
if input.manifest.identity.key != expected_key {
return Err(ResultError::InvalidObjectIdentity);
}
match self.publication_input(attempt_id).await? {
Some(stored) if stored == *input => Ok(()),
Some(_) => Err(ResultError::Integrity),
None => Err(ResultError::NotSealed),
}
}
pub async fn resolve_committed(
&self,
committed: &CommittedResult,
) -> Result<PublicationInput, ResultError> {
require_supported_version(committed.schema_version)?;
self.validate_publication_input(&committed.attempt_id, committed)
.await?;
Ok(committed.clone())
}
pub async fn page_members(
&self,
committed: &CommittedResult,
after_path: Option<&str>,
page_size: PageSize,
) -> Result<ResultMemberPage, ResultError> {
self.resolve_committed(committed).await?;
let after = after_path
.map(MemberPath::parse)
.transpose()
.map_err(|_| ResultError::InvalidMemberPath)?
.map(|path| path.canonical())
.unwrap_or_default();
let fetch = i64::from(page_size.get()) + 1;
let rows = sqlx::query_as::<_, MemberRow>(MEMBER_PAGE_SELECT)
.bind(committed.attempt_id.as_str())
.bind(after)
.bind(fetch)
.fetch_all(&self.pool)
.await?;
let has_more = rows.len() > page_size.get() as usize;
let mut items = rows
.into_iter()
.take(page_size.get() as usize)
.map(MemberRow::into_member)
.collect::<Result<Vec<_>, _>>()?;
let next_after = has_more
.then(|| items.last().map(|item| item.path().to_owned()))
.flatten();
items.shrink_to_fit();
Ok(ResultMemberPage { items, next_after })
}
pub async fn lookup_member(
&self,
committed: &CommittedResult,
member_id: &OpaqueId,
) -> Result<ResultMember, ResultError> {
self.resolve_committed(committed).await?;
sqlx::query_as::<_, MemberRow>(MEMBER_LOOKUP_SELECT)
.bind(committed.attempt_id.as_str())
.bind(member_id.as_str())
.fetch_optional(&self.pool)
.await?
.ok_or(ResultError::MemberNotFound)?
.into_member()
}
async fn validate_members(
&self,
attempt_id: &OpaqueId,
target: &TargetStore,
) -> Result<(), ResultError> {
let mut rows = sqlx::query_as::<_, MemberRow>(MEMBER_SELECT)
.bind(attempt_id.as_str())
.fetch(&self.pool);
while let Some(row) = rows.try_next().await? {
let member = row.into_member()?;
validate_member_identity(attempt_id, &member)?;
let observed = target.verify_exact(&member.exact_identity()).await?;
if observed.identity != member.exact_identity()
|| observed.size != member.size()
|| observed.content_sha256.as_ref() != Some(member.content_sha256())
{
return Err(ResultError::Integrity);
}
}
Ok(())
}
async fn inventory_totals(&self, attempt_id: &OpaqueId) -> Result<(u64, u64), ResultError> {
let (count, bytes): (i64, i64) = sqlx::query_as(
"SELECT member_count, total_bytes FROM result_inventories WHERE attempt_id = $1",
)
.bind(attempt_id.as_str())
.fetch_one(&self.pool)
.await?;
Ok((count as u64, bytes as u64))
}
async fn persist_sealed(&self, input: &PublicationInput) -> Result<(), ResultError> {
let result = sqlx::query(
"UPDATE result_inventories SET
state = 'sealed', manifest_schema_version = $2, manifest_id = $3,
manifest_store = $4, manifest_bucket = $5, manifest_key = $6,
manifest_version_id = $7, manifest_sha256 = $8, manifest_size = $9
WHERE attempt_id = $1 AND state = 'closed'",
)
.bind(input.attempt_id.as_str())
.bind(i32::try_from(input.schema_version).map_err(|_| ResultError::SizeOverflow)?)
.bind(input.manifest_id.as_str())
.bind(&input.manifest.identity.target_store_id)
.bind(&input.manifest.identity.bucket)
.bind(&input.manifest.identity.key)
.bind(&input.manifest.identity.version_id)
.bind(input.manifest.content_sha256.as_str())
.bind(i64::try_from(input.manifest.size).map_err(|_| ResultError::SizeOverflow)?)
.execute(&self.pool)
.await?;
if result.rows_affected() != 1 {
return Err(ResultError::NotSealed);
}
Ok(())
}
async fn publication_input(
&self,
attempt_id: &OpaqueId,
) -> Result<Option<PublicationInput>, ResultError> {
let row = sqlx::query_as::<_, InventoryRow>(
"SELECT state, manifest_schema_version, manifest_id, manifest_store,
manifest_bucket, manifest_key, manifest_version_id, manifest_sha256,
manifest_size, member_count, total_bytes
FROM result_inventories WHERE attempt_id = $1",
)
.bind(attempt_id.as_str())
.fetch_optional(&self.pool)
.await?
.ok_or(ResultError::InventoryNotFound)?;
row.into_publication_input(attempt_id)
}
}
const MEMBER_SELECT: &str = "SELECT member_id, path, size, object_store, object_bucket,
object_key, object_version_id, object_sha256
FROM result_inventory_members WHERE attempt_id = $1";
const MEMBER_SELECT_ORDERED: &str = "SELECT member_id, path, size, object_store, object_bucket,
object_key, object_version_id, object_sha256
FROM result_inventory_members WHERE attempt_id = $1 ORDER BY path, member_id";
const MEMBER_PAGE_SELECT: &str = "SELECT member_id, path, size, object_store, object_bucket,
object_key, object_version_id, object_sha256
FROM result_inventory_members
WHERE attempt_id = $1 AND path > $2 ORDER BY path, member_id LIMIT $3";
const MEMBER_LOOKUP_SELECT: &str = "SELECT member_id, path, size, object_store, object_bucket,
object_key, object_version_id, object_sha256
FROM result_inventory_members WHERE attempt_id = $1 AND member_id = $2";
#[derive(sqlx::FromRow)]
struct MemberRow {
member_id: String,
path: String,
size: i64,
object_store: String,
object_bucket: String,
object_key: String,
object_version_id: Option<String>,
object_sha256: String,
}
impl MemberRow {
fn into_member(self) -> Result<ResultMember, ResultError> {
let id = OpaqueId::parse(self.member_id).map_err(|_| ResultError::Integrity)?;
let size = u64::try_from(self.size).map_err(|_| ResultError::Integrity)?;
let digest =
ContentSha256::parse(self.object_sha256).map_err(|_| ResultError::Integrity)?;
ResultMember::new(
id,
&self.path,
size,
ObjectIdentity {
target_store_id: self.object_store,
bucket: self.object_bucket,
key: self.object_key,
version_id: self.object_version_id,
},
digest,
)
}
}
#[derive(sqlx::FromRow)]
struct InventoryRow {
state: String,
manifest_schema_version: Option<i32>,
manifest_id: Option<String>,
manifest_store: Option<String>,
manifest_bucket: Option<String>,
manifest_key: Option<String>,
manifest_version_id: Option<String>,
manifest_sha256: Option<String>,
manifest_size: Option<i64>,
member_count: i64,
total_bytes: i64,
}
impl InventoryRow {
fn into_publication_input(
self,
attempt_id: &OpaqueId,
) -> Result<Option<PublicationInput>, ResultError> {
if self.state != "sealed" {
return Ok(None);
}
let schema_version =
u32::try_from(self.manifest_schema_version.ok_or(ResultError::Integrity)?)
.map_err(|_| ResultError::Integrity)?;
let manifest_id = OpaqueId::parse(self.manifest_id.ok_or(ResultError::Integrity)?)
.map_err(|_| ResultError::Integrity)?;
let digest = ContentSha256::parse(self.manifest_sha256.ok_or(ResultError::Integrity)?)
.map_err(|_| ResultError::Integrity)?;
Ok(Some(PublicationInput {
attempt_id: attempt_id.clone(),
manifest_id,
schema_version,
manifest: StoredObject {
identity: ObjectIdentity {
target_store_id: self.manifest_store.ok_or(ResultError::Integrity)?,
bucket: self.manifest_bucket.ok_or(ResultError::Integrity)?,
key: self.manifest_key.ok_or(ResultError::Integrity)?,
version_id: self.manifest_version_id,
},
content_sha256: digest,
size: u64::try_from(self.manifest_size.ok_or(ResultError::Integrity)?)
.map_err(|_| ResultError::Integrity)?,
},
member_count: u64::try_from(self.member_count).map_err(|_| ResultError::Integrity)?,
total_bytes: u64::try_from(self.total_bytes).map_err(|_| ResultError::Integrity)?,
}))
}
}
fn validate_member_identity(
attempt_id: &OpaqueId,
member: &ResultMember,
) -> Result<(), ResultError> {
validate_attempt_key(member.object().key()).map_err(|_| ResultError::InvalidObjectIdentity)?;
let prefix = format!("_remora/a/{}/o/", attempt_id.as_str());
if !member.object().key().starts_with(&prefix) {
return Err(ResultError::StaleAttempt);
}
Ok(())
}
fn require_supported_version(version: u32) -> Result<(), ResultError> {
if version != MANIFEST_SCHEMA_VERSION {
return Err(ResultError::UnsupportedManifestVersion(version));
}
Ok(())
}
fn is_unique_violation(error: &sqlx::Error, constraint: &str) -> bool {
error
.as_database_error()
.is_some_and(|error| error.constraint() == Some(constraint))
}
#[derive(Serialize)]
struct ManifestHeader<'a> {
schema_version: u32,
attempt_id: &'a OpaqueId,
}
fn manifest_header(attempt_id: &OpaqueId) -> Vec<u8> {
let header = ManifestHeader {
schema_version: MANIFEST_SCHEMA_VERSION,
attempt_id,
};
let mut bytes = serde_json::to_vec(&header).expect("manifest header is serializable");
bytes.pop();
bytes.extend_from_slice(b",\"members\":[");
bytes
}
fn manifest_footer(member_count: u64, total_bytes: u64) -> Vec<u8> {
format!("],\"member_count\":{member_count},\"total_bytes\":{total_bytes}}}\n").into_bytes()
}

View file

@ -0,0 +1,14 @@
//! Durable, manifest-rooted result inventory and exact access primitives.
mod error;
mod inventory;
mod migrate;
mod types;
pub use error::ResultError;
pub use inventory::ResultInventory;
pub use migrate::{migrate, migrator};
pub use types::{
CommittedResult, MANIFEST_SCHEMA_VERSION, ManifestObjectIdentity, PageSize, PublicationInput,
ResultMember, ResultMemberPage,
};

View file

@ -0,0 +1,17 @@
use remora_durable::DurableError;
use sqlx::PgPool;
const MIGRATION_TABLE: &str = "_remora_result_migrations";
pub async fn migrate(pool: &PgPool) -> Result<(), DurableError> {
let mut migrator = sqlx::migrate!("./migrations");
migrator.dangerous_set_table_name(MIGRATION_TABLE);
migrator.run(pool).await?;
Ok(())
}
pub fn migrator() -> sqlx::migrate::Migrator {
let mut migrator = sqlx::migrate!("./migrations");
migrator.dangerous_set_table_name(MIGRATION_TABLE);
migrator
}

View file

@ -0,0 +1,165 @@
use std::num::NonZeroU32;
use remora_domain::{MemberPath, OpaqueId};
use remora_target::{ContentSha256, ObjectIdentity, StoredObject};
use serde::Serialize;
use crate::ResultError;
pub const MANIFEST_SCHEMA_VERSION: u32 = 1;
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct ResultMember {
id: OpaqueId,
path: String,
size: u64,
object: ManifestObjectIdentity,
}
impl ResultMember {
pub fn new(
id: OpaqueId,
path: &str,
size: u64,
object: ObjectIdentity,
content_sha256: ContentSha256,
) -> Result<Self, ResultError> {
let path = MemberPath::parse(path)
.map_err(|_| ResultError::InvalidMemberPath)?
.canonical();
Ok(Self {
id,
path,
size,
object: ManifestObjectIdentity::new(object, content_sha256),
})
}
#[must_use]
pub fn exact_identity(&self) -> ObjectIdentity {
self.object.exact_identity()
}
#[must_use]
pub fn content_sha256(&self) -> &ContentSha256 {
&self.object.sha256
}
#[must_use]
pub fn id(&self) -> &OpaqueId {
&self.id
}
#[must_use]
pub fn path(&self) -> &str {
&self.path
}
#[must_use]
pub fn size(&self) -> u64 {
self.size
}
#[must_use]
pub fn object(&self) -> &ManifestObjectIdentity {
&self.object
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct ManifestObjectIdentity {
store: String,
bucket: String,
key: String,
#[serde(skip_serializing_if = "Option::is_none")]
version_id: Option<String>,
#[serde(serialize_with = "serialize_sha256")]
sha256: ContentSha256,
}
fn serialize_sha256<S>(digest: &ContentSha256, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(digest.as_str())
}
impl ManifestObjectIdentity {
fn new(identity: ObjectIdentity, sha256: ContentSha256) -> Self {
Self {
store: identity.target_store_id,
bucket: identity.bucket,
key: identity.key,
version_id: identity.version_id,
sha256,
}
}
#[must_use]
pub fn exact_identity(&self) -> ObjectIdentity {
ObjectIdentity {
target_store_id: self.store.clone(),
bucket: self.bucket.clone(),
key: self.key.clone(),
version_id: self.version_id.clone(),
}
}
#[must_use]
pub fn store(&self) -> &str {
&self.store
}
#[must_use]
pub fn bucket(&self) -> &str {
&self.bucket
}
#[must_use]
pub fn key(&self) -> &str {
&self.key
}
#[must_use]
pub fn version_id(&self) -> Option<&str> {
self.version_id.as_deref()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PublicationInput {
pub attempt_id: OpaqueId,
pub manifest_id: OpaqueId,
pub schema_version: u32,
pub manifest: StoredObject,
pub member_count: u64,
pub total_bytes: u64,
}
pub type CommittedResult = PublicationInput;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ResultMemberPage {
pub items: Vec<ResultMember>,
pub next_after: Option<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PageSize(NonZeroU32);
impl PageSize {
pub const MAX: u32 = 1_000;
pub fn new(value: u32) -> Result<Self, ResultError> {
let value = NonZeroU32::new(value).ok_or(ResultError::InvalidPageSize)?;
if value.get() > Self::MAX {
return Err(ResultError::InvalidPageSize);
}
Ok(Self(value))
}
#[must_use]
pub fn get(self) -> u32 {
self.0.get()
}
}