Files
PaperClipAI/server
DottaandClaude Opus 5.5 99edcd28a7 perf(attention): read each retention candidate kind in its own bounded branch (#20566)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The attention retention worker archives old "Needs you" items, such
as open decisions, pending questions, failed runs and items in review
> - To find its work, the worker reads a page of retention candidates.
It reads one page per changed scope, and one single-source read for each
check of a due item
> - On a hosted stack this candidate read ran 2,835 times at about 1 s
per call, 4,201 s of database time in one afternoon window
> - The read built every candidate of every kind before it applied the
keyset limit. Its company and source filters used the `param is null or
column = param` form. Under a generic prepared plan these filters cannot
use an index. The failed-run branch also matched retry receipts with a
bound LIKE parameter, so it could not use the partial index
`heartbeat_run_events_retry_exhausted_idx`
> - This pull request reads each kind in its own branch, puts every
filter into that branch, and limits each branch to one page
> - The benefit is a bounded read for each page, and a primary-key read
for each single-source check, with the same rows, order and cursors

## Linked Issues or Issue Description

**What existing behavior does this improve?**
The attention retention candidate scan in
`server/src/services/attention-retention.ts`.

**Subsystem affected**
Server: attention retention background work (database reads only).

**Current behavior**
Each page reads all candidates of all selected kinds, then sorts them
and keeps 100. Optional filters use `($n is null or column = $n)`. A
generic prepared plan cannot use an index for these filters. The
single-source check compares `id::text`, so it cannot use the primary
key. The retry receipt LIKE pattern is a parameter, so the plan cannot
use the partial receipt index.

**Proposed behavior**
Each kind is one branch with its company, source, issue and keyset
filters inside it and `limit 100`. Kinds before the cursor are not read.
A source id filter uses the uuid primary key. The receipt pattern is a
SQL literal that matches the partial index predicate. The page result
does not change.

Refs #20386 (adds the partial receipt index that this branch now uses).

## What Changed

- `server/src/services/attention-retention.ts`: replace the single union
scan with `candidateQuery`. Each source kind is a branch that has its
own filters, retention join and one-page limit. The outer query still
orders by `(source_kind, source_id)` and keeps 100 rows.
- Only add a filter when the scope sets it. There is no `param is null
or ...` form now.
- A single-source read compares `id = $uuid`. It returns no rows for
text that is not the canonical lowercase uuid form. The former `id::text
= $text` comparison also never matched such text.
- Skip the kinds that come before the keyset cursor kind.
`ATTENTION_RETENTION_KIND_ORDER` holds the kind order, and a test checks
it against the database collation.
- The failed-run branch matches `'Bounded retry exhausted%'` as a
literal, so it reads `heartbeat_run_events_retry_exhausted_idx`.
- Export `listAttentionRetentionCandidates` for the test and the
benchmark. The service uses it without a change in behavior.
- New test
`server/src/__tests__/attention-retention-candidates.test.ts`. It runs
on real PostgreSQL and compares every page, row and cursor with the
former scan (kept in the test as an oracle). It covers: one company, all
companies, one kind, the blocker kind (which ignores retention), an
unknown kind, issue scopes (including uppercase and malformed ids),
exact source ids (uppercase, braces, no dashes, empty and malformed text
return nothing), a `null` source id from a decoded scope, other-company
isolation, inactive companies, kept and archived retention, and failed
runs without a lifecycle receipt.
- New benchmark `scripts/benchmark-attention-retention-candidates.ts`.
It loads the baseline service from git, captures the SQL that each
version sends for the same scopes, and times both under custom and
generic plans. Each round alternates the order. It fails if the rows
differ.
- No migration. The existing indexes cover each branch: company and
status indexes on each source table, the issues company/status index,
and the partial receipt index from #20386.

## Verification

- `cd server && npx vitest run
src/__tests__/attention-retention-candidates.test.ts`: 10 passed.
- `cd server && npx vitest run
src/__tests__/attention-retention-work.test.ts`: see the note under
Risks.
- `cd server && npx tsc --noEmit`: pass.
- Benchmark (local SQL measurement only):
`pnpm --filter @paperclipai/server exec tsx
../scripts/benchmark-attention-retention-candidates.ts
--baseline-ref=8d2bf78323`
Embedded PostgreSQL 18.1. Fixture: 12 companies (1 large and 11 at 1/5
size), 38,400 issues, 64,000 runs, 1.29 M run events, 6,400 decisions,
9,600 interactions, 4,800 recovery actions, 7,534 retention rows. Seven
samples for each scope, after two warmups, with `plan_cache_mode` forced
to custom and then to generic. Rows were equal in every sample.

| Plan | Scope | Rows | Before median (min–max) | After median (min–max)
| Speedup | EXPLAIN shared blocks before → after |
| --- | --- | --- | --- | --- | --- | --- |
| generic | single source (check path) | 1 | 4.82 ms (3.81–5.88) | 0.19
ms (0.12–0.66) | 25× | 3,491 → 10 |
| generic | company failed runs | 81 | 45.0 ms (40.5–51.7) | 2.9 ms
(2.3–4.6) | 15× | 3,752 → 3,752 |
| generic | company page 1 | 100 | 39.4 ms (30.1–63.7) | 7.6 ms
(5.8–14.3) | 5.2× | 4,396 → 4,719 |
| generic | company page 2 | 100 | 33.3 ms (31.2–38.1) | 6.9 ms
(5.2–10.3) | 4.8× | 4,237 → 4,712 |
| generic | company issue scope | 4 | 11.8 ms (9.8–24.0) | 4.1 ms
(3.4–6.4) | 2.9× | 4,243 → 4,225 |
| generic | all companies page 1 | 100 | 107.2 ms (100.2–116.0) | 29.3
ms (18.8–64.4) | 3.7× | 13,657 → 14,672 |
| custom | single source (check path) | 1 | 2.68 ms (2.01–5.33) | 0.81
ms (0.65–1.50) | 3.3× | 3,491 → 10 |
| custom | company failed runs | 81 | 5.7 ms (3.9–15.8) | 4.6 ms
(3.2–7.1) | 1.2× | 3,752 → 3,752 |
| custom | company page 1 | 100 | 11.7 ms (9.2–13.3) | 10.7 ms
(9.4–19.5) | 1.1× (neutral) | 4,396 → 4,719 |
| custom | company page 2 | 100 | 11.2 ms (7.6–14.2) | 10.1 ms
(8.5–12.0) | 1.1× (neutral) | 4,237 → 4,712 |
| custom | company issue scope | 4 | 10.9 ms (9.1–13.3) | 8.4 ms
(6.4–10.4) | 1.3× | 4,243 → 4,225 |
| custom | all companies page 1 | 100 | 21.3 ms (17.4–25.9) | 24.4 ms
(20.3–72.0) | 0.9× (neutral) | 13,688 → 14,672 |

- The timing connection uses named prepared statements, as the server
does by default. The benchmark checks `pg_prepared_statements` before it
collects samples, so the samples reuse the parsed statement.
- The driver uses named prepared statements by default, so after five
runs PostgreSQL can switch a statement to a generic plan. The generic
rows are the relevant case for a busy server. Custom plans were already
good for most scopes, and the change is neutral there.
- Plan after the change (company page, generic): the failed-run branch
reads the partial receipt index (13 blocks), then probes
`heartbeat_runs` by primary key. The single-source check reads each
primary key and the retention unique index (10 blocks in all).

## Risks

- Low behavior risk. The test compares the new pages with the former
query on the same data, including cursors in the middle of a kind and at
kind boundaries.
- Kind skipping depends on the order of kind names. Every kind name is
different from the others before its first underscore, so C and
linguistic collations give the same order. A test checks the order
against the database.
- The benchmark fixture is smaller than the hosted database. The hosted
cost (about 1 s per call) is larger than the local baseline. I did not
measure the hosted improvement. It needs a fresh query-statistics window
after deploy.
- On this loaded local machine, one full run of
`attention-retention-work.test.ts` had 3 tests time out at 30 s (no-run
issue finalization, no-run workspace finalization, and interaction
create/withdraw). The same 3 tests pass when run alone. CI is the
authority for this suite.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic. Agentic coding with
tool use (shell, file edits, local PostgreSQL).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:15:48 -06:00
..