sync from monorepo @ 2452e92e
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
//! Two-archive fanout tests exercising `ArchiveFilter` semantics.
|
||||
//!
|
||||
//! The primary backend is unfiltered; the secondary backend carries a
|
||||
//! restricted filter. Writes should always reach the primary but only
|
||||
//! fan out to the secondary when the session passes the filter.
|
||||
|
||||
#![cfg(feature = "test-utils")]
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use dirigent_archivist::backend::mock::MockBackend;
|
||||
use dirigent_archivist::backend::{ArchiveBackend, HealthStatus};
|
||||
use dirigent_archivist::coordinator::Archivist;
|
||||
use dirigent_archivist::registry::{
|
||||
ArchiveFilter, ArchiveRegistration, FailureMode, WritePolicy,
|
||||
};
|
||||
use dirigent_archivist::types::{
|
||||
ConnectorRecord, MessageRecord, RegisterSessionRequest,
|
||||
};
|
||||
|
||||
fn reg(
|
||||
name: &str,
|
||||
backend: Arc<MockBackend>,
|
||||
priority: u32,
|
||||
filter: ArchiveFilter,
|
||||
) -> Arc<ArchiveRegistration> {
|
||||
Arc::new(
|
||||
ArchiveRegistration::new(
|
||||
name.into(),
|
||||
"mock",
|
||||
backend as Arc<dyn ArchiveBackend>,
|
||||
/* write_active */ true,
|
||||
FailureMode::Required,
|
||||
priority,
|
||||
/* enabled */ true,
|
||||
WritePolicy::Inline,
|
||||
/* writer */ None,
|
||||
HealthStatus::Healthy,
|
||||
)
|
||||
.with_filter(filter),
|
||||
)
|
||||
}
|
||||
|
||||
/// Seed a connector into a MockBackend directly, bypassing the coordinator.
|
||||
async fn seed_connector(backend: &MockBackend, connector_uid: Uuid, client_native_id: &str) {
|
||||
use dirigent_archivist::backend::ConnectorRegistryBackend;
|
||||
let rec = ConnectorRecord {
|
||||
version: 1,
|
||||
connector_uid,
|
||||
r#type: "Mock".into(),
|
||||
title: "Mock connector".into(),
|
||||
client_native_id: client_native_id.into(),
|
||||
alias_of: None,
|
||||
created_at: Utc::now(),
|
||||
metadata: serde_json::Value::Null,
|
||||
fingerprint: None,
|
||||
};
|
||||
backend
|
||||
.put_connector(rec)
|
||||
.await
|
||||
.expect("put_connector succeeds");
|
||||
}
|
||||
|
||||
fn make_msg(session: Uuid, n: u32) -> MessageRecord {
|
||||
MessageRecord {
|
||||
version: 1,
|
||||
message_id: Uuid::now_v7(),
|
||||
session,
|
||||
parent_id: None,
|
||||
ts: Utc::now(),
|
||||
role: "user".into(),
|
||||
author: None,
|
||||
content_md: format!("msg {}", n),
|
||||
content_parts: None,
|
||||
attachments: vec![],
|
||||
metadata: serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn secondary_archive_filters_by_exclude_connector() {
|
||||
let primary_backend = Arc::new(MockBackend::new());
|
||||
let secondary_backend = Arc::new(MockBackend::new());
|
||||
|
||||
let connector_a = Uuid::now_v7();
|
||||
let connector_b = Uuid::now_v7();
|
||||
|
||||
// Connector A is excluded from the secondary.
|
||||
let mut excluded = HashSet::new();
|
||||
excluded.insert(connector_a);
|
||||
let secondary_filter = ArchiveFilter {
|
||||
exclude_connectors: excluded,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Seed connectors on primary (and on secondary so mapping writes that DO
|
||||
// pass the filter don't fail for unrelated reasons).
|
||||
seed_connector(&primary_backend, connector_a, "native/a").await;
|
||||
seed_connector(&primary_backend, connector_b, "native/b").await;
|
||||
|
||||
let archivist = Archivist::from_registrations(vec![
|
||||
reg("primary", primary_backend.clone(), 0, ArchiveFilter::default()),
|
||||
reg("secondary", secondary_backend.clone(), 10, secondary_filter),
|
||||
]);
|
||||
|
||||
// Register a session for each connector.
|
||||
let resp_a = archivist
|
||||
.register_session(
|
||||
RegisterSessionRequest {
|
||||
connector_uid: connector_a,
|
||||
native_session_id: "sess-a".into(),
|
||||
title: None,
|
||||
custom_scroll_id: None,
|
||||
metadata: serde_json::Value::Null,
|
||||
completeness: Default::default(),
|
||||
parent_scroll_id: None,
|
||||
is_subagent: false,
|
||||
continuation: None,
|
||||
agent_id: None,
|
||||
subagent_type: None,
|
||||
spawning_tool_use_id: None,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("register session a");
|
||||
let scroll_a = resp_a.scroll_id;
|
||||
|
||||
let resp_b = archivist
|
||||
.register_session(
|
||||
RegisterSessionRequest {
|
||||
connector_uid: connector_b,
|
||||
native_session_id: "sess-b".into(),
|
||||
title: None,
|
||||
custom_scroll_id: None,
|
||||
metadata: serde_json::Value::Null,
|
||||
completeness: Default::default(),
|
||||
parent_scroll_id: None,
|
||||
is_subagent: false,
|
||||
continuation: None,
|
||||
agent_id: None,
|
||||
subagent_type: None,
|
||||
spawning_tool_use_id: None,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("register session b");
|
||||
let scroll_b = resp_b.scroll_id;
|
||||
|
||||
// Append 3 messages to each session.
|
||||
archivist
|
||||
.append_messages(
|
||||
scroll_a,
|
||||
vec![make_msg(scroll_a, 1), make_msg(scroll_a, 2), make_msg(scroll_a, 3)],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("append to a");
|
||||
archivist
|
||||
.append_messages(
|
||||
scroll_b,
|
||||
vec![make_msg(scroll_b, 1), make_msg(scroll_b, 2), make_msg(scroll_b, 3)],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("append to b");
|
||||
|
||||
// Primary sees every message.
|
||||
assert_eq!(primary_backend.appended_count(scroll_a), 3);
|
||||
assert_eq!(primary_backend.appended_count(scroll_b), 3);
|
||||
|
||||
// Secondary excludes connector_a: scroll_a is filtered out,
|
||||
// scroll_b is replicated.
|
||||
assert_eq!(
|
||||
secondary_backend.appended_count(scroll_a),
|
||||
0,
|
||||
"secondary should NOT receive messages for the excluded connector"
|
||||
);
|
||||
assert_eq!(
|
||||
secondary_backend.appended_count(scroll_b),
|
||||
3,
|
||||
"secondary should receive messages for the allowed connector"
|
||||
);
|
||||
|
||||
// Session metadata fanout follows the same rule.
|
||||
assert!(
|
||||
primary_backend
|
||||
.get_session(scroll_a)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some(),
|
||||
"primary has scroll_a"
|
||||
);
|
||||
assert!(
|
||||
secondary_backend
|
||||
.get_session(scroll_a)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none(),
|
||||
"secondary should NOT have scroll_a (excluded connector)"
|
||||
);
|
||||
assert!(
|
||||
secondary_backend
|
||||
.get_session(scroll_b)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some(),
|
||||
"secondary should have scroll_b (allowed connector)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn secondary_archive_filters_by_include_tag() {
|
||||
let primary_backend = Arc::new(MockBackend::new());
|
||||
let secondary_backend = Arc::new(MockBackend::new());
|
||||
|
||||
let connector = Uuid::now_v7();
|
||||
seed_connector(&primary_backend, connector, "native/tagged").await;
|
||||
|
||||
let mut include = HashSet::new();
|
||||
include.insert("prod".to_string());
|
||||
let secondary_filter = ArchiveFilter {
|
||||
include_tags: include,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let archivist = Archivist::from_registrations(vec![
|
||||
reg("primary", primary_backend.clone(), 0, ArchiveFilter::default()),
|
||||
reg("secondary", secondary_backend.clone(), 10, secondary_filter),
|
||||
]);
|
||||
|
||||
// Register two sessions on the same connector.
|
||||
let prod_resp = archivist
|
||||
.register_session(
|
||||
RegisterSessionRequest {
|
||||
connector_uid: connector,
|
||||
native_session_id: "sess-prod".into(),
|
||||
title: None,
|
||||
custom_scroll_id: None,
|
||||
metadata: serde_json::Value::Null,
|
||||
completeness: Default::default(),
|
||||
parent_scroll_id: None,
|
||||
is_subagent: false,
|
||||
continuation: None,
|
||||
agent_id: None,
|
||||
subagent_type: None,
|
||||
spawning_tool_use_id: None,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("register prod session");
|
||||
let scroll_prod = prod_resp.scroll_id;
|
||||
|
||||
let dev_resp = archivist
|
||||
.register_session(
|
||||
RegisterSessionRequest {
|
||||
connector_uid: connector,
|
||||
native_session_id: "sess-dev".into(),
|
||||
title: None,
|
||||
custom_scroll_id: None,
|
||||
metadata: serde_json::Value::Null,
|
||||
completeness: Default::default(),
|
||||
parent_scroll_id: None,
|
||||
is_subagent: false,
|
||||
continuation: None,
|
||||
agent_id: None,
|
||||
subagent_type: None,
|
||||
spawning_tool_use_id: None,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("register dev session");
|
||||
let scroll_dev = dev_resp.scroll_id;
|
||||
|
||||
// Tag the prod session directly on the primary so the coordinator can
|
||||
// see it on the next fanout metadata lookup. We mutate via the primary
|
||||
// backend to avoid going through update_session_metadata (which doesn't
|
||||
// expose a tag API).
|
||||
{
|
||||
use dirigent_archivist::backend::ArchiveBackend as _;
|
||||
let mut md = primary_backend
|
||||
.get_session(scroll_prod)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("prod session on primary");
|
||||
md.tags.push("prod".into());
|
||||
primary_backend.put_session(md).await.unwrap();
|
||||
}
|
||||
|
||||
// Append messages AFTER tagging — now the filter consults the tagged metadata.
|
||||
archivist
|
||||
.append_messages(
|
||||
scroll_prod,
|
||||
vec![
|
||||
make_msg(scroll_prod, 1),
|
||||
make_msg(scroll_prod, 2),
|
||||
make_msg(scroll_prod, 3),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("append prod");
|
||||
archivist
|
||||
.append_messages(
|
||||
scroll_dev,
|
||||
vec![make_msg(scroll_dev, 1), make_msg(scroll_dev, 2), make_msg(scroll_dev, 3)],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("append dev");
|
||||
|
||||
// Primary keeps both.
|
||||
assert_eq!(primary_backend.appended_count(scroll_prod), 3);
|
||||
assert_eq!(primary_backend.appended_count(scroll_dev), 3);
|
||||
|
||||
// Secondary only keeps the tagged session.
|
||||
assert_eq!(
|
||||
secondary_backend.appended_count(scroll_prod),
|
||||
3,
|
||||
"secondary receives messages for the `prod`-tagged session"
|
||||
);
|
||||
assert_eq!(
|
||||
secondary_backend.appended_count(scroll_dev),
|
||||
0,
|
||||
"secondary rejects the untagged session"
|
||||
);
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
{"type":"user","uuid":"11111111-1111-7111-8111-111111111111","parentUuid":null,"timestamp":"2024-01-01T00:00:00Z","sessionId":"abc12345-1234-1234-1234-abcdef123456","cwd":"/home/user/myproj","version":"2.1.71","gitBranch":"main","isSidechain":false,"isMeta":false,"userType":"external","message":{"role":"user","content":"hello"}}
|
||||
{"type":"assistant","uuid":"22222222-2222-7222-8222-222222222222","parentUuid":"11111111-1111-7111-8111-111111111111","timestamp":"2024-01-01T00:00:01Z","sessionId":"abc12345-1234-1234-1234-abcdef123456","cwd":"/home/user/myproj","version":"2.1.71","gitBranch":"main","isSidechain":false,"requestId":"req-001","message":{"model":"claude-3-5-sonnet","id":"msg-abc","type":"message","role":"assistant","content":[{"type":"text","text":"hi back"}],"stop_reason":"end_turn","usage":{"input_tokens":10,"output_tokens":5}}}
|
||||
@@ -0,0 +1,153 @@
|
||||
//! End-to-end test: import a Claude fixture twice, expect no duplication;
|
||||
//! then append a new message and re-import, expect exactly 1 new message.
|
||||
|
||||
use camino::Utf8PathBuf;
|
||||
use dirigent_archivist::{
|
||||
backends::JsonlBackend,
|
||||
import::{claude::import_claude_sessions, ImportProgressSink},
|
||||
Archivist, SessionListQuery,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn fixture_root() -> Utf8PathBuf {
|
||||
Utf8PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap())
|
||||
.join("tests/fixtures/claude_minimal")
|
||||
}
|
||||
|
||||
/// Build a self-contained coordinator for a given archive root.
|
||||
///
|
||||
/// Uses `from_single_backend` so that parallel-test runs do not race on a
|
||||
/// shared `.archives.json` in the tempdir's parent (which is what
|
||||
/// `new_with_single_archive` would create).
|
||||
async fn mk_archivist(root: std::path::PathBuf) -> dirigent_archivist::Result<Archivist> {
|
||||
let backend = Arc::new(JsonlBackend::new(root).await?);
|
||||
Archivist::from_single_backend("main".into(), backend).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn claude_import_twice_is_idempotent() -> dirigent_archivist::Result<()> {
|
||||
let tmp = std::env::temp_dir().join(format!("claude_idem_{}", Uuid::now_v7()));
|
||||
let archivist = mk_archivist(tmp.clone()).await?;
|
||||
|
||||
let fixture = fixture_root();
|
||||
|
||||
// First run — should import everything.
|
||||
let stats1 = import_claude_sessions(&archivist, &fixture, None, &ImportProgressSink::noop(), &std::collections::HashMap::new()).await?;
|
||||
assert!(
|
||||
stats1.sessions_imported >= 1,
|
||||
"expected at least one imported session, got stats {:?}",
|
||||
stats1
|
||||
);
|
||||
assert!(
|
||||
stats1.messages_written >= 2,
|
||||
"expected >=2 messages written, got {:?}",
|
||||
stats1
|
||||
);
|
||||
|
||||
// Second run — should write nothing (fingerprint gate skips unchanged sessions).
|
||||
let stats2 = import_claude_sessions(&archivist, &fixture, None, &ImportProgressSink::noop(), &std::collections::HashMap::new()).await?;
|
||||
assert_eq!(
|
||||
stats2.messages_written, 0,
|
||||
"expected no re-write on second import, got {:?}",
|
||||
stats2
|
||||
);
|
||||
assert_eq!(stats2.sessions_imported, 0);
|
||||
assert!(
|
||||
stats2.sessions_skipped >= 1,
|
||||
"expected at least one skipped session, got {:?}",
|
||||
stats2
|
||||
);
|
||||
|
||||
// Verify on disk: no duplicate message_ids within any session.
|
||||
let page = archivist
|
||||
.list_sessions_paged(SessionListQuery::default().with_limit(200))
|
||||
.await?;
|
||||
for session in &page.items {
|
||||
let messages = archivist.get_messages(session.scroll_id, None).await?;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for m in &messages {
|
||||
assert!(
|
||||
seen.insert(m.message_id),
|
||||
"duplicate message_id {} in session {}",
|
||||
m.message_id,
|
||||
session.scroll_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(tmp).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn claude_import_picks_up_additive_growth() -> dirigent_archivist::Result<()> {
|
||||
// Copy the fixture to a mutable temp dir so we can append a message.
|
||||
let tmp_src = std::env::temp_dir().join(format!("claude_grow_src_{}", Uuid::now_v7()));
|
||||
let fixture = fixture_root();
|
||||
copy_dir_recursive(&fixture.as_std_path().to_path_buf(), &tmp_src).await;
|
||||
|
||||
let tmp_arch = std::env::temp_dir().join(format!("claude_grow_arch_{}", Uuid::now_v7()));
|
||||
let archivist = mk_archivist(tmp_arch.clone()).await?;
|
||||
|
||||
let src = Utf8PathBuf::from_path_buf(tmp_src.clone()).unwrap();
|
||||
let _ = import_claude_sessions(&archivist, &src, None, &ImportProgressSink::noop(), &std::collections::HashMap::new()).await?;
|
||||
|
||||
// Append a new message to the existing JSONL.
|
||||
let jsonl = find_jsonl(&tmp_src).expect("fixture jsonl not found");
|
||||
let extra = r#"{"type":"user","uuid":"33333333-3333-7333-8333-333333333333","parentUuid":"22222222-2222-7222-8222-222222222222","timestamp":"2024-01-01T00:00:02Z","sessionId":"abc12345-1234-1234-1234-abcdef123456","cwd":"/home/user/myproj","version":"2.1.71","gitBranch":"main","isSidechain":false,"isMeta":false,"userType":"external","message":{"role":"user","content":"follow up"}}"#;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let mut f = tokio::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&jsonl)
|
||||
.await
|
||||
.unwrap();
|
||||
f.write_all(extra.as_bytes()).await.unwrap();
|
||||
f.write_all(b"\n").await.unwrap();
|
||||
drop(f);
|
||||
|
||||
let stats = import_claude_sessions(&archivist, &src, None, &ImportProgressSink::noop(), &std::collections::HashMap::new()).await?;
|
||||
assert_eq!(
|
||||
stats.messages_written, 1,
|
||||
"expected 1 new message to be imported, got {:?}",
|
||||
stats
|
||||
);
|
||||
assert_eq!(
|
||||
stats.sessions_updated, 1,
|
||||
"expected 1 session updated, got {:?}",
|
||||
stats
|
||||
);
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(tmp_src).await;
|
||||
let _ = tokio::fs::remove_dir_all(tmp_arch).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) {
|
||||
tokio::fs::create_dir_all(dst).await.unwrap();
|
||||
let mut stack = vec![(src.to_path_buf(), dst.to_path_buf())];
|
||||
while let Some((s, d)) = stack.pop() {
|
||||
let mut entries = tokio::fs::read_dir(&s).await.unwrap();
|
||||
while let Some(entry) = entries.next_entry().await.unwrap() {
|
||||
let from = entry.path();
|
||||
let to = d.join(entry.file_name());
|
||||
if entry.file_type().await.unwrap().is_dir() {
|
||||
tokio::fs::create_dir_all(&to).await.unwrap();
|
||||
stack.push((from, to));
|
||||
} else {
|
||||
tokio::fs::copy(&from, &to).await.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_jsonl(dir: &std::path::Path) -> Option<std::path::PathBuf> {
|
||||
for entry in walkdir::WalkDir::new(dir).into_iter().flatten() {
|
||||
if entry.file_type().is_file()
|
||||
&& entry.path().extension().and_then(|s| s.to_str()) == Some("jsonl")
|
||||
{
|
||||
return Some(entry.path().to_path_buf());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Integration test: importer trait progress events fire in expected order.
|
||||
//!
|
||||
//! Drives a full `ChatGptImporter::import` against a fixture and asserts on
|
||||
//! the `ImportProgressEvent` sequence observed on the paired receiver.
|
||||
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use dirigent_archivist::{
|
||||
backends::JsonlBackend,
|
||||
coordinator::Archivist,
|
||||
import::{
|
||||
ImportConfig, ImportProgressEvent, ImportProgressSink, ImportTarget, ImporterRegistry,
|
||||
},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn progress_event_sequence_is_well_formed() {
|
||||
// 1. Setup an in-memory archivist (JsonlBackend in tempdir).
|
||||
let dir = TempDir::new().unwrap();
|
||||
let backend = Arc::new(JsonlBackend::new(dir.path().to_path_buf()).await.unwrap());
|
||||
let archivist = Archivist::from_single_backend("main".into(), backend)
|
||||
.await
|
||||
.unwrap();
|
||||
let archivist = Arc::new(archivist);
|
||||
|
||||
// 2. Use the chatgpt fixture — a minimal conversations.json with a
|
||||
// user + assistant message pair.
|
||||
let fixture = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../dirigent_chatgpt/tests/fixtures/minimal.json");
|
||||
assert!(
|
||||
fixture.exists(),
|
||||
"chatgpt fixture missing at {}",
|
||||
fixture.display()
|
||||
);
|
||||
|
||||
let cfg = ImportConfig {
|
||||
source: "chatgpt".into(),
|
||||
params: {
|
||||
let mut m = std::collections::BTreeMap::new();
|
||||
m.insert("path".into(), serde_json::json!(fixture.display().to_string()));
|
||||
m
|
||||
},
|
||||
};
|
||||
|
||||
// 3. Run the import with a channel sink.
|
||||
let registry = ImporterRegistry::default();
|
||||
let importer = registry.get("chatgpt").expect("chatgpt registered");
|
||||
let (sink, mut rx) = ImportProgressSink::channel();
|
||||
|
||||
let archivist_for_job = archivist.clone();
|
||||
let job = tokio::spawn(async move {
|
||||
importer
|
||||
.import(&cfg, &*archivist_for_job, ImportTarget::default(), sink)
|
||||
.await
|
||||
});
|
||||
|
||||
// 4. Collect all events until the sender side is dropped.
|
||||
let mut events = Vec::new();
|
||||
while let Some(evt) = rx.recv().await {
|
||||
events.push(evt);
|
||||
}
|
||||
let stats = job.await.unwrap().expect("import");
|
||||
|
||||
// 5. Assertions on the event sequence.
|
||||
// Must contain at least one SessionStarted before any SessionFinished.
|
||||
let started_idx = events
|
||||
.iter()
|
||||
.position(|e| matches!(e, ImportProgressEvent::SessionStarted { .. }));
|
||||
let finished_idx = events
|
||||
.iter()
|
||||
.position(|e| matches!(e, ImportProgressEvent::SessionFinished { .. }));
|
||||
|
||||
assert!(started_idx.is_some(), "expected a SessionStarted event");
|
||||
assert!(finished_idx.is_some(), "expected a SessionFinished event");
|
||||
assert!(
|
||||
started_idx.unwrap() < finished_idx.unwrap(),
|
||||
"SessionStarted must precede SessionFinished"
|
||||
);
|
||||
|
||||
// Stats shows at least 2 messages written (chatgpt fixture has a user
|
||||
// + assistant pair).
|
||||
assert!(
|
||||
stats.messages_written >= 2,
|
||||
"expected messages to be written, got stats {:?}",
|
||||
stats
|
||||
);
|
||||
assert_eq!(stats.sessions_imported, 1);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,364 @@
|
||||
//! Tests for `Archivist::list_sessions_paged` — pagination, filters, cursor stability.
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
use dirigent_archivist::{
|
||||
backends::JsonlBackend, Archivist, RegisterConnectorRequest, RegisterSessionRequest,
|
||||
Result, SessionListQuery,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Scaffold: create a coordinator backed by a single `JsonlBackend` in a
|
||||
/// unique temp dir, returning the backend alongside it so tests can probe
|
||||
/// disk paths via `backend.paths()`.
|
||||
async fn mk_archivist() -> Result<(Archivist, Arc<JsonlBackend>, std::path::PathBuf)> {
|
||||
let temp_dir = std::env::temp_dir().join(format!("paged_test_{}", Uuid::now_v7()));
|
||||
let backend = Arc::new(JsonlBackend::new(temp_dir.clone()).await?);
|
||||
let archivist =
|
||||
Archivist::from_single_backend("main".into(), backend.clone()).await?;
|
||||
Ok((archivist, backend, temp_dir))
|
||||
}
|
||||
|
||||
/// Register a connector, return its UID.
|
||||
async fn mk_connector(archivist: &Archivist, title: &str) -> Result<Uuid> {
|
||||
let resp = archivist
|
||||
.register_connector(
|
||||
RegisterConnectorRequest {
|
||||
r#type: "OpenCode".to_string(),
|
||||
title: title.to_string(),
|
||||
client_native_id: format!("{title}@local:{}", Uuid::now_v7()),
|
||||
custom_uid: None,
|
||||
metadata: serde_json::json!({}),
|
||||
fingerprint: None,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(resp.connector_uid)
|
||||
}
|
||||
|
||||
/// Register a session and patch fields that `register_session` does not expose.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn mk_session(
|
||||
archivist: &Archivist,
|
||||
backend: &JsonlBackend,
|
||||
connector_uid: Uuid,
|
||||
native_id: &str,
|
||||
title: Option<&str>,
|
||||
tags: Vec<String>,
|
||||
model: Option<&str>,
|
||||
project_id: Option<&str>,
|
||||
no_update: bool,
|
||||
) -> Result<Uuid> {
|
||||
let mut metadata = serde_json::Map::new();
|
||||
if let Some(m) = model {
|
||||
metadata.insert("model".to_string(), serde_json::Value::String(m.to_string()));
|
||||
}
|
||||
if let Some(p) = project_id {
|
||||
metadata.insert(
|
||||
"project_id".to_string(),
|
||||
serde_json::Value::String(p.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
let resp = archivist
|
||||
.register_session(
|
||||
RegisterSessionRequest {
|
||||
connector_uid,
|
||||
native_session_id: native_id.to_string(),
|
||||
title: title.map(String::from),
|
||||
custom_scroll_id: None,
|
||||
metadata: serde_json::Value::Object(metadata),
|
||||
completeness: Default::default(),
|
||||
parent_scroll_id: None,
|
||||
is_subagent: false,
|
||||
continuation: None,
|
||||
agent_id: None,
|
||||
subagent_type: None,
|
||||
spawning_tool_use_id: None,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let scroll_id = resp.scroll_id;
|
||||
|
||||
// Patch tags / no_update into session.json on disk.
|
||||
if !tags.is_empty() || no_update {
|
||||
let mut meta = archivist.get_session_metadata(scroll_id, None).await?;
|
||||
meta.tags = tags;
|
||||
meta.no_update = no_update;
|
||||
let path = backend.paths().session_json(scroll_id);
|
||||
dirigent_archivist::storage::json::write_json(&path, &meta)
|
||||
.await
|
||||
.map_err(dirigent_archivist::ArchivistError::Io)?;
|
||||
}
|
||||
|
||||
Ok(scroll_id)
|
||||
}
|
||||
|
||||
/// Overwrite a session's updated_at on disk for deterministic ordering.
|
||||
async fn set_updated_at(
|
||||
archivist: &Archivist,
|
||||
backend: &JsonlBackend,
|
||||
scroll_id: Uuid,
|
||||
when: chrono::DateTime<chrono::Utc>,
|
||||
) -> Result<()> {
|
||||
let mut meta = archivist.get_session_metadata(scroll_id, None).await?;
|
||||
meta.updated_at = when;
|
||||
let path = backend.paths().session_json(scroll_id);
|
||||
dirigent_archivist::storage::json::write_json(&path, &meta)
|
||||
.await
|
||||
.map_err(dirigent_archivist::ArchivistError::Io)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cleanup(path: std::path::PathBuf) {
|
||||
let _ = std::fs::remove_dir_all(path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_sessions_paged_respects_limit() -> Result<()> {
|
||||
let (archivist, backend, temp) = mk_archivist().await?;
|
||||
let uid = mk_connector(&archivist, "connector-a").await?;
|
||||
|
||||
let base = Utc::now();
|
||||
for i in 0..30 {
|
||||
let scroll = mk_session(
|
||||
&archivist,
|
||||
&backend,
|
||||
uid,
|
||||
&format!("native-{i}"),
|
||||
Some(&format!("title-{i}")),
|
||||
Vec::new(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
set_updated_at(&archivist, &backend, scroll, base - Duration::seconds(i)).await?;
|
||||
}
|
||||
|
||||
let page = archivist
|
||||
.list_sessions_paged(SessionListQuery::default().with_connector(uid).with_limit(10))
|
||||
.await?;
|
||||
|
||||
assert_eq!(page.items.len(), 10);
|
||||
assert!(page.next_cursor.is_some());
|
||||
|
||||
cleanup(temp);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_sessions_paged_end_of_list() -> Result<()> {
|
||||
let (archivist, backend, temp) = mk_archivist().await?;
|
||||
let uid = mk_connector(&archivist, "connector-b").await?;
|
||||
|
||||
let base = Utc::now();
|
||||
for i in 0..5 {
|
||||
let scroll = mk_session(
|
||||
&archivist,
|
||||
&backend,
|
||||
uid,
|
||||
&format!("native-{i}"),
|
||||
Some(&format!("title-{i}")),
|
||||
Vec::new(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
set_updated_at(&archivist, &backend, scroll, base - Duration::seconds(i)).await?;
|
||||
}
|
||||
|
||||
let page = archivist
|
||||
.list_sessions_paged(SessionListQuery::default().with_connector(uid).with_limit(100))
|
||||
.await?;
|
||||
|
||||
assert_eq!(page.items.len(), 5);
|
||||
assert!(page.next_cursor.is_none());
|
||||
|
||||
cleanup(temp);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_sessions_paged_cursor_stability() -> Result<()> {
|
||||
let (archivist, backend, temp) = mk_archivist().await?;
|
||||
let uid = mk_connector(&archivist, "connector-c").await?;
|
||||
|
||||
let fixed = Utc::now();
|
||||
for i in 0..6 {
|
||||
let scroll = mk_session(
|
||||
&archivist,
|
||||
&backend,
|
||||
uid,
|
||||
&format!("native-{i}"),
|
||||
Some(&format!("title-{i}")),
|
||||
Vec::new(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
set_updated_at(&archivist, &backend, scroll, fixed).await?;
|
||||
}
|
||||
|
||||
let p1 = archivist
|
||||
.list_sessions_paged(SessionListQuery::default().with_connector(uid).with_limit(3))
|
||||
.await?;
|
||||
assert_eq!(p1.items.len(), 3);
|
||||
assert!(p1.next_cursor.is_some());
|
||||
|
||||
let p2 = archivist
|
||||
.list_sessions_paged(
|
||||
SessionListQuery::default()
|
||||
.with_connector(uid)
|
||||
.with_limit(3)
|
||||
.with_cursor(p1.next_cursor.clone()),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(p2.items.len(), 3);
|
||||
|
||||
let ids1: std::collections::HashSet<_> = p1.items.iter().map(|s| s.scroll_id).collect();
|
||||
let ids2: std::collections::HashSet<_> = p2.items.iter().map(|s| s.scroll_id).collect();
|
||||
assert!(ids1.is_disjoint(&ids2), "page 1 and page 2 must not overlap");
|
||||
assert!(p2.next_cursor.is_none());
|
||||
|
||||
cleanup(temp);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_sessions_paged_title_filter() -> Result<()> {
|
||||
let (archivist, backend, temp) = mk_archivist().await?;
|
||||
let uid = mk_connector(&archivist, "connector-d").await?;
|
||||
|
||||
mk_session(&archivist, &backend, uid, "n1", Some("Alpha beta"), vec![], None, None, false).await?;
|
||||
mk_session(&archivist, &backend, uid, "n2", Some("BETA only"), vec![], None, None, false).await?;
|
||||
mk_session(&archivist, &backend, uid, "n3", Some("gamma"), vec![], None, None, false).await?;
|
||||
mk_session(&archivist, &backend, uid, "n4", None, vec![], None, None, false).await?;
|
||||
|
||||
let page = archivist
|
||||
.list_sessions_paged(
|
||||
SessionListQuery::default()
|
||||
.with_connector(uid)
|
||||
.with_limit(50)
|
||||
.with_title_query("beta"),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let titles: Vec<_> = page.items.iter().filter_map(|s| s.title.clone()).collect();
|
||||
assert_eq!(titles.len(), 2, "got {titles:?}");
|
||||
assert!(titles.iter().any(|t| t == "Alpha beta"));
|
||||
assert!(titles.iter().any(|t| t == "BETA only"));
|
||||
|
||||
cleanup(temp);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_sessions_paged_tags_and() -> Result<()> {
|
||||
let (archivist, backend, temp) = mk_archivist().await?;
|
||||
let uid = mk_connector(&archivist, "connector-e").await?;
|
||||
|
||||
mk_session(
|
||||
&archivist, &backend, uid, "n1", Some("s1"),
|
||||
vec!["red".into(), "blue".into()], None, None, false,
|
||||
).await?;
|
||||
mk_session(
|
||||
&archivist, &backend, uid, "n2", Some("s2"),
|
||||
vec!["red".into()], None, None, false,
|
||||
).await?;
|
||||
mk_session(
|
||||
&archivist, &backend, uid, "n3", Some("s3"),
|
||||
vec!["blue".into()], None, None, false,
|
||||
).await?;
|
||||
|
||||
let mut q = SessionListQuery::default().with_connector(uid).with_limit(50);
|
||||
q.tags = vec!["red".into(), "blue".into()];
|
||||
|
||||
let page = archivist.list_sessions_paged(q).await?;
|
||||
|
||||
assert_eq!(page.items.len(), 1);
|
||||
assert_eq!(page.items[0].title.as_deref(), Some("s1"));
|
||||
|
||||
cleanup(temp);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_sessions_paged_model_filter() -> Result<()> {
|
||||
let (archivist, backend, temp) = mk_archivist().await?;
|
||||
let uid = mk_connector(&archivist, "connector-f").await?;
|
||||
|
||||
mk_session(&archivist, &backend, uid, "n1", Some("s1"), vec![], Some("claude-3-5-sonnet"), None, false).await?;
|
||||
mk_session(&archivist, &backend, uid, "n2", Some("s2"), vec![], Some("gpt-4o"), None, false).await?;
|
||||
mk_session(&archivist, &backend, uid, "n3", Some("s3"), vec![], None, None, false).await?;
|
||||
|
||||
let mut q = SessionListQuery::default().with_connector(uid).with_limit(50);
|
||||
q.model_filter = Some("sonnet".into());
|
||||
|
||||
let page = archivist.list_sessions_paged(q).await?;
|
||||
|
||||
assert_eq!(page.items.len(), 1);
|
||||
assert_eq!(page.items[0].title.as_deref(), Some("s1"));
|
||||
|
||||
cleanup(temp);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_sessions_paged_include_hidden() -> Result<()> {
|
||||
let (archivist, backend, temp) = mk_archivist().await?;
|
||||
let uid = mk_connector(&archivist, "connector-g").await?;
|
||||
|
||||
mk_session(&archivist, &backend, uid, "n1", Some("visible"), vec![], None, None, false).await?;
|
||||
mk_session(&archivist, &backend, uid, "n2", Some("hidden"), vec![], None, None, true).await?;
|
||||
|
||||
let visible_only = archivist
|
||||
.list_sessions_paged(SessionListQuery::default().with_connector(uid).with_limit(50))
|
||||
.await?;
|
||||
assert_eq!(visible_only.items.len(), 1);
|
||||
assert_eq!(visible_only.items[0].title.as_deref(), Some("visible"));
|
||||
|
||||
let all = archivist
|
||||
.list_sessions_paged(
|
||||
SessionListQuery::default()
|
||||
.with_connector(uid)
|
||||
.with_limit(50)
|
||||
.with_include_hidden(true),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(all.items.len(), 2);
|
||||
|
||||
cleanup(temp);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_sessions_paged_project_scope() -> Result<()> {
|
||||
let (archivist, backend, temp) = mk_archivist().await?;
|
||||
let c1 = mk_connector(&archivist, "connector-h1").await?;
|
||||
let c2 = mk_connector(&archivist, "connector-h2").await?;
|
||||
|
||||
mk_session(&archivist, &backend, c1, "n1", Some("proj-a-1"), vec![], None, Some("proj-a"), false).await?;
|
||||
mk_session(&archivist, &backend, c1, "n2", Some("proj-b-1"), vec![], None, Some("proj-b"), false).await?;
|
||||
mk_session(&archivist, &backend, c2, "n3", Some("proj-a-2"), vec![], None, Some("proj-a"), false).await?;
|
||||
|
||||
let page = archivist
|
||||
.list_sessions_paged(
|
||||
SessionListQuery::default()
|
||||
.with_project("proj-a")
|
||||
.with_limit(50),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(page.items.len(), 2);
|
||||
for s in &page.items {
|
||||
assert_eq!(s.metadata.get("project_id").and_then(|v| v.as_str()), Some("proj-a"));
|
||||
}
|
||||
|
||||
cleanup(temp);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
use dirigent_archivist::{
|
||||
coordinator::Archivist,
|
||||
error::ArchivistBootError,
|
||||
registry::{ArchivesConfig, BackendRegistry},
|
||||
};
|
||||
|
||||
fn parse(toml_src: &str) -> ArchivesConfig {
|
||||
toml::from_str(toml_src).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn boot_with_one_jsonl_archive() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cfg = parse(&format!(
|
||||
r#"
|
||||
[[archives]]
|
||||
name = "main"
|
||||
type = "jsonl"
|
||||
[archives.params]
|
||||
path = "{}"
|
||||
"#,
|
||||
dir.path().to_string_lossy().replace('\\', "/")
|
||||
));
|
||||
let registry = BackendRegistry::with_jsonl();
|
||||
let _archivist = Archivist::from_config(cfg, ®istry, None).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn boot_empty_config_is_ephemeral() {
|
||||
let cfg: ArchivesConfig = toml::from_str("").unwrap();
|
||||
let registry = BackendRegistry::with_jsonl();
|
||||
let archivist = Archivist::from_config(cfg, ®istry, None).await.unwrap();
|
||||
let archives = archivist.list_archives().await.unwrap();
|
||||
assert!(archives.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn boot_unknown_type_errors() {
|
||||
let cfg = parse(
|
||||
r#"
|
||||
[[archives]]
|
||||
name = "x"
|
||||
type = "nope"
|
||||
[archives.params]
|
||||
"#,
|
||||
);
|
||||
let registry = BackendRegistry::with_jsonl();
|
||||
let result = Archivist::from_config(cfg, ®istry, None).await;
|
||||
match result {
|
||||
Ok(_) => panic!("expected UnknownType error"),
|
||||
Err(err) => assert!(
|
||||
matches!(err, ArchivistBootError::UnknownType { .. }),
|
||||
"expected UnknownType, got {err:?}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn boot_no_primary_errors() {
|
||||
let cfg = parse(
|
||||
r#"
|
||||
[[archives]]
|
||||
name = "mirror"
|
||||
type = "jsonl"
|
||||
failure_mode = "best_effort"
|
||||
[archives.params]
|
||||
path = "/tmp/whatever"
|
||||
"#,
|
||||
);
|
||||
let registry = BackendRegistry::with_jsonl();
|
||||
let result = Archivist::from_config(cfg, ®istry, None).await;
|
||||
match result {
|
||||
Ok(_) => panic!("expected Validation error"),
|
||||
Err(err) => assert!(
|
||||
matches!(err, ArchivistBootError::Validation(_)),
|
||||
"expected Validation, got {err:?}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn boot_duplicate_name_errors() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cfg = parse(&format!(
|
||||
r#"
|
||||
[[archives]]
|
||||
name = "main"
|
||||
type = "jsonl"
|
||||
[archives.params]
|
||||
path = "{p}"
|
||||
|
||||
[[archives]]
|
||||
name = "main"
|
||||
type = "jsonl"
|
||||
[archives.params]
|
||||
path = "{p}"
|
||||
"#,
|
||||
p = dir.path().to_string_lossy().replace('\\', "/"),
|
||||
));
|
||||
let registry = BackendRegistry::with_jsonl();
|
||||
let result = Archivist::from_config(cfg, ®istry, None).await;
|
||||
match result {
|
||||
Ok(_) => panic!("expected Validation error"),
|
||||
Err(err) => assert!(
|
||||
matches!(err, ArchivistBootError::Validation(_)),
|
||||
"expected Validation, got {err:?}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn example_toml_parses() {
|
||||
// Load the full dirigent.toml.example and parse just the [[archives]]
|
||||
// section as `ArchivesConfig`. Confirms the example's archive syntax is
|
||||
// valid Phase 3 schema.
|
||||
let src = std::fs::read_to_string(
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../dirigent.toml.example"),
|
||||
)
|
||||
.expect("dirigent.toml.example present at workspace root");
|
||||
// Parse the whole file as a TOML value, then try to deserialize the
|
||||
// full document into `ArchivesConfig`. Any `archives` subtable gets picked up;
|
||||
// other top-level fields (connectors, matrix, ...) are ignored because
|
||||
// `ArchivesConfig` only has `entries: Vec<ArchiveConfig>` via
|
||||
// `#[serde(rename = "archives")]`.
|
||||
let cfg: ArchivesConfig =
|
||||
toml::from_str(&src).expect("ArchivesConfig from full example");
|
||||
cfg.validate().expect("example config validates");
|
||||
assert!(!cfg.entries.is_empty(), "example must declare at least one archive");
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
#![cfg(feature = "test-utils")]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use dirigent_archivist::backend::mock::MockBackend;
|
||||
use dirigent_archivist::backend::{ArchiveBackend, ArchiveCapability, CapabilitySet, HealthStatus};
|
||||
use dirigent_archivist::coordinator::Archivist;
|
||||
use dirigent_archivist::registry::{ArchiveRegistration, FailureMode, WritePolicy};
|
||||
use dirigent_archivist::types::{MetaEventRecord, MetaEventType};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn reg(name: &str, backend: Arc<MockBackend>, priority: u32) -> Arc<ArchiveRegistration> {
|
||||
Arc::new(ArchiveRegistration::new(
|
||||
name.into(),
|
||||
"mock",
|
||||
backend as Arc<dyn ArchiveBackend>,
|
||||
true,
|
||||
FailureMode::Required,
|
||||
priority,
|
||||
true,
|
||||
WritePolicy::Inline,
|
||||
None,
|
||||
HealthStatus::Healthy,
|
||||
))
|
||||
}
|
||||
|
||||
fn stub_meta_event(scroll_id: Uuid) -> MetaEventRecord {
|
||||
MetaEventRecord {
|
||||
version: 1,
|
||||
event_id: Uuid::now_v7(),
|
||||
session: scroll_id,
|
||||
ts: chrono::Utc::now(),
|
||||
event_type: MetaEventType::ClientConnected,
|
||||
description: "test event".into(),
|
||||
linked_session_id: None,
|
||||
linked_connector_id: None,
|
||||
linked_connector_title: None,
|
||||
metadata: serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capability_filter_skips_backend_without_meta_events() {
|
||||
let mut caps_with_meta = CapabilitySet::new();
|
||||
caps_with_meta.insert(ArchiveCapability::MetaEvents);
|
||||
caps_with_meta.insert(ArchiveCapability::SessionMapping);
|
||||
caps_with_meta.insert(ArchiveCapability::ConnectorRegistry);
|
||||
let with_meta = Arc::new(MockBackend::with_capabilities(caps_with_meta));
|
||||
|
||||
let mut caps_without_meta = CapabilitySet::new();
|
||||
caps_without_meta.insert(ArchiveCapability::SessionMapping);
|
||||
caps_without_meta.insert(ArchiveCapability::ConnectorRegistry);
|
||||
let without_meta = Arc::new(MockBackend::with_capabilities(caps_without_meta));
|
||||
|
||||
let archivist = Archivist::from_registrations(vec![
|
||||
reg("primary", with_meta.clone(), 0),
|
||||
reg("secondary", without_meta.clone(), 10),
|
||||
]);
|
||||
|
||||
let scroll = Uuid::new_v4();
|
||||
archivist
|
||||
.append_meta_events(scroll, vec![stub_meta_event(scroll)], None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Primary received the meta event.
|
||||
assert!(
|
||||
with_meta.has_meta_events(scroll),
|
||||
"primary should receive meta event"
|
||||
);
|
||||
// Secondary was capability-skipped.
|
||||
assert!(
|
||||
!without_meta.has_meta_events(scroll),
|
||||
"secondary should be skipped"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
#![cfg(feature = "test-utils")]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use dirigent_archivist::backend::mock::MockBackend;
|
||||
use dirigent_archivist::backend::ArchiveBackend;
|
||||
use dirigent_archivist::backend::HealthStatus;
|
||||
use dirigent_archivist::coordinator::Archivist;
|
||||
use dirigent_archivist::error::ArchivistError;
|
||||
use dirigent_archivist::registry::{ArchiveRegistration, FailureMode, WritePolicy};
|
||||
use dirigent_archivist::types::SessionMetadata;
|
||||
use uuid::Uuid;
|
||||
|
||||
async fn dual_backend_coordinator() -> (Archivist, Arc<MockBackend>, Arc<MockBackend>) {
|
||||
let a = Arc::new(MockBackend::new());
|
||||
let b = Arc::new(MockBackend::new());
|
||||
let regs = vec![
|
||||
Arc::new(ArchiveRegistration::new(
|
||||
"a".into(),
|
||||
"mock",
|
||||
a.clone() as Arc<dyn ArchiveBackend>,
|
||||
true,
|
||||
FailureMode::Required,
|
||||
0,
|
||||
true,
|
||||
WritePolicy::Inline,
|
||||
None,
|
||||
HealthStatus::Healthy,
|
||||
)),
|
||||
Arc::new(ArchiveRegistration::new(
|
||||
"b".into(),
|
||||
"mock",
|
||||
b.clone() as Arc<dyn ArchiveBackend>,
|
||||
true,
|
||||
FailureMode::Required,
|
||||
10,
|
||||
true,
|
||||
WritePolicy::Inline,
|
||||
None,
|
||||
HealthStatus::Healthy,
|
||||
)),
|
||||
];
|
||||
(Archivist::from_registrations(regs), a, b)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn copy_session_carries_metadata_and_messages() {
|
||||
let (archivist, a, b) = dual_backend_coordinator().await;
|
||||
let scroll = Uuid::new_v4();
|
||||
|
||||
// Seed `a` only.
|
||||
a.put_session(SessionMetadata::stub(scroll)).await.unwrap();
|
||||
a.append_messages(scroll, vec![]).await.unwrap();
|
||||
|
||||
archivist.copy_session(scroll, "a", "b").await.unwrap();
|
||||
|
||||
assert!(b.get_session(scroll).await.unwrap().is_some());
|
||||
assert!(a.get_session(scroll).await.unwrap().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn move_session_removes_from_source() {
|
||||
let (archivist, a, b) = dual_backend_coordinator().await;
|
||||
let scroll = Uuid::new_v4();
|
||||
a.put_session(SessionMetadata::stub(scroll)).await.unwrap();
|
||||
|
||||
archivist.move_session(scroll, "a", "b").await.unwrap();
|
||||
|
||||
assert!(a.get_session(scroll).await.unwrap().is_none());
|
||||
assert!(b.get_session(scroll).await.unwrap().is_some());
|
||||
assert_eq!(
|
||||
archivist.read_cache_size().await,
|
||||
1,
|
||||
"cache should now reflect the move"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn move_session_partial_failure_returns_partial_move_error() {
|
||||
let (archivist, a, b) = dual_backend_coordinator().await;
|
||||
let scroll = Uuid::new_v4();
|
||||
a.put_session(SessionMetadata::stub(scroll)).await.unwrap();
|
||||
|
||||
// The source-side delete happens AFTER the copy. Inject ONE write failure
|
||||
// AFTER the copy has already consumed the write capacity. `MockBackend`'s
|
||||
// inject_write_failures decrements on every mutating call — so we:
|
||||
// 1. perform the copy through the archivist (uses put_session+append on `b`,
|
||||
// but NO writes on `a`, since reads happen on the source side).
|
||||
// 2. THEN inject a write failure on `a` to make the delete fail.
|
||||
//
|
||||
// Actually `copy_session` reads from `a` then writes to `b`, no writes on `a`.
|
||||
// So we can safely inject BEFORE calling move_session: the only write on `a`
|
||||
// during move_session is the delete, which will hit the injected failure.
|
||||
|
||||
a.inject_write_failures(1);
|
||||
|
||||
let err = archivist.move_session(scroll, "a", "b").await.unwrap_err();
|
||||
assert!(matches!(err, ArchivistError::PartialMove { .. }));
|
||||
|
||||
// Both backends now have the session.
|
||||
assert!(a.get_session(scroll).await.unwrap().is_some());
|
||||
assert!(b.get_session(scroll).await.unwrap().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_session_fans_out_and_invalidates_cache() {
|
||||
let (archivist, a, b) = dual_backend_coordinator().await;
|
||||
let scroll = Uuid::new_v4();
|
||||
a.put_session(SessionMetadata::stub(scroll)).await.unwrap();
|
||||
b.put_session(SessionMetadata::stub(scroll)).await.unwrap();
|
||||
|
||||
// Prime the cache with a read.
|
||||
let _ = archivist.get_session_metadata(scroll, None).await.unwrap();
|
||||
assert_eq!(archivist.read_cache_size().await, 1);
|
||||
|
||||
archivist.delete_session(scroll, None).await.unwrap();
|
||||
|
||||
assert!(a.get_session(scroll).await.unwrap().is_none());
|
||||
assert!(b.get_session(scroll).await.unwrap().is_none());
|
||||
assert_eq!(archivist.read_cache_size().await, 0);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
#![cfg(feature = "test-utils")]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use dirigent_archivist::backend::mock::MockBackend;
|
||||
use dirigent_archivist::backend::{ArchiveBackend, HealthStatus};
|
||||
use dirigent_archivist::coordinator::Archivist;
|
||||
use dirigent_archivist::registry::{ArchiveRegistration, FailureMode, WritePolicy};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn reg(
|
||||
name: &str,
|
||||
backend: Arc<MockBackend>,
|
||||
priority: u32,
|
||||
failure: FailureMode,
|
||||
) -> Arc<ArchiveRegistration> {
|
||||
Arc::new(ArchiveRegistration::new(
|
||||
name.into(),
|
||||
"mock",
|
||||
backend as Arc<dyn ArchiveBackend>,
|
||||
true,
|
||||
failure,
|
||||
priority,
|
||||
true,
|
||||
WritePolicy::Inline,
|
||||
None,
|
||||
HealthStatus::Healthy,
|
||||
))
|
||||
}
|
||||
|
||||
fn sample_message(session: Uuid) -> dirigent_archivist::types::MessageRecord {
|
||||
dirigent_archivist::types::MessageRecord {
|
||||
version: 1,
|
||||
message_id: Uuid::now_v7(),
|
||||
session,
|
||||
parent_id: None,
|
||||
ts: chrono::Utc::now(),
|
||||
role: "user".into(),
|
||||
author: None,
|
||||
content_md: "hi".into(),
|
||||
content_parts: None,
|
||||
attachments: vec![],
|
||||
metadata: serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_fans_out_to_both_backends() {
|
||||
let a = Arc::new(MockBackend::new());
|
||||
let b = Arc::new(MockBackend::new());
|
||||
let archivist = Archivist::from_registrations(vec![
|
||||
reg("a", a.clone(), 0, FailureMode::Required),
|
||||
reg("b", b.clone(), 10, FailureMode::BestEffort),
|
||||
]);
|
||||
|
||||
// Using a non-empty message vec for a robust positive-count check:
|
||||
let scroll = Uuid::new_v4();
|
||||
let m = sample_message(scroll);
|
||||
archivist
|
||||
.append_messages(scroll, vec![m], None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(a.appended_count(scroll), 1);
|
||||
assert_eq!(b.appended_count(scroll), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn best_effort_failure_does_not_propagate() {
|
||||
let a = Arc::new(MockBackend::new());
|
||||
let b = Arc::new(MockBackend::new());
|
||||
b.inject_write_failures(1);
|
||||
|
||||
let archivist = Archivist::from_registrations(vec![
|
||||
reg("a", a.clone(), 0, FailureMode::Required),
|
||||
reg("b", b.clone(), 10, FailureMode::BestEffort),
|
||||
]);
|
||||
|
||||
archivist
|
||||
.append_messages(Uuid::new_v4(), vec![], None)
|
||||
.await
|
||||
.unwrap(); // Ok despite secondary failure
|
||||
|
||||
let snapshot = archivist.list_archives_with_health().await;
|
||||
let b_status = snapshot.iter().find(|s| s.name == "b").unwrap();
|
||||
assert!(matches!(b_status.health, HealthStatus::Degraded { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn required_secondary_failure_propagates() {
|
||||
let a = Arc::new(MockBackend::new());
|
||||
let b = Arc::new(MockBackend::new());
|
||||
b.inject_write_failures(1);
|
||||
|
||||
let archivist = Archivist::from_registrations(vec![
|
||||
reg("a", a.clone(), 0, FailureMode::Required),
|
||||
reg("b", b.clone(), 10, FailureMode::Required),
|
||||
]);
|
||||
|
||||
let err = archivist
|
||||
.append_messages(Uuid::new_v4(), vec![], None)
|
||||
.await;
|
||||
assert!(err.is_err(), "expected error when required secondary fails");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_archive_overrides_default_primary() {
|
||||
let a = Arc::new(MockBackend::new());
|
||||
let b = Arc::new(MockBackend::new());
|
||||
let archivist = Archivist::from_registrations(vec![
|
||||
reg("a", a.clone(), 0, FailureMode::Required),
|
||||
reg("b", b.clone(), 10, FailureMode::Required),
|
||||
]);
|
||||
|
||||
let scroll = Uuid::new_v4();
|
||||
let m = sample_message(scroll);
|
||||
archivist
|
||||
.append_messages(scroll, vec![m], Some("b".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Both receive the write: b is explicit primary, a is secondary via fanout.
|
||||
assert_eq!(a.appended_count(scroll), 1);
|
||||
assert_eq!(b.appended_count(scroll), 1);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
#![cfg(feature = "test-utils")]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use dirigent_archivist::backend::mock::MockBackend;
|
||||
use dirigent_archivist::backend::{ArchiveBackend, HealthStatus};
|
||||
use dirigent_archivist::coordinator::Archivist;
|
||||
use dirigent_archivist::registry::{ArchiveRegistration, FailureMode, WritePolicy};
|
||||
use dirigent_archivist::types::SessionMetadata;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn reg(
|
||||
name: &str,
|
||||
backend: Arc<MockBackend>,
|
||||
priority: u32,
|
||||
failure: FailureMode,
|
||||
) -> Arc<ArchiveRegistration> {
|
||||
Arc::new(ArchiveRegistration::new(
|
||||
name.into(),
|
||||
"mock",
|
||||
backend as Arc<dyn ArchiveBackend>,
|
||||
true,
|
||||
failure,
|
||||
priority,
|
||||
true,
|
||||
WritePolicy::Inline,
|
||||
None,
|
||||
HealthStatus::Healthy,
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn five_consecutive_failures_drifts_to_unavailable() {
|
||||
let primary = Arc::new(MockBackend::new());
|
||||
let secondary = Arc::new(MockBackend::new());
|
||||
secondary.inject_write_failures(10);
|
||||
|
||||
let archivist = Archivist::from_registrations(vec![
|
||||
reg("primary", primary.clone(), 0, FailureMode::Required),
|
||||
reg("secondary", secondary.clone(), 10, FailureMode::BestEffort),
|
||||
]);
|
||||
|
||||
for _ in 0..5 {
|
||||
archivist
|
||||
.append_messages(Uuid::new_v4(), vec![], None)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
|
||||
let snapshot = archivist.list_archives_with_health().await;
|
||||
let secondary_status = snapshot.iter().find(|s| s.name == "secondary").unwrap();
|
||||
assert!(
|
||||
matches!(secondary_status.health, HealthStatus::Unavailable { .. }),
|
||||
"secondary should be Unavailable after 5 failures; got {:?}",
|
||||
secondary_status.health
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn success_after_failure_recovers_to_healthy() {
|
||||
let backend = Arc::new(MockBackend::new());
|
||||
backend.inject_write_failures(1);
|
||||
|
||||
let archivist = Archivist::from_registrations(vec![reg(
|
||||
"only",
|
||||
backend.clone(),
|
||||
0,
|
||||
FailureMode::Required,
|
||||
)]);
|
||||
|
||||
// First call fails.
|
||||
let _ = archivist
|
||||
.append_messages(Uuid::new_v4(), vec![], None)
|
||||
.await;
|
||||
let snapshot = archivist.list_archives_with_health().await;
|
||||
assert!(
|
||||
matches!(snapshot[0].health, HealthStatus::Degraded { .. }),
|
||||
"expected Degraded after first failure; got {:?}",
|
||||
snapshot[0].health
|
||||
);
|
||||
|
||||
// Second call succeeds — health returns to Healthy.
|
||||
archivist
|
||||
.append_messages(Uuid::new_v4(), vec![], None)
|
||||
.await
|
||||
.unwrap();
|
||||
let snapshot = archivist.list_archives_with_health().await;
|
||||
assert!(
|
||||
matches!(snapshot[0].health, HealthStatus::Healthy),
|
||||
"expected Healthy after recovery; got {:?}",
|
||||
snapshot[0].health
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unavailable_backend_skipped_during_read_walk() {
|
||||
let primary = Arc::new(MockBackend::new());
|
||||
let secondary = Arc::new(MockBackend::new());
|
||||
|
||||
let scroll = Uuid::new_v4();
|
||||
secondary
|
||||
.put_session(SessionMetadata::stub(scroll))
|
||||
.await
|
||||
.unwrap();
|
||||
secondary.break_permanently("kaput");
|
||||
|
||||
let primary_reg = reg("primary", primary.clone(), 0, FailureMode::Required);
|
||||
let secondary_reg = reg("secondary", secondary.clone(), 10, FailureMode::Required);
|
||||
// Force secondary's cached health to Unavailable BEFORE the walk,
|
||||
// so the routing layer skips it entirely rather than attempting + failing.
|
||||
*secondary_reg.last_health.write().await = HealthStatus::Unavailable {
|
||||
reason: "test".into(),
|
||||
};
|
||||
|
||||
let archivist = Archivist::from_registrations(vec![primary_reg, secondary_reg]);
|
||||
|
||||
// Primary doesn't have the session; secondary has it but is marked Unavailable.
|
||||
// Read walk should skip secondary → Ok(None)-style ergonomics, bubbling up
|
||||
// as `SessionUnknown` per `get_session_metadata`'s contract.
|
||||
let result = archivist.get_session_metadata(scroll, None).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"expected SessionUnknown error when Unavailable backend is skipped"
|
||||
);
|
||||
assert!(matches!(
|
||||
result.unwrap_err(),
|
||||
dirigent_archivist::error::ArchivistError::SessionUnknown(_)
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
#![cfg(feature = "test-utils")]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use dirigent_archivist::backend::mock::MockBackend;
|
||||
use dirigent_archivist::backend::{ArchiveBackend, HealthStatus};
|
||||
use dirigent_archivist::coordinator::Archivist;
|
||||
use dirigent_archivist::registry::{ArchiveRegistration, FailureMode, WritePolicy};
|
||||
use dirigent_archivist::types::SessionMetadata;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn reg(name: &str, backend: Arc<MockBackend>, priority: u32) -> Arc<ArchiveRegistration> {
|
||||
Arc::new(ArchiveRegistration::new(
|
||||
name.into(),
|
||||
"mock",
|
||||
backend as Arc<dyn ArchiveBackend>,
|
||||
true,
|
||||
FailureMode::Required,
|
||||
priority,
|
||||
true,
|
||||
WritePolicy::Inline,
|
||||
None,
|
||||
HealthStatus::Healthy,
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn high_priority_backend_serves_first() {
|
||||
let high = Arc::new(MockBackend::new());
|
||||
let low = Arc::new(MockBackend::new());
|
||||
let scroll = Uuid::new_v4();
|
||||
high.put_session(SessionMetadata::stub(scroll)).await.unwrap();
|
||||
|
||||
let archivist = Archivist::from_registrations(vec![
|
||||
reg("high", high.clone(), 0),
|
||||
reg("low", low.clone(), 10),
|
||||
]);
|
||||
|
||||
let meta = archivist.get_session_metadata(scroll, None).await;
|
||||
assert!(meta.is_ok(), "expected Ok; got {:?}", meta);
|
||||
assert_eq!(archivist.read_cache_size().await, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn falls_through_to_lower_priority_when_high_misses() {
|
||||
let high = Arc::new(MockBackend::new());
|
||||
let low = Arc::new(MockBackend::new());
|
||||
let scroll = Uuid::new_v4();
|
||||
low.put_session(SessionMetadata::stub(scroll)).await.unwrap();
|
||||
|
||||
let archivist = Archivist::from_registrations(vec![
|
||||
reg("high", high.clone(), 0),
|
||||
reg("low", low.clone(), 10),
|
||||
]);
|
||||
|
||||
let meta = archivist.get_session_metadata(scroll, None).await;
|
||||
assert!(meta.is_ok(), "expected Ok; got {:?}", meta);
|
||||
assert_eq!(archivist.read_cache_size().await, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cache_makes_second_read_skip_priority_walk() {
|
||||
let high = Arc::new(MockBackend::new());
|
||||
let low = Arc::new(MockBackend::new());
|
||||
let scroll = Uuid::new_v4();
|
||||
low.put_session(SessionMetadata::stub(scroll)).await.unwrap();
|
||||
|
||||
let archivist = Archivist::from_registrations(vec![
|
||||
reg("high", high.clone(), 0),
|
||||
reg("low", low.clone(), 10),
|
||||
]);
|
||||
|
||||
// Prime the cache.
|
||||
let _ = archivist.get_session_metadata(scroll, None).await.unwrap();
|
||||
|
||||
// Inject a read failure on `high` to detect whether the second read walks it.
|
||||
// If the cache works, `high` must NOT be touched.
|
||||
high.inject_read_failures(1);
|
||||
let _ = archivist.get_session_metadata(scroll, None).await.unwrap();
|
||||
|
||||
let snapshot = archivist.list_archives_with_health().await;
|
||||
let high_status = snapshot.iter().find(|s| s.name == "high").unwrap();
|
||||
assert!(
|
||||
matches!(high_status.health, HealthStatus::Healthy),
|
||||
"cache should have skipped `high`; got {:?}",
|
||||
high_status.health
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_invalidates_cache() {
|
||||
let high = Arc::new(MockBackend::new());
|
||||
let scroll = Uuid::new_v4();
|
||||
high.put_session(SessionMetadata::stub(scroll)).await.unwrap();
|
||||
|
||||
let archivist = Archivist::from_registrations(vec![reg("high", high.clone(), 0)]);
|
||||
let _ = archivist.get_session_metadata(scroll, None).await.unwrap();
|
||||
assert_eq!(archivist.read_cache_size().await, 1);
|
||||
|
||||
archivist.delete_session(scroll, None).await.unwrap();
|
||||
assert_eq!(archivist.read_cache_size().await, 0);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
#![cfg(feature = "test-utils")]
|
||||
|
||||
//! Integration tests for Task 17's per-backend queued writer task.
|
||||
//!
|
||||
//! These exercise the full enqueue → batch → coalesce → dispatch pipeline
|
||||
//! end-to-end by constructing real writer tasks against `MockBackend`
|
||||
//! instances and driving them through the `Archivist` coordinator.
|
||||
//!
|
||||
//! The tests are timing-sensitive: the batch window is 25ms and the
|
||||
//! backpressure test artificially slows the backend. Assertions use
|
||||
//! tolerant margins so they survive CI jitter.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use dirigent_archivist::backend::mock::MockBackend;
|
||||
use dirigent_archivist::backend::{ArchiveBackend, HealthStatus};
|
||||
use dirigent_archivist::coordinator::Archivist;
|
||||
use dirigent_archivist::registry::writer::spawn_writer;
|
||||
use dirigent_archivist::registry::{
|
||||
ArchiveRegistration, FailureMode, OverflowPolicy, WritePolicy,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn sample_message(scroll: Uuid) -> dirigent_archivist::types::MessageRecord {
|
||||
dirigent_archivist::types::MessageRecord {
|
||||
version: 1,
|
||||
message_id: Uuid::now_v7(),
|
||||
session: scroll,
|
||||
parent_id: None,
|
||||
ts: chrono::Utc::now(),
|
||||
role: "user".into(),
|
||||
author: None,
|
||||
content_md: "hi".into(),
|
||||
content_parts: None,
|
||||
attachments: vec![],
|
||||
metadata: serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn queued_reg(
|
||||
name: &str,
|
||||
backend: Arc<MockBackend>,
|
||||
priority: u32,
|
||||
overflow: OverflowPolicy,
|
||||
) -> Arc<ArchiveRegistration> {
|
||||
let initial_health = HealthStatus::Healthy;
|
||||
let policy = WritePolicy::Queued {
|
||||
batch_window_ms: 25,
|
||||
capacity: 8,
|
||||
overflow,
|
||||
};
|
||||
|
||||
let health = Arc::new(tokio::sync::RwLock::new(initial_health));
|
||||
let last_error = Arc::new(tokio::sync::RwLock::new(None));
|
||||
let consecutive = Arc::new(tokio::sync::RwLock::new(0u32));
|
||||
|
||||
let writer = Some(spawn_writer(
|
||||
backend.clone() as Arc<dyn ArchiveBackend>,
|
||||
name.into(),
|
||||
8,
|
||||
Duration::from_millis(25),
|
||||
overflow,
|
||||
health.clone(),
|
||||
last_error.clone(),
|
||||
consecutive.clone(),
|
||||
));
|
||||
|
||||
Arc::new(ArchiveRegistration::new_with_shared_state(
|
||||
name.into(),
|
||||
"mock",
|
||||
backend as Arc<dyn ArchiveBackend>,
|
||||
true,
|
||||
FailureMode::Required,
|
||||
priority,
|
||||
true,
|
||||
policy,
|
||||
writer,
|
||||
health,
|
||||
last_error,
|
||||
consecutive,
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queued_write_returns_immediately_then_eventually_lands() {
|
||||
let mock = Arc::new(MockBackend::new());
|
||||
let archivist = Archivist::from_registrations(vec![queued_reg(
|
||||
"queued",
|
||||
mock.clone(),
|
||||
0,
|
||||
OverflowPolicy::Block,
|
||||
)]);
|
||||
|
||||
let scroll = Uuid::new_v4();
|
||||
archivist
|
||||
.append_messages(scroll, vec![sample_message(scroll)], None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Wait up to 500ms for the writer to drain.
|
||||
let mut landed = false;
|
||||
for _ in 0..50 {
|
||||
if mock.appended_count(scroll) > 0 {
|
||||
landed = true;
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
assert!(landed, "writer task did not drain within 500ms");
|
||||
assert_eq!(mock.appended_count(scroll), 1);
|
||||
|
||||
archivist.shutdown().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn coalescing_merges_consecutive_appends_for_same_scroll() {
|
||||
let mock = Arc::new(MockBackend::new());
|
||||
let archivist = Archivist::from_registrations(vec![queued_reg(
|
||||
"queued",
|
||||
mock.clone(),
|
||||
0,
|
||||
OverflowPolicy::Block,
|
||||
)]);
|
||||
|
||||
let scroll = Uuid::new_v4();
|
||||
for _ in 0..5 {
|
||||
archivist
|
||||
.append_messages(scroll, vec![sample_message(scroll)], None)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Give the writer time to drain + coalesce, then shut down to guarantee
|
||||
// any still-queued ops are flushed before we assert.
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
archivist.shutdown().await.unwrap();
|
||||
|
||||
// Five enqueued ops may have been coalesced into fewer backend calls.
|
||||
// The only strict invariant we can reliably assert is: the total number
|
||||
// of backend `append_messages` INVOCATIONS is <= 5.
|
||||
assert!(
|
||||
mock.append_call_count(scroll) <= 5,
|
||||
"expected <= 5 backend calls, got {}",
|
||||
mock.append_call_count(scroll)
|
||||
);
|
||||
assert_eq!(
|
||||
mock.appended_count(scroll),
|
||||
5,
|
||||
"all 5 messages should land"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn overflow_block_applies_backpressure() {
|
||||
// For backpressure to visibly stall the sender, we need four things:
|
||||
// 1. A tight queue (capacity=2) so the channel actually fills up.
|
||||
// 2. A slow backend (per-op 50ms) so the writer stalls in dispatch
|
||||
// long enough for the channel to fill.
|
||||
// 3. batch_window=0 so the writer spends (almost) all its time in
|
||||
// the 50ms per-op sleep instead of draining fast inside the
|
||||
// batch-collection phase.
|
||||
// 4. Distinct scroll IDs so the writer's same-scroll coalescing
|
||||
// doesn't merge everything into one dispatch call (which would
|
||||
// collapse the entire batch into a single 50ms sleep).
|
||||
// With those, the writer dispatches N serial 50ms calls; while it's
|
||||
// sleeping the sender can't fit its next op into the full channel
|
||||
// and must wait for a drain.
|
||||
let mock = Arc::new(MockBackend::new());
|
||||
mock.set_per_op_delay(Duration::from_millis(50));
|
||||
|
||||
let capacity = 2usize;
|
||||
let overflow = OverflowPolicy::Block;
|
||||
// batch_window=0 means the writer dispatches each op immediately and
|
||||
// spends (almost) all its time in the 50ms per-op sleep — so the
|
||||
// channel stays full and the sender has to wait on every drain.
|
||||
let policy = WritePolicy::Queued {
|
||||
batch_window_ms: 0,
|
||||
capacity,
|
||||
overflow,
|
||||
};
|
||||
|
||||
let health = Arc::new(tokio::sync::RwLock::new(HealthStatus::Healthy));
|
||||
let last_error = Arc::new(tokio::sync::RwLock::new(None));
|
||||
let consecutive = Arc::new(tokio::sync::RwLock::new(0u32));
|
||||
|
||||
let writer = Some(spawn_writer(
|
||||
mock.clone() as Arc<dyn ArchiveBackend>,
|
||||
"queued".into(),
|
||||
capacity,
|
||||
Duration::from_millis(0),
|
||||
overflow,
|
||||
health.clone(),
|
||||
last_error.clone(),
|
||||
consecutive.clone(),
|
||||
));
|
||||
|
||||
let reg = Arc::new(ArchiveRegistration::new_with_shared_state(
|
||||
"queued".into(),
|
||||
"mock",
|
||||
mock.clone() as Arc<dyn ArchiveBackend>,
|
||||
true,
|
||||
FailureMode::Required,
|
||||
0,
|
||||
true,
|
||||
policy,
|
||||
writer,
|
||||
health,
|
||||
last_error,
|
||||
consecutive,
|
||||
));
|
||||
|
||||
let archivist = Archivist::from_registrations(vec![reg]);
|
||||
|
||||
// Prime the writer with one op and wait just long enough for it to
|
||||
// enter its first 50ms dispatch sleep. After that the writer is NOT
|
||||
// recv'ing, so the tight capacity=2 channel fills and further sends
|
||||
// must wait for a drain.
|
||||
let scroll0 = Uuid::new_v4();
|
||||
archivist
|
||||
.append_messages(scroll0, vec![sample_message(scroll0)], None)
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
|
||||
// Now measure the cost of many more sends with distinct scroll IDs
|
||||
// so the writer can't coalesce them. Each dispatch call is 50ms, the
|
||||
// queue holds only 2, so the sender must wait repeatedly for the
|
||||
// writer to drain cycles.
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..24 {
|
||||
let scroll = Uuid::new_v4();
|
||||
archivist
|
||||
.append_messages(scroll, vec![sample_message(scroll)], None)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
// With 24 distinct-scroll sends, a capacity=2 queue, batch_window=0,
|
||||
// and a 50ms per-op delay, the sender cannot finish instantly — the
|
||||
// writer needs many drain cycles and the sender waits on each. A
|
||||
// 100ms floor keeps the test meaningful (a non-blocking run measures
|
||||
// in microseconds) while being lenient on CI jitter.
|
||||
assert!(
|
||||
elapsed >= Duration::from_millis(100),
|
||||
"block policy did not apply backpressure (elapsed: {:?})",
|
||||
elapsed
|
||||
);
|
||||
|
||||
archivist.shutdown().await.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Pagination tests for dirigent_archivist
|
||||
//!
|
||||
//! These tests verify the count_messages and get_messages_range functionality.
|
||||
|
||||
#[cfg(test)]
|
||||
mod pagination_tests {
|
||||
use chrono::Utc;
|
||||
use dirigent_archivist::{
|
||||
backends::JsonlBackend, Archivist, MessageRecord, RegisterConnectorRequest,
|
||||
RegisterSessionRequest, Result,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Build a self-contained coordinator rooted at `archive_root`, backed by
|
||||
/// a single `JsonlBackend`. Avoids the shared `.archives.json` race that
|
||||
/// `new_with_single_archive` creates in the tempdir's parent.
|
||||
async fn mk_archivist(archive_root: std::path::PathBuf) -> Result<Archivist> {
|
||||
let backend = Arc::new(JsonlBackend::new(archive_root).await?);
|
||||
Archivist::from_single_backend("main".into(), backend).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pagination_count_and_range() -> Result<()> {
|
||||
let temp_dir = std::env::temp_dir().join(format!("archivist_test_{}", Uuid::now_v7()));
|
||||
let archivist = mk_archivist(temp_dir.clone()).await?;
|
||||
|
||||
// Register connector
|
||||
let connector_req = RegisterConnectorRequest {
|
||||
r#type: "OpenCode".to_string(),
|
||||
title: "Test Connector".to_string(),
|
||||
client_native_id: "opencode@localhost:12225".to_string(),
|
||||
custom_uid: None,
|
||||
metadata: serde_json::json!({}),
|
||||
fingerprint: None,
|
||||
};
|
||||
let connector_response = archivist.register_connector(connector_req, None).await?;
|
||||
|
||||
// Register session
|
||||
let session_req = RegisterSessionRequest {
|
||||
connector_uid: connector_response.connector_uid,
|
||||
native_session_id: "native-123".to_string(),
|
||||
title: Some("Pagination Test".to_string()),
|
||||
custom_scroll_id: None,
|
||||
metadata: serde_json::json!({}),
|
||||
completeness: Default::default(),
|
||||
parent_scroll_id: None,
|
||||
is_subagent: false,
|
||||
continuation: None,
|
||||
agent_id: None,
|
||||
subagent_type: None,
|
||||
spawning_tool_use_id: None,
|
||||
};
|
||||
let session_response = archivist.register_session(session_req, None).await?;
|
||||
let scroll_id = session_response.scroll_id;
|
||||
|
||||
// Test empty session
|
||||
let count = archivist.count_messages(scroll_id, None).await?;
|
||||
assert_eq!(count, 0, "Empty session should have 0 messages");
|
||||
|
||||
let range = archivist.get_messages_range(scroll_id, 0, 10, None).await?;
|
||||
assert_eq!(range.len(), 0, "Empty session should return empty range");
|
||||
|
||||
// Add 25 messages with varying timestamps
|
||||
let mut messages = Vec::new();
|
||||
let base_time = Utc::now();
|
||||
for i in 0..25 {
|
||||
messages.push(MessageRecord {
|
||||
version: 1,
|
||||
message_id: Uuid::now_v7(),
|
||||
session: scroll_id,
|
||||
parent_id: None,
|
||||
ts: base_time + chrono::Duration::seconds(i),
|
||||
role: if i % 2 == 0 { "user" } else { "assistant" }.to_string(),
|
||||
author: Some("test".to_string()),
|
||||
content_md: format!("Message {}", i),
|
||||
content_parts: None,
|
||||
attachments: Vec::new(),
|
||||
metadata: serde_json::json!({}),
|
||||
});
|
||||
}
|
||||
archivist.append_messages(scroll_id, messages, None).await?;
|
||||
|
||||
// Test count_messages
|
||||
let count = archivist.count_messages(scroll_id, None).await?;
|
||||
assert_eq!(count, 25, "Should count 25 messages");
|
||||
|
||||
// Test get_messages_range - first page
|
||||
let page1 = archivist.get_messages_range(scroll_id, 0, 10, None).await?;
|
||||
assert_eq!(page1.len(), 10, "First page should have 10 messages");
|
||||
assert_eq!(page1[0].content_md, "Message 0", "First message should be Message 0");
|
||||
assert_eq!(page1[9].content_md, "Message 9", "10th message should be Message 9");
|
||||
|
||||
// Test get_messages_range - second page
|
||||
let page2 = archivist.get_messages_range(scroll_id, 10, 10, None).await?;
|
||||
assert_eq!(page2.len(), 10, "Second page should have 10 messages");
|
||||
assert_eq!(page2[0].content_md, "Message 10", "11th message should be Message 10");
|
||||
assert_eq!(page2[9].content_md, "Message 19", "20th message should be Message 19");
|
||||
|
||||
// Test get_messages_range - partial last page
|
||||
let page3 = archivist.get_messages_range(scroll_id, 20, 10, None).await?;
|
||||
assert_eq!(page3.len(), 5, "Last page should have 5 messages");
|
||||
assert_eq!(page3[0].content_md, "Message 20", "21st message should be Message 20");
|
||||
assert_eq!(page3[4].content_md, "Message 24", "25th message should be Message 24");
|
||||
|
||||
// Test get_messages_range - offset beyond messages
|
||||
let page4 = archivist.get_messages_range(scroll_id, 30, 10, None).await?;
|
||||
assert_eq!(page4.len(), 0, "Offset beyond messages should return empty");
|
||||
|
||||
// Verify chronological ordering is maintained in pagination
|
||||
let all_messages = archivist.get_messages(scroll_id, None).await?;
|
||||
let first_10_from_all = &all_messages[0..10];
|
||||
let first_10_from_page = &page1[..];
|
||||
|
||||
for i in 0..10 {
|
||||
assert_eq!(
|
||||
first_10_from_all[i].message_id,
|
||||
first_10_from_page[i].message_id,
|
||||
"Pagination should maintain same order as get_messages()"
|
||||
);
|
||||
}
|
||||
|
||||
// Clean up
|
||||
tokio::fs::remove_dir_all(temp_dir).await.ok();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_count_messages_nonexistent_session() -> Result<()> {
|
||||
let temp_dir = std::env::temp_dir().join(format!("archivist_test_{}", Uuid::now_v7()));
|
||||
let archivist = mk_archivist(temp_dir.clone()).await?;
|
||||
|
||||
// Count messages for non-existent session (should return 0, not error)
|
||||
let nonexistent_scroll_id = Uuid::now_v7();
|
||||
let count = archivist.count_messages(nonexistent_scroll_id, None).await?;
|
||||
assert_eq!(count, 0, "Non-existent session should have 0 messages");
|
||||
|
||||
// Clean up
|
||||
tokio::fs::remove_dir_all(temp_dir).await.ok();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user