# A 60-second poll gave my memory extractor a 61-minute p90

> A poll interval doesn't cap extraction lag. Wake the extractor when a turn lands, keep the poll as a fallback, and check every writer that creates extractable rows.

- Author: Chad Priest
- Published: 2026-09-15
- Canonical URL: https://blog.vodou.ai/memory-extraction-lag-poll-wakeup/
- Tags: memory, ai-agents, architecture, llm, observability

---

If your agent has long-term memory, there's probably a background job that reads finished conversations and turns them into facts. It probably polls, and the interval is probably something that sounds fine, like 60 seconds. You likely haven't measured how long a turn actually waits before it becomes something the next turn can recall. I hadn't either.

I run this stack every day in [Vodou](https://vodou.ai/register?utm_source=blog&utm_medium=feature&utm_campaign=extraction-poke-the-extractor-when-a-turn-lands-and-audit-th), a local-first AI operating system with persistent memory, retrieval and MCP tool orchestration. Our extractor polled every 60 seconds. On 2026-09-03 I measured 133 conversations from assistant turn to extracted fact. The median was 4.6 minutes and the p90 was 61 minutes.

## n=133: median 4.6 minutes, p90 61 minutes, at a 60-second poll

**Diagram: Turn to extracted fact, 2026-09-03 (n=133)**

Configured poll interval of 1 minute compared with measured extraction lag: median 4.6 minutes, p90 61 minutes, over 133 conversations

```text
  poll interval :   1 min  #
  median lag    : 4.6 min  ## (problem)
  p90 lag       :  61 min  ################################ (problem)
```

I had assumed the poll interval set an upper bound on lag. It doesn't. The interval only says how often the loop wakes up when it has nothing to do. Once extraction is running, a cycle takes as long as the model call and the queue behind it take. A turn that arrives just after a tick waits for that whole cycle to finish before it even gets looked at. Extraction is limited by throughput, so the 60 seconds on the config line was the least important number in the path.

The practical cost is easy to picture. You tell your agent something, open a new chat twenty minutes later, and it doesn't know. You then have to explain it again, and avoiding that is the whole point of having memory.

## One IPC verb, a single-permit wakeup, and the poll stays

I didn't replace the poll. I added a way to cut the wait short.

The engine gained one IPC verb, `extract_now`, which it serves on the socket the daemon already listens on. The verb releases a single-permit wakeup that the extraction loop waits on alongside its timer. The single permit is the part that matters. If twenty turns land while a cycle is running, the loop runs exactly one extra cycle afterwards, not twenty. The poke doesn't carry the work. It only says "look now," and the cycle drains the same durable queue it always did.

**Diagram: Poke to wake up, queue for the work**

An assistant turn is saved, the gateway sends a fire-and-forget extract_now poke to the daemon, which releases a single-permit wakeup; the extraction cycle drains the durable queue, and the 60 second poll tick reaches the same cycle if the poke is lost

```text
  [Assistant turn saved] --> [extract_now poke]
  [Assistant turn saved] --row exists--> [Durable extraction queue]
  [extract_now poke] --> [Single-permit wakeup]
  [Single-permit wakeup] --> [Extraction cycle (fixed)]
  [60 s poll tick] --> [Extraction cycle (fixed)]
  [Durable extraction queue] --drained--> [Extraction cycle (fixed)]

  notes:
    extract_now poke: 500 ms budget, errors ignored
    Single-permit wakeup: a burst of pokes = one extra cycle
    60 s poll tick: unchanged safety net

  If the daemon is down when the poke is sent, the next tick still finds the turn.
```

The caller is in the open-source gateway, in `MCP-servers/Vodou-Console/src/conversation-store.ts`, at the end of `saveMessage`:

```ts
if (role === 'assistant' && !isBackfill && pokeEnabled) {
  void daemonRequest('extract_now', { conversation_id: conversationId }, 500)
    .catch(() => { /* the poll covers it */ });
}
```

Each condition in that `if` came from something that went wrong or nearly did. It fires only on assistant turns, because the queue skips a conversation that is still awaiting a reply. A poke on the user's turn would wake the extractor to find nothing. Backfilled history is skipped because a months-old imported transcript isn't "now", and poking once per imported turn would turn an import into a flood of wakeups. The call is fire-and-forget with a 500 ms budget because extraction must never sit on the user's latency path. If the daemon is slow or down, the chat saves normally and the poll picks up the turn later. It is also off under the unit test runner, so a test never opens a socket.

## The wakeup only came from one of the writers

The first version worked, and it was incomplete in a way no test caught. Only the gateway sent the poke. Turns also reach the conversation store from other surfaces, such as hook-captured turns from coding sessions. Those turns still waited for the poll. I had fixed lag for the path I happened to be looking at.

The engine half also shipped awkwardly. The verb existed in the daemon two days before anything called it, sitting uncommitted in a worktree that several agent sessions share. For those two days the fix was a socket verb that no one could reach. It went into the same commit as the caller, together with an unrelated instrumentation change that touched the same file.

A second trap came up around re-extraction. The extractor spawns a model subprocess, and it has to run in a neutral working directory. Start it from inside a project and it picks up that project's context, and the facts it writes carry that context in. We had already fixed that contamination once. Any new trigger that spawns extraction through its own path, instead of the single existing entry point, brings the bug back. So the poke doesn't spawn anything. It wakes the loop, and the loop calls the one extraction function that already has the isolation.

## The FTS5 trigger I instrumented in the same commit came back clean

The other half of that commit was a suspect in a database corruption investigation. The conversation table is indexed by an external-content FTS5 table. An update trigger issues a `'delete'` for the old row, and that `'delete'` has to present the exact bytes that were indexed. Every UPDATE fires the trigger, including metadata-only updates like excluding a message from context, and one of those statements can touch thousands of rows.

I added `auditFtsMutation` (in `MCP-servers/Vodou-Console/src/fts-audit.ts`) at four mutation sites, so each statement logs its site and row count. The suspect didn't hold up. 4,441 mutations across every site the audit names, plus the foreign-key cascade, came back ok on `quick_check`, on a full `integrity_check`, and on FTS5's own `integrity-check`. I'm including it because a dead end you can measure is worth recording, and because it's the same idea as the extractor fix: log which writer did what, instead of guessing afterwards.

## Invariant: the set of turn writers equals the set of wakers, and neither is required

Here is the failure class, stated so you can check it against your code. In any event-accelerated background job, two properties have to hold. First, every code path that creates work the job consumes also sends the wakeup. Second, if every wakeup is lost, the job still finds all of that work within one poll interval plus one cycle. If the first fails, some surfaces are slow and nobody notices. If the second fails, the event has quietly become your queue, and a daemon restart loses facts.

## Measure turn-to-fact lag on your own tables

This takes five minutes and uses nothing from our stack. It assumes a `messages` table with `conversation_id`, `role` and `created_at`, and a `memories` table that records its `source_conversation_id`. Rename to match your schema. This is SQLite, and on Postgres you'd use `percentile_cont`.

```sql
WITH turn AS (
  SELECT conversation_id, MIN(created_at) AS first_reply
  FROM messages WHERE role = 'assistant' GROUP BY conversation_id
), lag AS (
  SELECT (julianday(MIN(m.created_at)) - julianday(t.first_reply)) * 1440.0 AS minutes
  FROM turn t JOIN memories m ON m.source_conversation_id = t.conversation_id
  WHERE m.created_at >= t.first_reply
  GROUP BY t.conversation_id
)
SELECT COUNT(*) AS n,
  (SELECT minutes FROM lag ORDER BY minutes LIMIT 1 OFFSET (SELECT COUNT(*)/2 FROM lag))    AS p50_min,
  (SELECT minutes FROM lag ORDER BY minutes LIMIT 1 OFFSET (SELECT COUNT(*)*9/10 FROM lag)) AS p90_min
FROM lag;
```

A pass looks like a p50 close to your poll interval plus one cycle, with a p90 no more than a few times the p50. A fail looks like ours did: a p90 more than ten times the poll interval. That means turns are queuing behind long cycles, and shortening the interval won't help.

Then check both halves of the invariant:

```bash
grep -rlE "INSERT INTO (messages|turns)" src/ | sort -u > writers.txt
grep -rlE "extract_now|wakeExtractor" src/ | sort -u > wakers.txt
comm -23 writers.txt wakers.txt   # pass: empty. each listed file writes turns that wait for the poll

# stop the extractor, write one assistant turn to conversation 'probe-1', start it, wait one interval + one cycle
sqlite3 app.db "SELECT COUNT(*) FROM memories WHERE source_conversation_id = 'probe-1';"
# pass: > 0.  fail: 0 means the wakeup was your only path
```

The grep only sees writers in this repo and this language. A writer in another process won't show up, and that is exactly how our hook turns were missed.

## Session-end consolidation assumes a chat ends

Most memory guidance says to consolidate when a session ends. The [Geodocs agent memory spec](https://geodocs.dev/ai-agents/agent-memory-pattern-spec) says to "consolidate from working to long-term memory on session end". A browser chat tab never ends a session, though. It just goes quiet, so a consolidation hook tied to session end either never fires or fires at whatever time you picked as a timeout. [Kunal Ganglani's state management guide](https://www.kunalganglani.com/blog/ai-agent-memory-state-management) is right that memory bugs "fail in confusing, silent ways", and extraction lag is one of them: nothing errors, and the agent just doesn't know yet. The [agent memory atlas entry for TencentDB Agent Memory](https://github.com/neoneye/agent-memory-atlas/blob/main/content/systems/tencentdb-agent-memory.md) describes successful-turn capture, which triggers on the event. Its weaknesses section, though, is about write atomicity and dedupe failing open. None of these sources ask how long a captured turn waits before it can be recalled, or which writers trigger the capture.

## No after number yet, and hook turns still wait for the tick

I don't have a post-fix lag distribution to show you. The 4.6 and 61 are from before the change, and I'm not putting an "after" bar on a chart until I've re-run that query on the same kind of traffic. The gateway pokes. Turns from other surfaces still rely on the poll, so for those the invariant above is still false in our own code.

If you want an agent whose memory of a conversation is ready by the next chat, and extraction you can measure instead of assume, that's what [vodou.ai](https://vodou.ai/register?utm_source=blog&utm_medium=feature&utm_campaign=extraction-poke-the-extractor-when-a-turn-lands-and-audit-th) runs on your own machine.

---

Source: [A 60-second poll gave my memory extractor a 61-minute p90](https://blog.vodou.ai/memory-extraction-lag-poll-wakeup/) by Chad Priest, from Building Vodou in Public.
