Correction (2026-09-03). The "Suggested fixes" section below says option 2 is "a contract addition, so it needs a release plus a host re-pin". That is wrong. FlushSourceTree has been a MemoryTree contract member for some time (crates/tinymemory-bus/src/names.rs FLUSH_SOURCE_TREE, in METHODS; MemoryTree::flush_source_tree(&self, source_scope: &str) -> Result<u64, MemoryError>), the pinned module serves it, and openhuman already calls it from flush_source_tree_rpc. So the host can close this without any engine change: tinyhumansai/openhuman#6015 replaces the post-sync flush_pending() with flush_source_tree(&format!("{toolkit}:{connection_id}")), which bypasses the dedupe queue (no race) and seals one scope instead of the whole workspace. Option 1 (record a rerun on a suppressed enqueue) remains a valid engine-side hardening for other flush_pending callers, but is no longer needed for the sync path. The original text is kept below unedited for the record.
Raised from a CodeRabbit finding on tinyhumansai/openhuman#6011, which added a post-sync flush_pending() call. The finding is valid, and the durable fix is engine-side — a host cannot close it.
The race
MemoryMaintenance::flush_pending enqueues a flush_stale job keyed date + hour/3:
let date_iso = now.format("%Y-%m-%d").to_string();
let hour_block = chrono::Timelike::hour(&now) / 3;
let job = NewJob::flush_stale(&payload, &date_iso, hour_block)?;
let enqueued = queue::store::enqueue(config, &job)?.is_some();
enqueue is INSERT OR IGNORE against the partial unique index
CREATE UNIQUE INDEX idx_mem_tree_jobs_dedupe_active
ON mem_tree_jobs(dedupe_key)
WHERE dedupe_key IS NOT NULL AND status IN ('ready', 'running');
so a request is suppressed while a flush for that window is ready or running. That is correct for "spamming the button is safe", but it conflates two different situations:
- suppressed against a
ready job — harmless, that job has not scanned yet and will pick the new buffers up;
- suppressed against a
running job — the scan may already have walked past the buffer the caller just wrote, and nothing remains queued for it.
Why it bites now
Before openhuman#6011 nothing on the sync path asked for a seal at all, so this was invisible. With a post-sync flush it becomes the residual gap, and the fallback is poor: the periodic scheduler does not rescue those records. It enqueues FlushStalePayload::default(), whose max_age_secs is None, and the handler resolves that with
let age_secs = payload.max_age_secs.unwrap_or(L0_DEFAULT_FLUSH_AGE_SECS);
L0_DEFAULT_FLUSH_AGE_SECS is 604800 — seven days. So the three-hourly tick only sweeps genuinely stale buffers and steps over a buffer written ten minutes ago. The affected records wait for the next force flush, which in practice means the next connector sync, and absent one, seven days.
Why the host cannot fix it
Both halves live in the engine. flush_pending takes no arguments, so a caller has no way to express "rerun after the in-flight scan", and the dedupe key is built inside the driver. The obvious host-side placements were considered on openhuman#6011 and each has a defect:
- Flush once after the multi-page loop instead of per page. Avoids self-suppression by an earlier page's job, but the loop has three exits and two of them (
next_pass_budget exhausted, MAX_PASSES reached) leave with more_pending: true. Gating on batch completeness therefore skips the seal for precisely the runs that wrote the most records.
- Poll until the in-flight job settles, then re-enqueue. Puts a queue-state wait on a user-facing sync path for a best-effort secondary index.
Suggested fixes
Either would close it; the first is smaller.
- Record a rerun. When
enqueue is suppressed against a running row, set a flag on that job (or insert a successor with a distinct key) so the worker re-scans once on completion. This is CodeRabbit's phrasing — "make the maintenance queue record a rerun" — and it keeps the button-spam property, since the rerun collapses to one regardless of how many requests arrived.
- Put
force_flush_tree on the contract. The engine already has a per-tree force seal (tree::tree::flush::force_flush_tree), which is what a connector sync actually wants — it knows its own path_scope and has no business sealing every unrelated tree. Exposing it as a Maintenance member would also fix the second-order complaint on openhuman#6011: flush_pending is workspace-wide, so a Gmail sync currently also seals whatever a folder or github_repo source left pending.
Option 2 is the better shape but is a contract addition, so it needs a release plus a host re-pin before any caller can use it.
Not urgent
Strictly better than the pre-openhuman#6011 behaviour, where every small connector sync waited seven days. The race needs a flush to be mid-scan at the moment a pass commits, and the next sync clears it. Filing so it is not rediscovered as a bug report.
Raised from a CodeRabbit finding on tinyhumansai/openhuman#6011, which added a post-sync
flush_pending()call. The finding is valid, and the durable fix is engine-side — a host cannot close it.The race
MemoryMaintenance::flush_pendingenqueues aflush_stalejob keyeddate + hour/3:enqueueisINSERT OR IGNOREagainst the partial unique indexso a request is suppressed while a flush for that window is
readyorrunning. That is correct for "spamming the button is safe", but it conflates two different situations:readyjob — harmless, that job has not scanned yet and will pick the new buffers up;runningjob — the scan may already have walked past the buffer the caller just wrote, and nothing remains queued for it.Why it bites now
Before openhuman#6011 nothing on the sync path asked for a seal at all, so this was invisible. With a post-sync flush it becomes the residual gap, and the fallback is poor: the periodic scheduler does not rescue those records. It enqueues
FlushStalePayload::default(), whosemax_age_secsisNone, and the handler resolves that withL0_DEFAULT_FLUSH_AGE_SECSis 604800 — seven days. So the three-hourly tick only sweeps genuinely stale buffers and steps over a buffer written ten minutes ago. The affected records wait for the next force flush, which in practice means the next connector sync, and absent one, seven days.Why the host cannot fix it
Both halves live in the engine.
flush_pendingtakes no arguments, so a caller has no way to express "rerun after the in-flight scan", and the dedupe key is built inside the driver. The obvious host-side placements were considered on openhuman#6011 and each has a defect:next_pass_budgetexhausted,MAX_PASSESreached) leave withmore_pending: true. Gating on batch completeness therefore skips the seal for precisely the runs that wrote the most records.Suggested fixes
Either would close it; the first is smaller.
enqueueis suppressed against arunningrow, set a flag on that job (or insert a successor with a distinct key) so the worker re-scans once on completion. This is CodeRabbit's phrasing — "make the maintenance queue record a rerun" — and it keeps the button-spam property, since the rerun collapses to one regardless of how many requests arrived.force_flush_treeon the contract. The engine already has a per-tree force seal (tree::tree::flush::force_flush_tree), which is what a connector sync actually wants — it knows its ownpath_scopeand has no business sealing every unrelated tree. Exposing it as aMaintenancemember would also fix the second-order complaint on openhuman#6011:flush_pendingis workspace-wide, so a Gmail sync currently also seals whatever a folder orgithub_reposource left pending.Option 2 is the better shape but is a contract addition, so it needs a release plus a host re-pin before any caller can use it.
Not urgent
Strictly better than the pre-openhuman#6011 behaviour, where every small connector sync waited seven days. The race needs a flush to be mid-scan at the moment a pass commits, and the next sync clears it. Filing so it is not rediscovered as a bug report.