use crate::*;
use std::time::{Duration, Instant};
pub const DISPATCH_MAX_BYTES: usize = 16 * 1024;
pub const CLAIM_REPLY_MAX_BYTES: usize = 16 * 1024 * 1024;
pub const MAX_PUBLICATION_BATCH: u32 = 100;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DispatchRef {
pub scope: Scope,
pub queue: String,
pub task_id: String,
pub generation: u32,
}
impl DispatchRef {
pub fn validate(&self) -> Result<()> {
self.scope.validate()?;
validate_text(&self.queue, 128)?;
validate_text(&self.task_id, 128)?;
if !(1..=1_000).contains(&self.generation) {
return Err(invalid("dispatch generation must be between 1 and 1000"));
}
Ok(())
}
pub fn decode(bytes: &[u8]) -> Result<Self> {
let value: Self = decode_unique_json(bytes, DISPATCH_MAX_BYTES)?;
value.validate()?;
Ok(value)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PublishedDispatch {
pub dispatch: DispatchRef,
pub publication_id: String,
}
impl PublishedDispatch {
pub fn validate(&self) -> Result<()> {
self.dispatch.validate()?;
validate_text(&self.publication_id, 128)
}
pub fn decode(bytes: &[u8]) -> Result<Self> {
let value: Self = decode_unique_json(bytes, DISPATCH_MAX_BYTES)?;
value.validate()?;
Ok(value)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ClaimCommand {
pub acquisition: AcquireCommand,
pub dispatch: DispatchRef,
}
impl ClaimCommand {
pub fn validate(&self) -> Result<()> {
self.dispatch.validate()?;
let acquisition = &self.acquisition;
acquisition.scope.validate()?;
validate_text(&acquisition.queue, 128)?;
validate_text(&acquisition.worker_session_id, 128)?;
if acquisition.sequence == 0 {
return Err(invalid("claim sequence must be nonzero"));
}
if acquisition.scope != self.dispatch.scope || acquisition.queue != self.dispatch.queue {
return Err(invalid("claim consumer and dispatch scope/queue differ"));
}
Ok(())
}
pub fn decode(bytes: &[u8]) -> Result<Self> {
let value: Self = decode_unique_json(bytes, DISPATCH_MAX_BYTES)?;
value.validate()?;
Ok(value)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum ClaimDisposition {
Claimed { reply: AcquireReply },
AlreadyHandedOff { attempt: AttemptRef },
TerminalOrSuperseded,
Deferred { available_at: Timestamp },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ClaimReply {
pub command: ClaimCommand,
pub disposition: ClaimDisposition,
}
impl ClaimReply {
pub fn validate_reply_against(&self, expected: &ClaimCommand) -> Result<()> {
expected.validate()?;
self.validate_identity(expected)
.map_err(|_| inconsistent("claim response does not match the requested dispatch"))
}
pub fn decode(bytes: &[u8], expected: &ClaimCommand) -> Result<Self> {
let value: Self = decode_unique_json(bytes, CLAIM_REPLY_MAX_BYTES)
.map_err(|_| inconsistent("invalid claim response JSON"))?;
value.validate_reply_against(expected)?;
Ok(value)
}
fn validate_identity(&self, expected: &ClaimCommand) -> Result<()> {
if self.command != *expected {
return Err(invalid("claim command changed"));
}
match &self.disposition {
ClaimDisposition::Claimed { reply } => match reply {
AcquireReply::Assigned {
sequence,
assignment,
} => {
if *sequence != expected.acquisition.sequence {
return Err(invalid("claim sequence changed"));
}
let owner = &assignment.lease.owner;
let event = &assignment.event;
if owner.scope != expected.dispatch.scope
|| owner.task_id != expected.dispatch.task_id
|| owner.generation != expected.dispatch.generation
|| owner.worker_session_id != expected.acquisition.worker_session_id
|| owner.consumer_id != expected.acquisition.consumer_id
|| assignment.authority.owner != *owner
|| assignment.authority.expires_at != assignment.lease.expires_at
|| event.tenant_id() != owner.scope.tenant_id
|| event.namespace() != owner.scope.namespace
|| event.task_id() != owner.task_id
|| event.attempt_id() != owner.attempt_id
|| event.value()["ldgattemptno"].as_u64()
!= Some(u64::from(owner.generation))
{
return Err(invalid("claim assignment identity changed"));
}
validate_text(&owner.attempt_id, 128)?;
validate_text(&owner.lease_id, 128)?;
assignment.descriptor.validate()?;
assignment.validate_workflow_identity()?;
for key in ["id", "ldgrunid", "ldgtaskid", "ldgattemptid"] {
validate_text(event.value()[key].as_str().unwrap_or_default(), 128)?;
}
validate_text(event.value()["source"].as_str().unwrap_or_default(), 2048)
}
AcquireReply::OwnershipLost {
sequence,
assignment,
} => {
if *sequence != expected.acquisition.sequence {
return Err(invalid("claim sequence changed"));
}
validate_attempt_ref(assignment, &expected.dispatch)
}
AcquireReply::Empty { .. } => Err(invalid("claimed response cannot be empty")),
},
ClaimDisposition::AlreadyHandedOff { attempt } => {
validate_attempt_ref(attempt, &expected.dispatch)
}
ClaimDisposition::TerminalOrSuperseded => Ok(()),
ClaimDisposition::Deferred { available_at } => {
if *available_at > i64::MAX as u64 {
return Err(invalid("deferred timestamp exceeds supported range"));
}
Ok(())
}
}
}
}
fn validate_attempt_ref(attempt: &AttemptRef, dispatch: &DispatchRef) -> Result<()> {
if attempt.task_id != dispatch.task_id {
return Err(invalid("claim attempt references another task"));
}
validate_text(&attempt.attempt_id, 128)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DispatchRoute {
pub scope: Scope,
pub queue: String,
pub destination: String,
}
impl DispatchRoute {
pub fn validate(&self) -> Result<()> {
self.scope.validate()?;
validate_text(&self.queue, 128)?;
validate_text(&self.destination, 128)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PublicationLease {
pub record: PublishedDispatch,
pub destination: String,
pub lease_token: String,
}
impl PublicationLease {
pub fn validate(&self) -> Result<()> {
self.record.validate()?;
validate_text(&self.destination, 128)?;
validate_text(&self.lease_token, 128)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PublicationOutcome {
Confirmed,
Retry,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PublicationCompletion {
pub dispatch: DispatchRef,
pub publication_id: String,
pub lease_token: String,
pub outcome: PublicationOutcome,
}
impl PublicationCompletion {
pub fn validate(&self) -> Result<()> {
self.dispatch.validate()?;
validate_text(&self.publication_id, 128)?;
validate_text(&self.lease_token, 128)
}
}
pub trait DispatchIntentStore: Send + Sync {
fn configure_route<'a>(&'a self, route: &'a DispatchRoute) -> ContractFuture<'a, ()>;
fn lease_publications<'a>(
&'a self,
destination: &'a str,
limit: u32,
deadline: Instant,
) -> ContractFuture<'a, Vec<PublicationLease>>;
fn complete_publications<'a>(
&'a self,
completions: &'a [PublicationCompletion],
deadline: Instant,
) -> ContractFuture<'a, ()>;
}
pub const QUEUE_RECEIPT_MAX_BYTES: usize = 16 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct QueueLimits {
pub max_publish_batch: u32,
pub max_receive_batch: u32,
pub max_ack_batch: u32,
pub max_message_bytes: usize,
}
impl QueueLimits {
pub fn validate(&self) -> Result<()> {
if [
self.max_publish_batch,
self.max_receive_batch,
self.max_ack_batch,
]
.into_iter()
.any(|limit| !(1..=MAX_PUBLICATION_BATCH).contains(&limit))
|| self.max_message_bytes == 0
{
return Err(invalid("queue limits exceed the portable contract"));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PublishResult {
pub publication_id: String,
pub outcome: PublicationOutcome,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QueueDelivery {
pub body: Vec<u8>,
pub receipt: String,
}
impl QueueDelivery {
pub fn validate(&self, limits: QueueLimits) -> Result<()> {
limits.validate()?;
if self.body.len() > limits.max_message_bytes.min(DISPATCH_MAX_BYTES) {
return Err(invalid("queue record exceeds dispatch byte limit"));
}
validate_receipt(&self.receipt)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AckResult {
pub receipt: String,
pub confirmed: bool,
}
pub trait DispatchPublisher: Send + Sync {
fn limits(&self) -> QueueLimits;
fn publish<'a>(
&'a self,
records: &'a [PublishedDispatch],
deadline: Instant,
) -> ContractFuture<'a, Vec<PublishResult>>;
}
pub trait AckQueue: Send + Sync {
fn limits(&self) -> QueueLimits;
fn receive(
&self,
max: u32,
wait: Duration,
deadline: Instant,
) -> ContractFuture<'_, Vec<QueueDelivery>>;
fn acknowledge<'a>(
&'a self,
receipts: &'a [String],
deadline: Instant,
) -> ContractFuture<'a, Vec<AckResult>>;
}
fn validate_receipt(receipt: &str) -> Result<()> {
if receipt.is_empty() || receipt.len() > QUEUE_RECEIPT_MAX_BYTES {
return Err(invalid("invalid queue receipt byte length"));
}
Ok(())
}
fn invalid(message: &str) -> ContractError {
ContractError::InvalidInput(message.into())
}
fn inconsistent(message: &str) -> ContractError {
ContractError::Unavailable(message.into())
}
#[cfg(test)]
mod tests {
use super::*;
use ledgence_worker_api::{CloudEvent, Digest, ProgramDescriptor, ProgramRef};
use serde_json::json;
fn dispatch() -> DispatchRef {
DispatchRef {
scope: Scope {
tenant_id: "acme".into(),
namespace: "billing".into(),
},
queue: "invoices".into(),
task_id: "task_1042".into(),
generation: 1,
}
}
fn command() -> ClaimCommand {
let dispatch = dispatch();
ClaimCommand {
acquisition: AcquireCommand {
scope: dispatch.scope.clone(),
queue: dispatch.queue.clone(),
worker_session_id: "worker_1".into(),
consumer_id: 0,
sequence: 1,
},
dispatch,
}
}
fn assigned() -> ClaimReply {
let command = command();
let owner = LeaseOwner {
scope: command.dispatch.scope.clone(),
task_id: command.dispatch.task_id.clone(),
attempt_id: "attempt_1".into(),
lease_id: "lease_1".into(),
generation: 1,
worker_session_id: command.acquisition.worker_session_id.clone(),
consumer_id: 0,
};
let assignment = Assignment {
workflow_activation_id: None, descriptor: ProgramDescriptor {
program: ProgramRef { id: "invoice".into(), version: "1".into() },
digest: Digest(format!("sha256:{}", "a".repeat(64))), size: 100,
},
event: CloudEvent::new(json!({
"specversion":"1.0","id":"event_1","source":"urn:ledgence:orchestrator",
"type":"com.ledgence.task.invocation.requested.v1","datacontenttype":"application/json",
"ldgtenantid":"acme","ldgnamespace":"billing","ldgrunid":"run_1042",
"ldgtaskid":"task_1042","ldgattemptid":"attempt_1","ldgattemptno":1,
"data":{"value":9007199254740993_u64}
})).unwrap(),
lease: Lease { owner: owner.clone(), expires_at: 61_000 },
authority: Authority {
owner, expires_at: 61_000, remaining_ms: 60_000, execution_remaining_ms: 60_000,
renew_sequence: 0, cancel_requested: false, dispatch_allowed: false,
},
attempt_deadline: 301_000,
};
ClaimReply {
command,
disposition: ClaimDisposition::Claimed {
reply: AcquireReply::Assigned {
sequence: 1,
assignment: Box::new(assignment),
},
},
}
}
fn assignment(reply: &mut ClaimReply) -> &mut Assignment {
let ClaimDisposition::Claimed {
reply: AcquireReply::Assigned { assignment, .. },
} = &mut reply.disposition
else {
panic!("assignment fixture")
};
assignment
}
#[test]
fn queue_limits_and_copied_transport_bytes_are_bounded() {
let limits = QueueLimits {
max_publish_batch: 10,
max_receive_batch: 10,
max_ack_batch: 10,
max_message_bytes: 1024 * 1024,
};
assert!(limits.validate().is_ok());
for bad in [
QueueLimits {
max_publish_batch: 0,
..limits
},
QueueLimits {
max_receive_batch: 101,
..limits
},
QueueLimits {
max_ack_batch: 101,
..limits
},
QueueLimits {
max_message_bytes: 0,
..limits
},
] {
assert!(bad.validate().is_err());
}
let mut delivery = QueueDelivery {
body: vec![b' '; DISPATCH_MAX_BYTES],
receipt: "receipt".into(),
};
assert!(delivery.validate(limits).is_ok());
assert!(
delivery
.validate(QueueLimits {
max_message_bytes: DISPATCH_MAX_BYTES - 1,
..limits
})
.is_err()
);
delivery.body.push(b' ');
assert!(delivery.validate(limits).is_err());
delivery.body.clear();
delivery.receipt = "r".repeat(QUEUE_RECEIPT_MAX_BYTES);
assert!(delivery.validate(limits).is_ok());
delivery.receipt.push('r');
assert!(delivery.validate(limits).is_err());
delivery.receipt.clear();
assert!(delivery.validate(limits).is_err());
}
#[test]
fn publication_and_command_round_trip_preserve_identity() {
let record = PublishedDispatch {
dispatch: dispatch(),
publication_id: "publication_1".into(),
};
assert_eq!(
PublishedDispatch::decode(&serde_json::to_vec(&record).unwrap()).unwrap(),
record
);
let mut command = command();
command.acquisition.sequence = u64::MAX;
assert_eq!(
ClaimCommand::decode(&serde_json::to_vec(&command).unwrap()).unwrap(),
command
);
}
#[test]
fn decoding_rejects_duplicate_unknown_fractional_and_oversized_records() {
let record = PublishedDispatch {
dispatch: dispatch(),
publication_id: "publication_1".into(),
};
let bytes = serde_json::to_vec(&record).unwrap();
let text = String::from_utf8(bytes.clone()).unwrap();
for invalid in [
text.replace("\"generation\":1", "\"generation\":1,\"generation\":1"),
text.replace(
"\"generation\":1",
"\"generation\":1,\"generatio\\u006e\":1",
),
text.replace("\"generation\":1", "\"generation\":1.5"),
text.replace("\"generation\":1", "\"generation\":1,\"extra\":true"),
text.replacen('{', "{\"extra\":true,", 1),
] {
assert!(
PublishedDispatch::decode(invalid.as_bytes()).is_err(),
"{invalid}"
);
}
let mut bounded = bytes;
bounded.resize(DISPATCH_MAX_BYTES, b' ');
assert!(PublishedDispatch::decode(&bounded).is_ok());
bounded.push(b' ');
assert!(PublishedDispatch::decode(&bounded).is_err());
}
#[test]
fn claim_validation_binds_queue_scope_sequence_and_generation() {
let original = command();
for mutate in [
|c: &mut ClaimCommand| c.acquisition.scope.tenant_id = "other".into(),
|c: &mut ClaimCommand| c.acquisition.queue = "other".into(),
|c: &mut ClaimCommand| c.acquisition.sequence = 0,
|c: &mut ClaimCommand| c.dispatch.generation = 0,
|c: &mut ClaimCommand| c.dispatch.generation = 1001,
|c: &mut ClaimCommand| c.dispatch.task_id = "x".repeat(129),
|c: &mut ClaimCommand| c.acquisition.worker_session_id = "bad\nvalue".into(),
] {
let mut bad = original.clone();
mutate(&mut bad);
assert!(bad.validate().is_err());
}
}
#[test]
fn valid_claim_round_trip_preserves_large_user_integer() {
let reply = assigned();
let decoded = ClaimReply::decode(&serde_json::to_vec(&reply).unwrap(), &command()).unwrap();
let ClaimDisposition::Claimed {
reply: AcquireReply::Assigned { assignment, .. },
} = decoded.disposition
else {
panic!("assignment expected")
};
assert_eq!(
assignment.event.value()["data"]["value"].as_u64(),
Some(9007199254740993)
);
}
#[test]
fn changed_echoed_command_never_provides_handoff_evidence() {
for mutate in [
|c: &mut ClaimCommand| c.acquisition.sequence += 1,
|c: &mut ClaimCommand| c.acquisition.worker_session_id = "worker_2".into(),
|c: &mut ClaimCommand| c.acquisition.consumer_id = 1,
|c: &mut ClaimCommand| c.dispatch.task_id = "task_2".into(),
|c: &mut ClaimCommand| c.dispatch.generation = 2,
|c: &mut ClaimCommand| c.dispatch.queue = "other".into(),
|c: &mut ClaimCommand| c.dispatch.scope.namespace = "other".into(),
] {
let mut reply = assigned();
mutate(&mut reply.command);
assert!(matches!(
reply.validate_reply_against(&command()),
Err(ContractError::Unavailable(_))
));
}
}
#[test]
fn changed_assignment_authority_never_provides_handoff_evidence() {
for mutate in [
|a: &mut Assignment| a.lease.owner.task_id = "task_2".into(),
|a: &mut Assignment| a.lease.owner.attempt_id = "attempt_2".into(),
|a: &mut Assignment| a.lease.owner.generation = 2,
|a: &mut Assignment| a.lease.owner.scope.tenant_id = "other".into(),
|a: &mut Assignment| a.lease.owner.worker_session_id = "worker_2".into(),
|a: &mut Assignment| a.lease.owner.consumer_id = 1,
|a: &mut Assignment| a.authority.owner.lease_id = "lease_2".into(),
|a: &mut Assignment| a.authority.expires_at += 1,
|a: &mut Assignment| a.descriptor.size = 0,
] {
let mut reply = assigned();
mutate(assignment(&mut reply));
assert!(matches!(
reply.validate_reply_against(&command()),
Err(ContractError::Unavailable(_))
));
}
}
#[test]
fn event_identity_must_match_both_claim_and_lease() {
for (key, value) in [
("ldgtenantid", json!("other")),
("ldgnamespace", json!("other")),
("ldgtaskid", json!("task_2")),
("ldgattemptid", json!("attempt_2")),
("ldgattemptno", json!(2)),
("id", json!("x".repeat(129))),
] {
let mut reply = assigned();
let a = assignment(&mut reply);
let mut event = a.event.value().clone();
event[key] = value;
a.event = CloudEvent::new(event).unwrap();
assert!(reply.validate_reply_against(&command()).is_err(), "{key}");
}
}
#[test]
fn only_identity_bound_durable_nonauthority_replies_are_accepted() {
let reference = AttemptRef {
task_id: "task_1042".into(),
attempt_id: "attempt_1".into(),
};
for disposition in [
ClaimDisposition::AlreadyHandedOff {
attempt: reference.clone(),
},
ClaimDisposition::TerminalOrSuperseded,
ClaimDisposition::Deferred {
available_at: 90_000,
},
ClaimDisposition::Claimed {
reply: AcquireReply::OwnershipLost {
sequence: 1,
assignment: reference,
},
},
] {
assert!(
ClaimReply {
command: command(),
disposition
}
.validate_reply_against(&command())
.is_ok()
);
}
for disposition in [
ClaimDisposition::Claimed {
reply: AcquireReply::Empty { sequence: 1 },
},
ClaimDisposition::AlreadyHandedOff {
attempt: AttemptRef {
task_id: "other".into(),
attempt_id: "attempt_1".into(),
},
},
ClaimDisposition::Claimed {
reply: AcquireReply::OwnershipLost {
sequence: 2,
assignment: AttemptRef {
task_id: "task_1042".into(),
attempt_id: "attempt_1".into(),
},
},
},
ClaimDisposition::Deferred {
available_at: u64::MAX,
},
] {
assert!(
ClaimReply {
command: command(),
disposition
}
.validate_reply_against(&command())
.is_err()
);
}
}
#[test]
fn publication_leases_and_completion_require_bounded_opaque_identity() {
let route = DispatchRoute {
scope: dispatch().scope,
queue: "invoices".into(),
destination: "billing-primary".into(),
};
assert!(route.validate().is_ok());
let mut lease = PublicationLease {
record: PublishedDispatch {
dispatch: dispatch(),
publication_id: "publication_1".into(),
},
destination: route.destination,
lease_token: "token_1".into(),
};
assert!(lease.validate().is_ok());
lease.lease_token.clear();
assert!(lease.validate().is_err());
let mut completion = PublicationCompletion {
dispatch: dispatch(),
publication_id: "publication_1".into(),
lease_token: "token_1".into(),
outcome: PublicationOutcome::Retry,
};
assert!(completion.validate().is_ok());
completion.publication_id = "x".repeat(129);
assert!(completion.validate().is_err());
}
}