Add PostgreSQL DurableStore substrate for WP-03
Introduce remora-durable with pool/migrations, a RemoraApiMutation-shaped ledger, claim-fenced outbox projection, CAS helpers, and keyset cursors, plus a kind Job acceptance suite against the hermetic fixture.
This commit is contained in:
parent
af8bb1a03a
commit
957d170150
1 changed files with 3203 additions and 18 deletions
231
crates/remora-durable/src/cursor.rs
Normal file
231
crates/remora-durable/src/cursor.rs
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use remora_domain::fingerprint;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::error::DurableError;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Stable keyset order for job-style listings: `(created_at DESC, id DESC)`.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub struct KeysetPosition {
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: OffsetDateTime,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
struct CursorPayload {
|
||||
subject: String,
|
||||
filter_fingerprint: String,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
high_water_created_at: OffsetDateTime,
|
||||
high_water_id: String,
|
||||
after: Option<KeysetPosition>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct KeysetCursor {
|
||||
pub subject: String,
|
||||
pub filter_fingerprint: [u8; 32],
|
||||
pub high_water: KeysetPosition,
|
||||
pub after: Option<KeysetPosition>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CursorCodec {
|
||||
secret: Vec<u8>,
|
||||
}
|
||||
|
||||
impl CursorCodec {
|
||||
pub fn new(secret: impl Into<Vec<u8>>) -> Result<Self, DurableError> {
|
||||
let secret = secret.into();
|
||||
if secret.len() < 16 {
|
||||
return Err(DurableError::Config(
|
||||
"cursor integrity secret must be at least 16 bytes".into(),
|
||||
));
|
||||
}
|
||||
Ok(Self { secret })
|
||||
}
|
||||
|
||||
pub fn filter_fingerprint(filters: &impl Serialize) -> Result<[u8; 32], DurableError> {
|
||||
let digest = fingerprint(filters).map_err(|_| {
|
||||
DurableError::Config("filters cannot be represented as canonical JSON".into())
|
||||
})?;
|
||||
Ok(*digest.as_bytes())
|
||||
}
|
||||
|
||||
pub fn encode(&self, cursor: &KeysetCursor) -> Result<String, DurableError> {
|
||||
let payload = CursorPayload {
|
||||
subject: cursor.subject.clone(),
|
||||
filter_fingerprint: hex::encode(cursor.filter_fingerprint),
|
||||
high_water_created_at: cursor.high_water.created_at,
|
||||
high_water_id: cursor.high_water.id.clone(),
|
||||
after: cursor.after.clone(),
|
||||
};
|
||||
let body = serde_json::to_vec(&payload).map_err(|error| {
|
||||
DurableError::Config(format!("cursor payload is not JSON-serializable: {error}"))
|
||||
})?;
|
||||
let mut mac = HmacSha256::new_from_slice(&self.secret)
|
||||
.map_err(|error| DurableError::Config(format!("invalid cursor secret: {error}")))?;
|
||||
mac.update(&body);
|
||||
let tag = mac.finalize().into_bytes();
|
||||
|
||||
let mut framed = Vec::with_capacity(body.len() + tag.len());
|
||||
framed.extend_from_slice(&body);
|
||||
framed.extend_from_slice(&tag);
|
||||
Ok(URL_SAFE_NO_PAD.encode(framed))
|
||||
}
|
||||
|
||||
pub fn decode(&self, token: &str) -> Result<KeysetCursor, DurableError> {
|
||||
let framed = URL_SAFE_NO_PAD
|
||||
.decode(token.as_bytes())
|
||||
.map_err(|_| DurableError::InvalidCursor)?;
|
||||
if framed.len() <= 32 {
|
||||
return Err(DurableError::InvalidCursor);
|
||||
}
|
||||
let (body, tag) = framed.split_at(framed.len() - 32);
|
||||
let mut mac = HmacSha256::new_from_slice(&self.secret)
|
||||
.map_err(|error| DurableError::Config(format!("invalid cursor secret: {error}")))?;
|
||||
mac.update(body);
|
||||
mac.verify_slice(tag)
|
||||
.map_err(|_| DurableError::InvalidCursor)?;
|
||||
|
||||
let payload: CursorPayload =
|
||||
serde_json::from_slice(body).map_err(|_| DurableError::InvalidCursor)?;
|
||||
let filter_fingerprint = parse_hex32(&payload.filter_fingerprint)?;
|
||||
Ok(KeysetCursor {
|
||||
subject: payload.subject,
|
||||
filter_fingerprint,
|
||||
high_water: KeysetPosition {
|
||||
created_at: payload.high_water_created_at,
|
||||
id: payload.high_water_id,
|
||||
},
|
||||
after: payload.after,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn bind(
|
||||
&self,
|
||||
token: &str,
|
||||
subject: &str,
|
||||
filter_fingerprint: [u8; 32],
|
||||
) -> Result<KeysetCursor, DurableError> {
|
||||
let cursor = self.decode(token)?;
|
||||
if cursor.subject != subject || cursor.filter_fingerprint != filter_fingerprint {
|
||||
return Err(DurableError::CursorBindingMismatch);
|
||||
}
|
||||
Ok(cursor)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a first-page cursor after returning one page.
|
||||
///
|
||||
/// `high_water` is the newest visible row at snapshot time (membership bound).
|
||||
/// `last_on_page` is the last row of the returned page and becomes the exclusive
|
||||
/// keyset lower bound for the next page. For a single-item page the two
|
||||
/// positions are equal.
|
||||
pub fn first_page_cursor(
|
||||
subject: impl Into<String>,
|
||||
filter_fingerprint: [u8; 32],
|
||||
high_water: KeysetPosition,
|
||||
last_on_page: KeysetPosition,
|
||||
) -> KeysetCursor {
|
||||
KeysetCursor {
|
||||
subject: subject.into(),
|
||||
filter_fingerprint,
|
||||
high_water,
|
||||
after: Some(last_on_page),
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance a keyset cursor after returning `last` on the current page.
|
||||
pub fn next_page_cursor(current: &KeysetCursor, last: KeysetPosition) -> KeysetCursor {
|
||||
KeysetCursor {
|
||||
subject: current.subject.clone(),
|
||||
filter_fingerprint: current.filter_fingerprint,
|
||||
high_water: current.high_water.clone(),
|
||||
after: Some(last),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_hex32(value: &str) -> Result<[u8; 32], DurableError> {
|
||||
if value.len() != 64 {
|
||||
return Err(DurableError::InvalidCursor);
|
||||
}
|
||||
let mut out = [0_u8; 32];
|
||||
for (index, slot) in out.iter_mut().enumerate() {
|
||||
*slot = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16)
|
||||
.map_err(|_| DurableError::InvalidCursor)?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// Minimal hex encode without an extra dependency.
|
||||
mod hex {
|
||||
pub fn encode(bytes: [u8; 32]) -> String {
|
||||
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||
let mut output = String::with_capacity(64);
|
||||
for byte in bytes {
|
||||
output.push(char::from(HEX[usize::from(byte >> 4)]));
|
||||
output.push(char::from(HEX[usize::from(byte & 0x0f)]));
|
||||
}
|
||||
output
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use time::macros::datetime;
|
||||
|
||||
#[test]
|
||||
fn round_trips_and_binds_subject_and_filters() {
|
||||
let codec = CursorCodec::new(b"test-cursor-secret").unwrap();
|
||||
let filters = json!({"bucket": "alpha", "lifecycle": "visible"});
|
||||
let fingerprint = CursorCodec::filter_fingerprint(&filters).unwrap();
|
||||
let newest = KeysetPosition {
|
||||
created_at: datetime!(2026-07-16 12:00:00 UTC),
|
||||
id: "job-zzzzzzzz".into(),
|
||||
};
|
||||
let last = KeysetPosition {
|
||||
created_at: datetime!(2026-07-16 11:00:00 UTC),
|
||||
id: "job-aaaaaaaa".into(),
|
||||
};
|
||||
let cursor = first_page_cursor("subject-001", fingerprint, newest, last);
|
||||
let token = codec.encode(&cursor).unwrap();
|
||||
let decoded = codec.bind(&token, "subject-001", fingerprint).unwrap();
|
||||
assert_eq!(decoded, cursor);
|
||||
assert!(matches!(
|
||||
codec.bind(&token, "other-subject", fingerprint),
|
||||
Err(DurableError::CursorBindingMismatch)
|
||||
));
|
||||
let other = CursorCodec::filter_fingerprint(&json!({"bucket": "beta"})).unwrap();
|
||||
assert!(matches!(
|
||||
codec.bind(&token, "subject-001", other),
|
||||
Err(DurableError::CursorBindingMismatch)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_tampered_tokens() {
|
||||
let codec = CursorCodec::new(b"test-cursor-secret").unwrap();
|
||||
let fingerprint = [7_u8; 32];
|
||||
let position = KeysetPosition {
|
||||
created_at: datetime!(2026-07-16 12:00:00 UTC),
|
||||
id: "job-aaaaaaaa".into(),
|
||||
};
|
||||
let cursor = first_page_cursor("subject-001", fingerprint, position.clone(), position);
|
||||
let mut token = codec.encode(&cursor).unwrap();
|
||||
token.replace_range(0..1, if token.starts_with('A') { "B" } else { "A" });
|
||||
assert!(matches!(
|
||||
codec.decode(&token),
|
||||
Err(DurableError::InvalidCursor)
|
||||
));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue