use std::time::{Duration, Instant};
use async_trait::async_trait;
use serde_json::{Value as Json, json};
use super::{CanonicalStore, DurabilityToken};
use crate::runtime::executors::neo4j::Neo4jExecutor;
const OUTBOX_SEQ_ID: &str = "outbox_seq";
pub struct Neo4jCanonicalStore {
pub(super) executor: Neo4jExecutor,
pub(super) instance_name: String,
pub(super) run_tag: String,
}
impl Neo4jCanonicalStore {
pub fn new(executor: Neo4jExecutor, instance_name: impl Into<String>) -> Self {
Self {
executor,
instance_name: instance_name.into(),
run_tag: String::new(),
}
}
pub fn with_run_tag(mut self, run_tag: impl Into<String>) -> Self {
self.run_tag = run_tag.into();
self
}
fn tag(&self) -> Json {
json!(self.run_tag)
}
pub(super) fn executor(&self) -> &Neo4jExecutor {
&self.executor
}
pub(super) fn run_tag(&self) -> &str {
&self.run_tag
}
fn now_unix_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
}
#[async_trait]
impl CanonicalStore for Neo4jCanonicalStore {
fn backend_label(&self) -> &'static str {
"neo4j"
}
fn instance_name(&self) -> &str {
&self.instance_name
}
async fn ensure_system_tables(&self) -> Result<(), String> {
let indexes = [
"CREATE INDEX udb_counter_idx IF NOT EXISTS \
FOR (c:UdbCounter) ON (c.run_tag, c.id)",
"CREATE INDEX udb_outbox_event_idx IF NOT EXISTS \
FOR (o:UdbOutbox) ON (o.run_tag, o.event_id)",
"CREATE INDEX udb_lease_name_idx IF NOT EXISTS \
FOR (l:UdbLease) ON (l.run_tag, l.lease_name)",
];
for ddl in indexes {
self.executor.cypher_rows(ddl, json!({})).await?;
}
self.executor
.cypher_rows(
"MERGE (c:UdbCounter {run_tag:$tag, id:$id}) \
ON CREATE SET c.seq = 0",
json!({ "tag": self.tag(), "id": OUTBOX_SEQ_ID }),
)
.await?;
Ok(())
}
async fn enqueue_outbox_event(
&self,
event_id: &str,
topic: &str,
partition_key: &str,
payload: &serde_json::Value,
) -> Result<i64, String> {
let payload_text = serde_json::to_string(payload)
.map_err(|e| format!("outbox payload serialise failed: {e}"))?;
let cypher = "MERGE (c:UdbCounter {run_tag:$tag, id:$id}) \
ON CREATE SET c.seq = 1 \
ON MATCH SET c.seq = c.seq + 1 \
WITH c.seq AS s \
CREATE (:UdbOutbox {run_tag:$tag, event_seq:s, event_id:$eid, \
topic:$topic, partition_key:$pk, payload:$payload, \
created_at:timestamp()}) \
RETURN s";
let rows = self
.executor
.cypher_rows(
cypher,
json!({
"tag": self.tag(),
"id": OUTBOX_SEQ_ID,
"eid": event_id,
"topic": topic,
"pk": partition_key,
"payload": payload_text,
}),
)
.await?;
let seq = rows
.first()
.and_then(|r| r.get("s"))
.and_then(Json::as_i64)
.ok_or_else(|| "outbox enqueue did not return a sequence".to_string())?;
Ok(seq)
}
async fn outbox_max_seq(&self) -> Result<i64, String> {
let rows = self
.executor
.cypher_rows(
"MATCH (c:UdbCounter {run_tag:$tag, id:$id}) RETURN c.seq AS seq",
json!({ "tag": self.tag(), "id": OUTBOX_SEQ_ID }),
)
.await?;
let seq = rows
.first()
.and_then(|r| r.get("seq"))
.and_then(Json::as_i64)
.unwrap_or(0);
Ok(seq)
}
async fn current_durability_token(&self) -> Result<DurabilityToken, String> {
let seq = self.outbox_max_seq().await?;
Ok(DurabilityToken::new("neo4j", seq.to_string()))
}
async fn wait_for_token(
&self,
token: &DurabilityToken,
timeout: Duration,
) -> Result<bool, String> {
if !token.is_for("neo4j") {
return Err(format!(
"Neo4jCanonicalStore cannot wait on a '{}' token",
token.backend_label
));
}
let target: i64 = token
.value
.parse()
.map_err(|e| format!("malformed neo4j durability token '{}': {e}", token.value))?;
let started = Instant::now();
let poll = super::durability_poll_interval(timeout, super::NEO4J_DURABILITY_POLL_MS);
loop {
if self.outbox_max_seq().await? >= target {
return Ok(true);
}
if started.elapsed() >= timeout {
return Ok(false);
}
tokio::time::sleep(poll).await;
}
}
async fn ensure_advisory_lease_table(&self) -> Result<(), String> {
self.executor
.cypher_rows(
"CREATE INDEX udb_lease_name_idx IF NOT EXISTS \
FOR (l:UdbLease) ON (l.run_tag, l.lease_name)",
json!({}),
)
.await?;
Ok(())
}
async fn try_acquire_advisory_lease(
&self,
lease_name: &str,
owner_id: &str,
ttl: Duration,
) -> Result<bool, String> {
let now = Self::now_unix_ms();
let new_expires = now + (ttl.as_millis() as i64);
let cypher = "MERGE (l:UdbLease {run_tag:$tag, lease_name:$n}) \
ON CREATE SET l.owner_id = $o, l.expires_at = $exp \
WITH l WHERE l.owner_id = $o OR l.expires_at <= $now \
SET l.owner_id = $o, l.expires_at = $exp \
RETURN l.owner_id AS owner";
let rows = self
.executor
.cypher_rows(
cypher,
json!({
"tag": self.tag(),
"n": lease_name,
"o": owner_id,
"exp": new_expires,
"now": now,
}),
)
.await?;
let acquired = rows
.first()
.and_then(|r| r.get("owner"))
.and_then(Json::as_str)
.map(|owner| owner == owner_id)
.unwrap_or(false);
Ok(acquired)
}
async fn release_advisory_lease(&self, lease_name: &str, owner_id: &str) -> Result<(), String> {
self.executor
.cypher_rows(
"MATCH (l:UdbLease {run_tag:$tag, lease_name:$n}) \
WHERE l.owner_id = $o DELETE l",
json!({ "tag": self.tag(), "n": lease_name, "o": owner_id }),
)
.await?;
Ok(())
}
}
pub(super) const LABEL_PROJECTION_TASK: &str = "UdbProjectionTask";
pub(super) const LABEL_SAGA: &str = "UdbSaga";
pub(super) const LABEL_AUDIT_LOG: &str = "UdbAuditLog";
pub(super) const LABEL_MIGRATION_RUN: &str = "UdbMigrationRun";
pub(super) const LABEL_MIGRATION_OP: &str = "UdbMigrationOp";
pub(super) fn now_unix_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
pub(super) fn prop_str(node: &Json, key: &str) -> String {
node.get(key)
.and_then(Json::as_str)
.map(|s| s.to_string())
.unwrap_or_default()
}
pub(super) fn prop_i64(node: &Json, key: &str) -> i64 {
node.get(key).and_then(Json::as_i64).unwrap_or(0)
}
pub(super) fn prop_i32(node: &Json, key: &str) -> i32 {
prop_i64(node, key) as i32
}
pub(super) fn prop_uuid(
node: &Json,
key: &str,
) -> Result<uuid::Uuid, super::system_store::SystemStoreError> {
let raw = node.get(key).and_then(Json::as_str).ok_or_else(|| {
super::system_store::SystemStoreError::InvalidInput(format!(
"neo4j row missing string uuid property '{key}'"
))
})?;
uuid::Uuid::parse_str(raw).map_err(|e| {
super::system_store::SystemStoreError::InvalidInput(format!(
"neo4j row property '{key}' is not a uuid '{raw}': {e}"
))
})
}
pub(super) fn prop_dt(node: &Json, key: &str) -> chrono::DateTime<chrono::Utc> {
prop_opt_dt(node, key).unwrap_or_else(chrono::Utc::now)
}
pub(super) fn prop_opt_dt(node: &Json, key: &str) -> Option<chrono::DateTime<chrono::Utc>> {
let millis = node.get(key).and_then(Json::as_i64)?;
chrono::DateTime::<chrono::Utc>::from_timestamp_millis(millis)
}
pub(super) fn prop_json(node: &Json, key: &str, default: Json) -> Json {
match node.get(key).and_then(Json::as_str) {
Some(s) => serde_json::from_str(s).unwrap_or(default),
None => default,
}
}
pub(super) fn neo_err(
op: &str,
err: impl std::fmt::Display,
) -> super::system_store::SystemStoreError {
super::system_store::SystemStoreError::Io {
backend: "neo4j",
source: format!("{op}: {err}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::executors::neo4j::Neo4jConfig;
fn dummy_store() -> Neo4jCanonicalStore {
let exec = Neo4jExecutor::new(Neo4jConfig {
http_base: "http://localhost:7474".to_string(),
username: "neo4j".to_string(),
password: "secret".to_string(),
database: "neo4j".to_string(),
is_cloud: false,
dev_mode: true,
timeout_secs: 30,
});
Neo4jCanonicalStore::new(exec, "primary")
}
#[test]
fn backend_label_is_pinned() {
let store = dummy_store();
assert_eq!(store.backend_label(), "neo4j");
assert_eq!(store.instance_name(), "primary");
}
#[tokio::test]
async fn wait_for_token_rejects_foreign_backend() {
let store = dummy_store();
let foreign = DurabilityToken::new("postgres", "0/100");
let err = store
.wait_for_token(&foreign, Duration::from_millis(1))
.await
.expect_err("foreign token must be rejected");
assert!(err.contains("cannot wait on"));
}
#[tokio::test]
async fn wait_for_token_rejects_malformed_value() {
let store = dummy_store();
let bad = DurabilityToken::new("neo4j", "not-an-int");
let err = store
.wait_for_token(&bad, Duration::from_millis(1))
.await
.expect_err("malformed token must error");
assert!(err.contains("malformed"));
}
}