transport: complete M0–M7
Some checks failed
ci / rust (push) Failing after 2m21s
ci / ui (push) Failing after 28s
images / build-and-push (push) Failing after 18s

shared: add stream+consumer policy helpers; NATS context header builder

aggregate/runner/projection: centralize stream validation and header usage; set bounded consumer params

projection: add QueryService gRPC and wire into main; settings include PROJECTION_GRPC_ADDR

gateway: gRPC routing to Projection/Runner with deadlines; bounded read-only retries; pooled gRPC channels (bounded LRU+TTL); admin proxy forwards to gRPC; probes use concurrency limiter + TTL cache

runner: add RunnerAdmin gRPC server (drain, status, reload) and wire into main; settings include RUNNER_GRPC_ADDR

tests: add gateway authz for runner admin, projection tenant isolation, runner admin drain semantics

docs: update TRANSPORT_DEVELOPMENT_PLAN to reflect completed milestones and details
This commit is contained in:
2026-03-30 14:24:14 +03:00
parent 1ab112438b
commit 90c307016d
41 changed files with 2391 additions and 505 deletions

View File

@@ -1,12 +1,112 @@
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fmt;
use std::str::FromStr;
use std::time::Duration;
use uuid::Uuid;
pub const HEADER_X_CORRELATION_ID: &str = "x-correlation-id";
pub const HEADER_X_TENANT_ID: &str = "x-tenant-id";
pub const HEADER_X_REQUEST_ID: &str = "x-request-id";
pub const HEADER_TRACEPARENT: &str = "traceparent";
pub const HEADER_TRACE_ID: &str = "trace-id";
pub const NATS_HEADER_CORRELATION_ID: &str = "correlation-id";
pub const NATS_HEADER_TENANT_ID: &str = "tenant-id";
pub const NATS_HEADER_NATS_MSG_ID: &str = "Nats-Msg-Id";
pub const NATS_SUBJECT_AGGREGATE_EVENTS_ALL: &str = "tenant.*.aggregate.*.*";
pub const NATS_SUBJECT_EFFECT_COMMANDS_ALL: &str = "tenant.*.effect.*.*";
pub const NATS_SUBJECT_WORKFLOW_COMMANDS_ALL: &str = "tenant.*.workflow.*.*";
pub const NATS_SUBJECT_EFFECT_RESULTS_ALL: &str = "tenant.*.effect_result.*.*";
pub const NATS_SUBJECT_WORKFLOW_EVENTS_ALL: &str = "tenant.*.workflow_event.*.*";
pub fn nats_subject_aggregate_event(
tenant_id: &str,
aggregate_type: &str,
aggregate_id: &str,
) -> String {
format!("tenant.{tenant_id}.aggregate.{aggregate_type}.{aggregate_id}")
}
pub fn nats_subject_effect_command(tenant_id: &str, effect_name: &str, command_id: &str) -> String {
format!("tenant.{tenant_id}.effect.{effect_name}.{command_id}")
}
pub fn nats_subject_effect_result(tenant_id: &str, effect_name: &str, command_id: &str) -> String {
format!("tenant.{tenant_id}.effect_result.{effect_name}.{command_id}")
}
pub fn nats_subject_workflow_command(
tenant_id: &str,
workflow_name: &str,
command_id: &str,
) -> String {
format!("tenant.{tenant_id}.workflow.{workflow_name}.{command_id}")
}
pub fn nats_subject_workflow_event(tenant_id: &str, workflow_name: &str, event_id: &str) -> String {
format!("tenant.{tenant_id}.workflow_event.{workflow_name}.{event_id}")
}
pub fn nats_filter_subject_aggregate_for_tenant(tenant_id: &str) -> String {
format!("tenant.{tenant_id}.aggregate.*.*")
}
pub fn nats_filter_subject_effect_for_tenant(tenant_id: &str) -> String {
format!("tenant.{tenant_id}.effect.*.*")
}
pub fn nats_context_headers_required(
tenant_id: &str,
msg_id: Option<&str>,
correlation_id: Option<&str>,
traceparent: Option<&str>,
trace_id: Option<&str>,
) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
out.insert(NATS_HEADER_TENANT_ID.to_string(), tenant_id.to_string());
if let Some(msg_id) = msg_id {
let msg_id = msg_id.trim();
if !msg_id.is_empty() {
out.insert(NATS_HEADER_NATS_MSG_ID.to_string(), msg_id.to_string());
}
}
let correlation_id = normalize_correlation_id(correlation_id).to_string();
out.insert(HEADER_X_CORRELATION_ID.to_string(), correlation_id.clone());
out.insert(NATS_HEADER_CORRELATION_ID.to_string(), correlation_id);
let mut traceparent = traceparent
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|tp| normalize_traceparent(Some(tp)))
.or_else(|| {
trace_id
.and_then(|tid| traceparent_from_trace_id(&TraceId::new(tid)))
.and_then(|tp| {
if trace_id_from_traceparent(&tp).is_some() {
Some(tp)
} else {
None
}
})
})
.unwrap_or_else(generate_traceparent);
let trace_id = match trace_id_from_traceparent(&traceparent) {
Some(v) => v.to_string(),
None => {
traceparent = generate_traceparent();
trace_id_from_traceparent(&traceparent).unwrap().to_string()
}
};
out.insert(HEADER_TRACEPARENT.to_string(), traceparent);
out.insert(HEADER_TRACE_ID.to_string(), trace_id);
out
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub struct TenantId(String);
@@ -121,12 +221,164 @@ impl AsRef<str> for TraceId {
}
}
pub fn normalize_correlation_id(value: Option<&str>) -> CorrelationId {
value
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(CorrelationId::new)
.unwrap_or_else(CorrelationId::generate)
}
pub fn generate_traceparent() -> String {
let trace_id = Uuid::new_v4().simple().to_string();
let span_id = Uuid::new_v4().simple().to_string()[..16].to_string();
format!("00-{trace_id}-{span_id}-01")
}
pub fn normalize_traceparent(value: Option<&str>) -> String {
value
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.and_then(|s| {
if trace_id_from_traceparent(s).is_some() {
Some(s.to_string())
} else {
None
}
})
.unwrap_or_else(generate_traceparent)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ConsumerPolicy {
pub ack_wait: Duration,
pub max_ack_pending: i64,
pub max_deliver: i64,
}
pub fn consumer_policy_from_parts(
ack_timeout_ms: u64,
max_in_flight: usize,
max_deliver: i64,
) -> ConsumerPolicy {
ConsumerPolicy {
ack_wait: Duration::from_millis(ack_timeout_ms.max(1)),
max_ack_pending: max_in_flight.max(1) as i64,
max_deliver: max_deliver.max(1),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StreamPolicy {
pub name: String,
pub subjects: Vec<String>,
pub max_messages: i64,
pub max_bytes: i64,
pub max_age: Duration,
pub duplicate_window: Duration,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StreamPolicyMismatch(String);
impl fmt::Display for StreamPolicyMismatch {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for StreamPolicyMismatch {}
pub fn stream_policy_defaults(name: impl Into<String>, subjects: Vec<String>) -> StreamPolicy {
StreamPolicy {
name: name.into(),
subjects,
max_messages: 10_000_000,
max_bytes: -1,
max_age: Duration::from_secs(365 * 24 * 60 * 60),
duplicate_window: Duration::from_secs(120),
}
}
pub fn stream_policy_from_parts(
name: &str,
subjects: Vec<String>,
max_messages: i64,
max_bytes: i64,
max_age: Duration,
duplicate_window: Duration,
) -> StreamPolicy {
StreamPolicy {
name: name.to_string(),
subjects,
max_messages,
max_bytes,
max_age,
duplicate_window,
}
}
pub fn validate_stream_policy(
expected: &StreamPolicy,
actual: &StreamPolicy,
) -> Result<(), StreamPolicyMismatch> {
if expected.name != actual.name {
return Err(StreamPolicyMismatch(format!(
"stream config mismatch: name expected={} actual={}",
expected.name, actual.name
)));
}
for subject in expected.subjects.iter() {
if !actual.subjects.iter().any(|s| s == subject) {
return Err(StreamPolicyMismatch(format!(
"stream config mismatch: missing subject {}",
subject
)));
}
}
fn gte_or_unlimited(actual: i64, expected: i64) -> bool {
actual == -1 || actual >= expected
}
if !gte_or_unlimited(actual.max_messages, expected.max_messages) {
return Err(StreamPolicyMismatch(format!(
"stream config mismatch: max_messages expected>={} actual={}",
expected.max_messages, actual.max_messages
)));
}
if !gte_or_unlimited(actual.max_bytes, expected.max_bytes) {
return Err(StreamPolicyMismatch(format!(
"stream config mismatch: max_bytes expected>={} actual={}",
expected.max_bytes, actual.max_bytes
)));
}
if actual.max_age < expected.max_age {
return Err(StreamPolicyMismatch(format!(
"stream config mismatch: max_age expected>={:?} actual={:?}",
expected.max_age, actual.max_age
)));
}
if actual.duplicate_window < expected.duplicate_window {
return Err(StreamPolicyMismatch(format!(
"stream config mismatch: duplicate_window expected>={:?} actual={:?}",
expected.duplicate_window, actual.duplicate_window
)));
}
Ok(())
}
pub fn trace_id_from_traceparent(traceparent: &str) -> Option<&str> {
let mut parts = traceparent.split('-');
let version = parts.next()?;
let trace_id = parts.next()?;
let span_id = parts.next()?;
let flags = parts.next()?;
if parts.next().is_some() {
return None;
}
if version.len() != 2 || trace_id.len() != 32 || span_id.len() != 16 || flags.len() != 2 {
return None;
}
@@ -137,6 +389,9 @@ pub fn trace_id_from_traceparent(traceparent: &str) -> Option<&str> {
{
return None;
}
if is_all_zeros(trace_id) || is_all_zeros(span_id) {
return None;
}
Some(trace_id)
}
@@ -152,6 +407,10 @@ fn is_valid_hex_32(s: &str) -> bool {
s.len() == 32 && s.chars().all(|c| c.is_ascii_hexdigit())
}
fn is_all_zeros(s: &str) -> bool {
s.chars().all(|c| c == '0')
}
#[cfg(test)]
mod tests {
use super::*;
@@ -193,4 +452,96 @@ mod tests {
Some("0123456789abcdef0123456789abcdef")
);
}
#[test]
fn trace_id_from_traceparent_rejects_extra_parts() {
let tp = "00-0123456789abcdef0123456789abcdef-1111111111111111-01-extra";
assert_eq!(trace_id_from_traceparent(tp), None);
}
#[test]
fn trace_id_from_traceparent_rejects_all_zero_ids() {
let tp = "00-00000000000000000000000000000000-1111111111111111-01";
assert_eq!(trace_id_from_traceparent(tp), None);
let tp = "00-0123456789abcdef0123456789abcdef-0000000000000000-01";
assert_eq!(trace_id_from_traceparent(tp), None);
}
#[test]
fn normalize_correlation_id_generates_when_missing_or_empty() {
let a = normalize_correlation_id(None);
let b = normalize_correlation_id(Some(""));
assert!(!a.as_str().is_empty());
assert!(!b.as_str().is_empty());
assert_ne!(a.as_str(), b.as_str());
}
#[test]
fn normalize_traceparent_accepts_valid_else_generates() {
let valid = "00-0123456789abcdef0123456789abcdef-1111111111111111-01";
assert_eq!(normalize_traceparent(Some(valid)), valid.to_string());
let generated = normalize_traceparent(Some("not-a-traceparent"));
assert!(trace_id_from_traceparent(&generated).is_some());
}
#[test]
fn nats_subject_builders_are_stable() {
assert_eq!(
nats_subject_aggregate_event("t1", "Account", "a1"),
"tenant.t1.aggregate.Account.a1"
);
assert_eq!(
nats_subject_effect_command("t1", "send_email", "c1"),
"tenant.t1.effect.send_email.c1"
);
assert_eq!(
nats_subject_effect_result("t1", "send_email", "c1"),
"tenant.t1.effect_result.send_email.c1"
);
assert_eq!(
nats_subject_workflow_command("t1", "wf", "c1"),
"tenant.t1.workflow.wf.c1"
);
assert_eq!(
nats_subject_workflow_event("t1", "wf", "e1"),
"tenant.t1.workflow_event.wf.e1"
);
assert_eq!(
nats_filter_subject_aggregate_for_tenant("t1"),
"tenant.t1.aggregate.*.*"
);
assert_eq!(
nats_filter_subject_effect_for_tenant("t1"),
"tenant.t1.effect.*.*"
);
}
#[test]
fn nats_context_headers_required_generates_missing_context() {
let headers = nats_context_headers_required("t1", Some("m1"), None, None, None);
assert_eq!(headers.get(NATS_HEADER_TENANT_ID).unwrap(), "t1");
assert_eq!(headers.get(NATS_HEADER_NATS_MSG_ID).unwrap(), "m1");
assert!(!headers.get(HEADER_X_CORRELATION_ID).unwrap().is_empty());
assert!(!headers.get(NATS_HEADER_CORRELATION_ID).unwrap().is_empty());
assert!(trace_id_from_traceparent(headers.get(HEADER_TRACEPARENT).unwrap()).is_some());
assert!(headers.get(HEADER_TRACE_ID).unwrap().len() == 32);
}
#[test]
fn validate_stream_policy_allows_subject_superset() {
let expected = stream_policy_defaults("S", vec!["a".to_string(), "b".to_string()]);
let mut actual = expected.clone();
actual.subjects.push("c".to_string());
validate_stream_policy(&expected, &actual).unwrap();
}
#[test]
fn validate_stream_policy_rejects_missing_subject() {
let expected = stream_policy_defaults("S", vec!["a".to_string(), "b".to_string()]);
let mut actual = expected.clone();
actual.subjects.retain(|s| s != "b");
assert!(validate_stream_policy(&expected, &actual).is_err());
}
}