blob: d2e84ab4f1601e9e47b103cdbd0e7336bf7edbab [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
//! Immutable decision receipts and a CAS head, in ordinary plugin storage.
//! Opening this repository never replays pending Host work.
use super::{Error, Request, Snapshot, identifier, invalid};
use maka_capabilities::storage::Data;
use maka_capabilities::storage::Mutation;
use maka_capabilities::storage::Record;
use maka_capabilities::storage::Store;
use maka_capabilities::storage::StoreError;
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use sha2::{Digest, Sha256};
use std::sync::Arc;
pub struct Repository<A = maka_capabilities::authorization::Id> {
authority: std::marker::PhantomData<A>,
storage: Arc<dyn Store>,
prefix: String,
session: String,
index: String,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Head {
revision: u64,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Decision<A> {
operation_id: String,
fingerprint: String,
snapshot: Snapshot<A>,
}
/// Immutable revisions at a fixed watermark, ordered oldest first. Continuations
/// keep `through_revision` even when another caller advances the current state.
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct HistoryPage<A = maka_capabilities::authorization::Id> {
pub through_revision: u64,
pub snapshots: Vec<Snapshot<A>>,
pub next_after: Option<u64>,
}
const MAX_PAGE_BYTES: usize = 512 * 1024;
const MAX_PAGE_ITEMS: usize = 8;
impl<A: Clone + Serialize + DeserializeOwned> Repository<A> {
/// Package/scope is already bound by Host. Entry and Session divide the
/// plugin's domain data, not authorization to query or execute a Session.
pub fn new(storage: Arc<dyn Store>, entry: &str, session: &str) -> Result<Self, Error> {
super::text(entry)?;
if entry.trim() != entry || entry.chars().any(char::is_control) {
return Err(invalid("invalid Plan entry identity"));
}
identifier(session)?;
Ok(Self {
authority: std::marker::PhantomData,
storage,
session: session.into(),
prefix: format!("plans/{}/", digest(&(entry, session))?),
index: format!("plan-sessions/{}/{session}", digest(&entry)?),
})
}
pub async fn current(&self) -> Result<Snapshot<A>, Error> {
self.head().await.map(|(_, snapshot)| snapshot)
}
/// Returns the original outcome, not a newer Session state. This is safe
/// after a lost reply; changed inputs under the same operation are rejected.
pub async fn receipt(&self, request: &Request<A>) -> Result<Option<Snapshot<A>>, Error> {
let Some(decision) = self.operation(&request.operation_id).await? else {
return Ok(None);
};
if decision.fingerprint != digest(request)? {
return Err(Error::Conflict);
}
Ok(Some(decision.snapshot))
}
/// Observe a pinned decision after a lost reply, without reconstructing
/// its authorization or retrying it. Absence is not proof of rejection.
pub async fn outcome(&self, operation_id: &str) -> Result<Option<Snapshot<A>>, Error> {
Ok(self
.operation(operation_id)
.await?
.map(|decision| decision.snapshot))
}
async fn operation(&self, operation_id: &str) -> Result<Option<Decision<A>>, Error> {
identifier(operation_id)?;
let record = self.storage.read(self.operation_key(operation_id)?).await?;
let Some(record) = record else {
return Ok(None);
};
let revision: u64 = decode(record)?;
let decision = self.decision(revision).await?;
if decision.operation_id != operation_id {
return Err(Error::Corrupt(
"Plan operation index disagrees with its decision".into(),
));
}
Ok(Some(decision))
}
pub async fn apply(&self, request: &Request<A>, now: u64) -> Result<Snapshot<A>, Error> {
self.apply_with(request, now, vec![]).await
}
/// Commit caller receipts with the same domain decision and CAS head.
pub async fn apply_with(
&self,
request: &Request<A>,
now: u64,
mut receipts: Vec<Mutation>,
) -> Result<Snapshot<A>, Error> {
if let Some(snapshot) = self.receipt(request).await? {
return Ok(snapshot);
}
if now >= 1 << 53 {
return Err(invalid("timestamp exceeds the protocol integer range"));
}
let (expected, mut snapshot) = self.head().await?;
if snapshot.revision != request.expected_revision {
return self.receipt(request).await?.ok_or(Error::Conflict);
}
// Host operation identities are package/scope-local, not Session-local.
let operation = digest(&(&self.prefix, &request.operation_id))?;
snapshot.apply(&request.command, &operation, &self.session, now)?;
snapshot.revision = snapshot
.revision
.checked_add(1)
.filter(|v| *v < 1 << 53)
.ok_or_else(|| invalid("Plan revision exhausted"))?;
let head = Head {
revision: snapshot.revision,
};
let decision = Decision {
operation_id: request.operation_id.clone(),
fingerprint: digest(request)?,
snapshot: snapshot.clone(),
};
let mut writes = vec![
mutation(format!("{}head", self.prefix), expected, &head)?,
mutation(self.decision_key(snapshot.revision), None, &decision)?,
mutation(
self.operation_key(&request.operation_id)?,
None,
&snapshot.revision,
)?,
];
if expected.is_none() {
writes.push(mutation(self.index.clone(), None, &self.session)?);
}
receipts.append(&mut writes);
match self.storage.batch(receipts).await {
Ok(_) => Ok(snapshot),
Err(StoreError::Conflict { .. }) => {
// A concurrent exact retry may have won the same CAS. Never
// rebase a different decision onto state the caller did not see.
self.receipt(request).await?.ok_or(Error::Conflict)
}
Err(error) => Err(error.into()),
}
}
pub async fn sessions(storage: &dyn Store, entry: &str) -> Result<Vec<String>, Error> {
let prefix = format!("plan-sessions/{}/", digest(&entry)?);
let mut after = None;
let mut sessions = Vec::new();
loop {
let page = storage
.scan(maka_capabilities::storage::Scan {
prefix: prefix.clone(),
after,
})
.await?;
for item in page.entries {
let session: String = decode(item.record)?;
identifier(&session)?;
sessions.push(session);
}
after = page.next_after;
if after.is_none() {
return Ok(sessions);
}
}
}
pub async fn at(&self, revision: u64) -> Result<Snapshot<A>, Error> {
self.decision(revision)
.await
.map(|decision| decision.snapshot)
}
/// A bounded historical read, not a live projection assembled across writes.
pub async fn history(
&self,
through_revision: Option<u64>,
after: u64,
) -> Result<HistoryPage<A>, Error> {
let (_, current) = self.head().await?;
let through = through_revision.unwrap_or(current.revision);
if after > through || through > current.revision {
return Err(invalid(
"Plan history cursor is outside the committed revisions",
));
}
let mut page = HistoryPage {
through_revision: through,
snapshots: Vec::new(),
next_after: None,
};
let mut bytes = 256usize; // JSON envelope and separators.
let mut last = after;
while last < through && page.snapshots.len() < MAX_PAGE_ITEMS {
let snapshot = self.decision(last + 1).await?.snapshot;
let size = serde_json::to_vec(&snapshot).map_err(invalid)?.len();
if !page.snapshots.is_empty()
&& bytes.saturating_add(size).saturating_add(1) > MAX_PAGE_BYTES
{
break;
}
bytes = bytes.saturating_add(size).saturating_add(1);
last += 1;
page.snapshots.push(snapshot);
}
page.next_after = (last < through).then_some(last);
Ok(page)
}
async fn head(&self) -> Result<(Option<u64>, Snapshot<A>), Error> {
let Some(record) = self.storage.read(format!("{}head", self.prefix)).await? else {
return Ok((None, Snapshot::default()));
};
let revision = record.revision;
let head: Head = decode(record)?;
let decision = self.decision(head.revision).await?;
// Receipt is immutable: even if head advanced while reading, this is
// a complete committed snapshot and its old CAS revision will conflict.
Ok((Some(revision), decision.snapshot))
}
async fn decision(&self, revision: u64) -> Result<Decision<A>, Error> {
if revision == 0 || revision >= 1 << 53 {
return Err(Error::Corrupt("invalid Plan revision".into()));
}
let record = self
.storage
.read(self.decision_key(revision))
.await?
.ok_or_else(|| Error::Corrupt("Plan revision has no decision receipt".into()))?;
let decision: Decision<A> = decode(record)?;
if decision.snapshot.revision != revision {
return Err(Error::Corrupt(
"Plan decision revision disagrees with its key".into(),
));
}
Ok(decision)
}
fn decision_key(&self, revision: u64) -> String {
format!("{}revisions/{revision:016}", self.prefix)
}
fn operation_key(&self, operation: &str) -> Result<String, Error> {
Ok(format!("{}operations/{}", self.prefix, digest(&operation)?))
}
}
fn digest(value: &impl Serialize) -> Result<String, Error> {
Ok(hex::encode(Sha256::digest(
serde_json::to_vec(value).map_err(invalid)?,
)))
}
fn decode<T: DeserializeOwned>(record: Record) -> Result<T, Error> {
match record.data {
Data::Present(value) => {
serde_json::from_value(value).map_err(|e| Error::Corrupt(e.to_string()))
}
Data::Deleted => Err(Error::Corrupt("Plan decision was deleted".into())),
}
}
fn mutation(key: String, revision: Option<u64>, value: &impl Serialize) -> Result<Mutation, Error> {
let mutation = Mutation {
key,
expected_revision: revision,
data: Data::Present(serde_json::to_value(value).map_err(invalid)?),
};
mutation.validate().map_err(invalid)?;
Ok(mutation)
}