Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,64 @@ jobs:

- name: cargo test
run: cargo test --workspace --all-features

qdrant-integration:
# §7.0 Phase 2: the durable/hybrid Qdrant memory backend needs a live Qdrant,
# which the gauntlet lacks — so those scenarios are `#[ignore]`d there (they
# report as `ignored`, never a masked `passed` — see #358). This job boots a
# real Qdrant and runs them with `--ignored`, so the recall re-entry (#348)
# and async-drop (#349) fixes are exercised and cannot silently regress.
name: qdrant integration (--ignored)
runs-on: ubuntu-latest
services:
qdrant:
# Pin a server whose minor version is within one of the qdrant-client in
# Cargo.lock (the client refuses a wider skew).
image: qdrant/qdrant:v1.18.0
ports:
- 6333:6333
- 6334:6334
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7

- name: Free disk space (ubuntu)
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \
/usr/local/share/powershell /usr/share/swift /usr/local/.ghcup \
/usr/lib/jvm /usr/lib/mono 2>/dev/null || true
df -h /

- name: Install pinned toolchain
run: rustup toolchain install 1.96.1 --profile minimal

- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2

- name: Wait for Qdrant readiness
run: |
for i in $(seq 1 60); do
if curl -sf http://localhost:6333/readyz >/dev/null; then
echo "qdrant ready after ${i}s"; exit 0
fi
sleep 1
done
echo "qdrant did not become ready" >&2; exit 1

- name: Gated Qdrant integration tests (--ignored)
env:
QDRANT_INTEGRATION_TEST: "1"
QDRANT_URL: http://localhost:6334
run: |
# Crate-level: durable backend (incl. the async-drop restart path) and
# hybrid dense+sparse recall over Mock embeddings. The `--skip`ped tests
# need extras out of this job's scope: `snapshot_*` a longer snapshot
# deadline (tracked separately), the two semantic tests the BGE model.
cargo test -p ardur-memory-qdrant --test integration \
-- --ignored --skip snapshot_into_receipt_records_event
cargo test -p ardur-memory-qdrant --test hybrid_integration \
-- --ignored --skip semantic_hit_gated --skip hybrid_beats_either
# End-to-end through the fused runtime: recall re-entry (#348) and the
# async-context drop (#349) on the real durable + hybrid backends.
cargo test -p ardur-e2e-tests \
--test scenario_qdrant_memory_persistence \
--test scenario_hybrid_memory_full_pipeline -- --ignored

24 changes: 10 additions & 14 deletions crates/e2e-tests/tests/scenario_hybrid_memory_full_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@
//! live Qdrant: chat turn → receipt-chained memory store → hybrid dense+sparse
//! recall → memory context display in the next provider request.
//!
//! Gated on `QDRANT_INTEGRATION_TEST=1` (CI has no Qdrant). To run locally:
//! `#[ignore]`d because it needs a live Qdrant (CI has none by default); the
//! default suite reports it as `ignored`, never a silent `passed` (#358). Run it
//! explicitly against a Qdrant — the dedicated CI job does exactly this:
//!
//! ```text
//! docker run -p 6333:6333 -p 6334:6334 qdrant/qdrant
//! QDRANT_INTEGRATION_TEST=1 \
//! cargo test -p ardur-e2e-tests --test scenario_hybrid_memory_full_pipeline
//! QDRANT_INTEGRATION_TEST=1 QDRANT_URL=http://localhost:6334 \
//! cargo test -p ardur-e2e-tests --test scenario_hybrid_memory_full_pipeline \
//! -- --ignored
//! ```

use std::collections::VecDeque;
Expand All @@ -32,12 +35,8 @@ use async_trait::async_trait;

const COLLECTION: &str = "ardur_e2e_hybrid_full_pipeline";

fn gate() -> Option<QdrantMemoryConfig> {
if std::env::var("QDRANT_INTEGRATION_TEST").as_deref() != Ok("1") {
eprintln!("skipping scenario_hybrid_memory_full_pipeline: set QDRANT_INTEGRATION_TEST=1");
return None;
}
Some(QdrantMemoryConfig::from_env().with_collection_name(COLLECTION))
fn config() -> QdrantMemoryConfig {
QdrantMemoryConfig::from_env().with_collection_name(COLLECTION)
}

struct CapturingProvider {
Expand Down Expand Up @@ -107,12 +106,9 @@ fn submit_request(prompt: &str, session_id: SessionId) -> SubmitRequest {
}

#[test]
#[ignore = "requires a live Qdrant; run with `-- --ignored` (see module docs)"]
fn chat_store_recall_and_display_through_real_hybrid_memory() {
let Some(cfg) = gate() else {
return;
};

let qdrant = QdrantMemoryRuntime::connect(cfg).expect("connect qdrant");
let qdrant = QdrantMemoryRuntime::connect(config()).expect("connect qdrant");
let bm25 = Bm25Index::new(None).expect("in-memory bm25");
let hybrid = Arc::new(HybridMemoryRetriever::new(
qdrant,
Expand Down
27 changes: 14 additions & 13 deletions crates/e2e-tests/tests/scenario_qdrant_memory_persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@
//! Phase 1 in-process store would lose the fact here; the Qdrant store recovers
//! it.
//!
//! Gated on `QDRANT_INTEGRATION_TEST=1` (CI has no Qdrant). To run locally:
//! `#[ignore]`d because it needs a live Qdrant (CI has none by default); the
//! default suite reports it as `ignored`, never a silent `passed` (#358). Run it
//! explicitly against a Qdrant — the dedicated CI job does exactly this:
//!
//! ```text
//! docker run -p 6334:6334 qdrant/qdrant
//! QDRANT_INTEGRATION_TEST=1 \
//! cargo test -p ardur-e2e-tests --test scenario_qdrant_memory_persistence
//! QDRANT_INTEGRATION_TEST=1 QDRANT_URL=http://localhost:6334 \
//! cargo test -p ardur-e2e-tests --test scenario_qdrant_memory_persistence \
//! -- --ignored
//! ```

use std::sync::Arc;
Expand All @@ -28,23 +31,21 @@ use ardur_runtime::{CapTokenRef, ChatMessage, ChatRuntime, SessionId, SubmitRequ
const PROMPT: &str = "remember this across a restart";
const COLLECTION: &str = "ardur_e2e_qdrant_persistence";

/// The Qdrant config for this scenario, or `None` when the gate var is unset.
fn gate() -> Option<QdrantMemoryConfig> {
if std::env::var("QDRANT_INTEGRATION_TEST").as_deref() != Ok("1") {
eprintln!("skipping scenario_qdrant_memory_persistence: set QDRANT_INTEGRATION_TEST=1");
return None;
}
Some(QdrantMemoryConfig::from_env().with_collection_name(COLLECTION))
/// The Qdrant config for this scenario. The endpoint comes from `QDRANT_URL`
/// (default `http://localhost:6334`); `#[ignore]` — not an env early-return — is
/// what keeps this off the default suite, so a skip can never masquerade as a
/// pass (#358).
fn config() -> QdrantMemoryConfig {
QdrantMemoryConfig::from_env().with_collection_name(COLLECTION)
}

/// The multi-thread flavor matters: the fused turn calls the synchronous
/// `MemoryRuntime::record` from inside this runtime, and the Qdrant backend
/// bridges it with `block_in_place`, which requires a multi-threaded runtime.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "requires a live Qdrant; run with `-- --ignored` (see module docs)"]
async fn fused_turn_memory_survives_restart() {
let Some(cfg) = gate() else {
return;
};
let cfg = config();

let subject = HolderId::from(TEST_HOLDER);

Expand Down
32 changes: 21 additions & 11 deletions crates/memory-qdrant/src/hybrid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ impl HybridMemoryRetriever {
pub async fn record(&self, rec: MemoryRecord) -> Result<RecordId> {
let doc_id = rec.record_id.to_string();
let text = searchable_text(&rec);
let record_id = self.qdrant.record(rec)?;
let record_id = self.qdrant.record_async(rec).await?;
self.bm25
.lock()
.await
Expand Down Expand Up @@ -156,12 +156,18 @@ impl HybridMemoryRetriever {

// ARD-477: exclude any chain that has been tombstoned so a forgotten
// memory is never re-injected. One scroll of the relevant records.
let dead = self.qdrant.dead_chains(subject)?;
// Awaited directly (not the sync bridge) so this recall runs on a single
// `block_on` pass — a nested bridge here re-enters the owned runtime and
// panics the turn (#348).
let dead = self.qdrant.dead_chains(subject).await?;

// ---- dense: embed the query, ANN-search, drop tombstones, and keep the
// hydrated records (vector hits carry their full record_json).
let query_vec = self.embed_query(query).await?;
let vector_hits = self.qdrant.search_vectors(query_vec, candidate_k as u64)?;
let vector_hits = self
.qdrant
.search_vectors_async(query_vec, candidate_k as u64)
.await?;
let mut hydrated: HashMap<String, MemoryRecord> = HashMap::new();
let mut vector_list: Vec<ScoredDoc> = Vec::with_capacity(vector_hits.len());
for (rec, score) in vector_hits {
Expand Down Expand Up @@ -201,7 +207,7 @@ impl HybridMemoryRetriever {
}
let rec = match hydrated.remove(&doc.doc_id) {
Some(rec) => rec,
None => match self.fetch_live(&doc.doc_id, subject, &dead)? {
None => match self.fetch_live(&doc.doc_id, subject, &dead).await? {
Some(rec) => rec,
None => continue,
},
Expand All @@ -225,7 +231,7 @@ impl HybridMemoryRetriever {
/// Hydrate a fused `doc_id` from the durable store, returning it only if it is
/// a live (non-tombstone) record. An unparseable id or a missing point yields
/// `None`.
fn fetch_live(
async fn fetch_live(
&self,
doc_id: &str,
subject: Option<&HolderId>,
Expand All @@ -236,7 +242,8 @@ impl HybridMemoryRetriever {
};
Ok(self
.qdrant
.fetch_record(RecordId(uuid))?
.fetch_record_async(RecordId(uuid))
.await?
.filter(|rec| rec.invalidation_time.is_none())
.filter(|rec| !dead.contains(&rec.correction_chain_root))
.filter(|rec| subject.is_none_or(|s| &rec.subject == s)))
Expand All @@ -252,11 +259,14 @@ impl HybridMemoryRetriever {
/// straight to the durable [`QdrantMemoryRuntime`]. The write/recall methods —
/// [`record`](MemoryRuntime::record) and [`search`](MemoryRuntime::search) — are
/// asynchronous on the inherent API (dual-write to Qdrant **and** the BM25 index;
/// fused recall over both), so the synchronous trait methods bridge onto the
/// runtime's own Tokio executor via its `block_on` (the same `block_in_place`
/// path the durable runtime uses for its sync trait methods). `self.record(..)`
/// and `self.search(..)` below resolve to the *inherent* async methods (inherent
/// methods shadow trait methods of the same name), so there is no recursion.
/// fused recall over both), so each synchronous trait method bridges onto the
/// runtime's own Tokio executor with a **single** outer `block_on` over a fully
/// async body. That body `await`s the durable store's `*_async` cores directly
/// (`record_async`, `search_vectors_async`, `dead_chains`, `fetch_record_async`)
/// rather than the sync methods, so it never re-enters the owned runtime — the
/// nested-bridge recall panic (#348). `self.record(..)` and `self.search(..)`
/// below resolve to the *inherent* async methods (inherent methods shadow trait
/// methods of the same name), so there is no recursion.
impl MemoryRuntime for HybridMemoryRetriever {
fn record(&self, rec: MemoryRecord) -> Result<RecordId> {
self.qdrant.block_on(self.record(rec))
Expand Down
Loading