Skip to content

Commit d2e46ec

Browse files
committed
fix: bound task projection reconcile candidates
1 parent b4b0e4b commit d2e46ec

1 file changed

Lines changed: 184 additions & 26 deletions

File tree

garyx-gateway/src/task_projection.rs

Lines changed: 184 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -251,40 +251,46 @@ pub(crate) async fn reconcile_task_projection(
251251
thread_store: &Arc<dyn ThreadStore>,
252252
garyx_db: &GaryxDbService,
253253
) -> usize {
254-
let thread_ids = thread_store.list_keys(Some("thread::")).await;
255-
let mut projected_thread_ids = BTreeSet::new();
256-
let mut reconciled = 0usize;
257-
for thread_id in thread_ids {
258-
let Some(data) = thread_store.get(&thread_id).await else {
259-
continue;
260-
};
261-
if let Some(draft) = task_projection_draft_from_thread_data(&thread_id, &data) {
262-
projected_thread_ids.insert(thread_id.clone());
263-
if let Err(error) = garyx_db.replace_task_projection(draft) {
264-
warn!(thread_id, error = %error, "failed to reconcile task projection row");
265-
} else {
266-
reconciled += 1;
267-
}
268-
}
269-
}
270-
271254
let existing_thread_ids = match garyx_db.list_task_projection_thread_ids() {
272255
Ok(thread_ids) => thread_ids,
273256
Err(error) => {
274-
warn!(error = %error, "failed to list task projection rows before reconcile prune");
275-
return reconciled;
257+
warn!(error = %error, "failed to list task projection rows before reconcile");
258+
return 0;
276259
}
277260
};
278-
for thread_id in existing_thread_ids {
279-
if projected_thread_ids.contains(&thread_id) {
280-
continue;
261+
262+
let mut candidate_thread_ids = existing_thread_ids.into_iter().collect::<BTreeSet<_>>();
263+
match garyx_db.list_recent_threads(usize::MAX, 0) {
264+
Ok(records) => {
265+
for record in records {
266+
let projection_is_active = record
267+
.active_run_id
268+
.as_deref()
269+
.map(str::trim)
270+
.is_some_and(|value| !value.is_empty())
271+
|| record.run_state == "running";
272+
if projection_is_active {
273+
candidate_thread_ids.insert(record.thread_id);
274+
}
275+
}
281276
}
282-
let still_has_task = thread_store
277+
Err(error) => {
278+
warn!(error = %error, "failed to list recent thread projection rows before task projection reconcile");
279+
}
280+
}
281+
282+
let mut reconciled = 0usize;
283+
for thread_id in candidate_thread_ids {
284+
let draft = thread_store
283285
.get(&thread_id)
284286
.await
285-
.and_then(|data| task_projection_draft_from_thread_data(&thread_id, &data))
286-
.is_some();
287-
if still_has_task {
287+
.and_then(|data| task_projection_draft_from_thread_data(&thread_id, &data));
288+
if let Some(draft) = draft {
289+
if let Err(error) = garyx_db.replace_task_projection(draft) {
290+
warn!(thread_id, error = %error, "failed to reconcile task projection row");
291+
} else {
292+
reconciled += 1;
293+
}
288294
continue;
289295
}
290296
match garyx_db.remove_task_projection(&thread_id) {
@@ -321,3 +327,155 @@ fn source_channel_account_id(source: &TaskSource) -> Option<String> {
321327
let account_id = normalized(source.account_id.as_deref())?;
322328
Some(format!("{channel}:{account_id}"))
323329
}
330+
331+
#[cfg(test)]
332+
mod tests {
333+
use std::sync::atomic::{AtomicUsize, Ordering};
334+
335+
use async_trait::async_trait;
336+
use chrono::{DateTime, Utc};
337+
use garyx_models::{
338+
Principal, TASK_SCHEMA_VERSION_V1, TaskEvent, TaskEventKind, TaskStatus, ThreadTask,
339+
};
340+
use garyx_router::{InMemoryThreadStore, ThreadStore, ThreadStoreError};
341+
use serde_json::{Value, json};
342+
343+
use super::*;
344+
use crate::garyx_db::RecentThreadDraft;
345+
346+
struct CountingThreadStore {
347+
inner: Arc<InMemoryThreadStore>,
348+
list_keys_calls: AtomicUsize,
349+
}
350+
351+
impl CountingThreadStore {
352+
fn new() -> Self {
353+
Self {
354+
inner: Arc::new(InMemoryThreadStore::new()),
355+
list_keys_calls: AtomicUsize::new(0),
356+
}
357+
}
358+
359+
async fn insert_task(&self, thread_id: &str, task: ThreadTask) {
360+
self.inner
361+
.set(
362+
thread_id,
363+
json!({ "task": task, "updated_at": task.updated_at }),
364+
)
365+
.await;
366+
}
367+
}
368+
369+
#[async_trait]
370+
impl ThreadStore for CountingThreadStore {
371+
async fn get(&self, thread_id: &str) -> Option<Value> {
372+
self.inner.get(thread_id).await
373+
}
374+
375+
async fn set(&self, thread_id: &str, data: Value) {
376+
self.inner.set(thread_id, data).await;
377+
}
378+
379+
async fn delete(&self, thread_id: &str) -> bool {
380+
self.inner.delete(thread_id).await
381+
}
382+
383+
async fn list_keys(&self, prefix: Option<&str>) -> Vec<String> {
384+
self.list_keys_calls.fetch_add(1, Ordering::SeqCst);
385+
self.inner.list_keys(prefix).await
386+
}
387+
388+
async fn exists(&self, thread_id: &str) -> bool {
389+
self.inner.exists(thread_id).await
390+
}
391+
392+
async fn update(&self, thread_id: &str, updates: Value) -> Result<(), ThreadStoreError> {
393+
self.inner.update(thread_id, updates).await
394+
}
395+
}
396+
397+
fn test_task(number: u64, status: TaskStatus, updated_at: &str) -> ThreadTask {
398+
let at = DateTime::parse_from_rfc3339(updated_at)
399+
.expect("valid timestamp")
400+
.with_timezone(&Utc);
401+
let actor = Principal::Agent {
402+
agent_id: "agent:test".to_owned(),
403+
};
404+
ThreadTask {
405+
schema_version: TASK_SCHEMA_VERSION_V1,
406+
number,
407+
title: format!("Task {number}"),
408+
status,
409+
creator: actor.clone(),
410+
assignee: None,
411+
notification_target: None,
412+
source: None,
413+
executor: None,
414+
body: None,
415+
created_at: at,
416+
updated_at: at,
417+
updated_by: actor.clone(),
418+
events: vec![TaskEvent {
419+
event_id: format!("event-{number}"),
420+
at,
421+
actor: actor.clone(),
422+
kind: TaskEventKind::Created {
423+
initial_status: status,
424+
assignee: None,
425+
},
426+
}],
427+
}
428+
}
429+
430+
fn active_recent_thread(thread_id: &str) -> RecentThreadDraft {
431+
RecentThreadDraft {
432+
thread_id: thread_id.to_owned(),
433+
title: "Active task".to_owned(),
434+
workspace_dir: None,
435+
thread_type: "thread".to_owned(),
436+
provider_type: Some("claude_code".to_owned()),
437+
agent_id: Some("claude".to_owned()),
438+
message_count: 0,
439+
last_message_preview: String::new(),
440+
recent_run_id: Some("run-active".to_owned()),
441+
active_run_id: Some("run-active".to_owned()),
442+
run_state: "running".to_owned(),
443+
updated_at: Some("2026-01-01T00:00:01.000Z".to_owned()),
444+
last_active_at: "2026-01-01T00:00:01.000Z".to_owned(),
445+
}
446+
}
447+
448+
#[tokio::test]
449+
async fn task_projection_reconcile_uses_sql_candidates_without_listing_all_threads() {
450+
let store = Arc::new(CountingThreadStore::new());
451+
let thread_store: Arc<dyn ThreadStore> = store.clone();
452+
let db = GaryxDbService::memory().expect("db opens");
453+
454+
let active_thread = "thread::active-task";
455+
store
456+
.insert_task(
457+
active_thread,
458+
test_task(12, TaskStatus::InProgress, "2026-01-01T00:00:01.000Z"),
459+
)
460+
.await;
461+
db.upsert_recent_thread(active_recent_thread(active_thread))
462+
.expect("seed active recent row");
463+
464+
let stale_task = test_task(13, TaskStatus::Todo, "2026-01-01T00:00:01.000Z");
465+
db.replace_task_projection(
466+
task_projection_draft_from_task("thread::stale-task", &stale_task)
467+
.expect("stale projection draft"),
468+
)
469+
.expect("seed stale task projection");
470+
471+
let reconciled = reconcile_task_projection(&thread_store, &db).await;
472+
473+
assert_eq!(reconciled, 2);
474+
assert_eq!(store.list_keys_calls.load(Ordering::SeqCst), 0);
475+
assert_eq!(
476+
db.thread_id_for_number(12).expect("active lookup"),
477+
Some(active_thread.to_owned())
478+
);
479+
assert_eq!(db.thread_id_for_number(13).expect("stale lookup"), None);
480+
}
481+
}

0 commit comments

Comments
 (0)