Skip to content

Commit 6f0d649

Browse files
committed
docs: add task projection table design
1 parent 5c068e8 commit 6f0d649

1 file changed

Lines changed: 384 additions & 0 deletions

File tree

Lines changed: 384 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,384 @@
1+
# Task Projection Table Design
2+
3+
Status: approved synthesis for implementation. The thread file record remains
4+
the only source of truth for task data; `task_projection` is a read-only derived
5+
SQLite projection.
6+
7+
## Goal
8+
9+
Garyx tasks are stored as a `task` overlay on normal thread records. Today,
10+
task listing, number lookup, source/status/assignee filtering, and running
11+
subtask checks depend on the process-local `TASK_INDEX`. After a gateway
12+
restart, that index is rebuilt by scanning all thread files.
13+
14+
Add an independent `task_projection` table that stores queryable task business
15+
fields and allows:
16+
17+
- SQL-backed list, status, assignee, creator, and source filtering.
18+
- SQL-backed recursive parent/child traversal.
19+
- Cold-start `TASK_INDEX` bootstrap from SQLite instead of a full file scan.
20+
- Running-subtask checks that work immediately after restart.
21+
22+
The projection must never become canonical task storage. If projection writes
23+
fail, the file write still succeeds and backfill/reconcile repairs the derived
24+
table later.
25+
26+
## Decisions
27+
28+
Use an independent `task_projection` table rather than adding sparse task-only
29+
columns to `thread_meta`.
30+
31+
Use the existing `RecentThreadProjectingStore` write path. TaskService CRUD and
32+
bridge task-status transitions already write through the same gateway
33+
`ThreadStore`; projection writes belong in `project_thread()` and `delete()`,
34+
not in a new wrapper store or router-to-gateway dependency.
35+
36+
Use versioned backfill plus realtime single-row upsert. Backfill is the only
37+
production path allowed to scan all thread files, and it runs from gateway
38+
warmup and reader `is_current` gating.
39+
40+
Keep `TASK_INDEX` for now as a process-local cache and number-allocation guard,
41+
but bootstrap it from the projection when current. Non-gateway tests and stores
42+
without a reader continue to use the existing file-scan fallback.
43+
44+
Use router-owned reader traits and a gateway implementation. `garyx-router`
45+
must not depend on gateway SQLite code.
46+
47+
## Schema
48+
49+
The table keeps flattened filter columns plus canonical JSON strings for
50+
structured task values that must be reconstructed without loss.
51+
52+
```sql
53+
CREATE TABLE IF NOT EXISTS task_projection (
54+
thread_id TEXT PRIMARY KEY,
55+
number INTEGER NOT NULL CHECK (number > 0),
56+
status TEXT NOT NULL CHECK (
57+
status IN ('todo', 'in_progress', 'in_review', 'done')
58+
),
59+
title TEXT NOT NULL,
60+
61+
creator_json TEXT NOT NULL,
62+
creator_id TEXT NOT NULL,
63+
assignee_json TEXT,
64+
assignee_id TEXT,
65+
updated_by_json TEXT NOT NULL,
66+
executor_json TEXT,
67+
68+
source_json TEXT,
69+
source_thread_id TEXT,
70+
source_task_thread_id TEXT,
71+
source_task_id TEXT COLLATE NOCASE,
72+
parent_task_number INTEGER CHECK (
73+
parent_task_number IS NULL OR parent_task_number > 0
74+
),
75+
source_bot_id TEXT,
76+
77+
notification_thread_id TEXT,
78+
79+
created_at TEXT NOT NULL,
80+
updated_at TEXT NOT NULL,
81+
source_updated_at TEXT NOT NULL,
82+
source_events_len INTEGER NOT NULL CHECK (source_events_len >= 0),
83+
84+
projection_version INTEGER NOT NULL DEFAULT 1,
85+
projected_at TEXT NOT NULL
86+
) STRICT;
87+
```
88+
89+
Indexes:
90+
91+
```sql
92+
CREATE INDEX IF NOT EXISTS idx_task_projection_number
93+
ON task_projection(number);
94+
95+
CREATE INDEX IF NOT EXISTS idx_task_projection_updated
96+
ON task_projection(updated_at DESC, thread_id ASC);
97+
98+
CREATE INDEX IF NOT EXISTS idx_task_projection_open_updated
99+
ON task_projection(updated_at DESC, thread_id ASC)
100+
WHERE status <> 'done';
101+
102+
CREATE INDEX IF NOT EXISTS idx_task_projection_status_updated
103+
ON task_projection(status, updated_at DESC, thread_id ASC);
104+
105+
CREATE INDEX IF NOT EXISTS idx_task_projection_assignee_status_updated
106+
ON task_projection(assignee_id, status, updated_at DESC, thread_id ASC);
107+
108+
CREATE INDEX IF NOT EXISTS idx_task_projection_creator_status_updated
109+
ON task_projection(creator_id, status, updated_at DESC, thread_id ASC);
110+
111+
CREATE INDEX IF NOT EXISTS idx_task_projection_source_thread_updated
112+
ON task_projection(source_thread_id, updated_at DESC, thread_id ASC);
113+
114+
CREATE INDEX IF NOT EXISTS idx_task_projection_source_task_thread_updated
115+
ON task_projection(source_task_thread_id, updated_at DESC, thread_id ASC);
116+
117+
CREATE INDEX IF NOT EXISTS idx_task_projection_source_task_updated
118+
ON task_projection(source_task_id, updated_at DESC, thread_id ASC);
119+
120+
CREATE INDEX IF NOT EXISTS idx_task_projection_source_bot_updated
121+
ON task_projection(source_bot_id, updated_at DESC, thread_id ASC);
122+
123+
CREATE INDEX IF NOT EXISTS idx_task_projection_notification_thread_status
124+
ON task_projection(notification_thread_id, status, updated_at DESC)
125+
WHERE status = 'in_progress';
126+
127+
CREATE INDEX IF NOT EXISTS idx_task_projection_parent_thread_updated
128+
ON task_projection(source_task_thread_id, updated_at DESC, thread_id ASC);
129+
130+
CREATE INDEX IF NOT EXISTS idx_task_projection_parent_number_updated
131+
ON task_projection(parent_task_number, updated_at DESC, thread_id ASC);
132+
```
133+
134+
`number` is intentionally not unique. The projection is derived data and must
135+
not let historical drift or concurrent repair prevent `ON CONFLICT(thread_id)`
136+
upserts. Readers dedupe by `number` and warn on duplicates.
137+
138+
## Draft Construction
139+
140+
Create `garyx-gateway/src/task_projection.rs` with:
141+
142+
```rust
143+
task_projection_draft_from_thread_data(thread_id, data) -> Option<TaskProjectionDraft>
144+
backfill_task_projection_if_incomplete(thread_store, garyx_db) -> usize
145+
```
146+
147+
Draft construction must parse the record through the typed `ThreadTask` path and
148+
then serialize structured fields with `serde_json::to_string`. Do not store raw
149+
`serde_json::Value` subtrees, because legacy aliases and map ordering would
150+
break canonical equality.
151+
152+
Field mapping:
153+
154+
- `thread_id`: canonical thread key.
155+
- `number`, `status`, `title`, `created_at`, `updated_at`: from `ThreadTask`.
156+
- `creator_json`, `assignee_json`, `updated_by_json`, `executor_json`,
157+
`source_json`: canonical typed JSON.
158+
- `creator_id`, `assignee_id`: principal id helper columns.
159+
- `source_thread_id`, `source_task_thread_id`, `source_task_id`: from
160+
`TaskSource`.
161+
- `parent_task_number`: parse `source.task_id` with the existing task-id parser.
162+
- `source_bot_id`: `source.bot_id`, or the existing `channel:account` fallback.
163+
- `notification_thread_id`: populated only for thread notification targets.
164+
- `source_updated_at`: task `updated_at`.
165+
- `source_events_len`: `task.events.len()`.
166+
167+
## Write Semantics
168+
169+
`GaryxDbService` owns:
170+
171+
- `CURRENT_TASK_PROJECTION_VERSION = 1`.
172+
- `TASK_PROJECTION_NAME = "task_projection"`.
173+
- `TaskProjectionDraft`.
174+
- `replace_task_projection`.
175+
- `remove_task_projection`.
176+
- `sync_task_projection_snapshot`.
177+
- `task_projection_needs_backfill`.
178+
- `count_task_projection`.
179+
- SQL read methods for index rows, number lookup, list, running-subtask checks,
180+
subtree, and ancestor traversal.
181+
182+
All upserts, from realtime writes, backfill snapshots, and reconcile, must use
183+
the same revision guard:
184+
185+
```sql
186+
ON CONFLICT(thread_id) DO UPDATE SET ...
187+
WHERE (excluded.source_events_len, excluded.source_updated_at)
188+
> (task_projection.source_events_len, task_projection.source_updated_at)
189+
```
190+
191+
`source_events_len` is the primary monotonic revision. `updated_at` is only a
192+
tie-break. All mutations that can change a projected field must append a task
193+
event, including create, assign, unassign, update status, stop, set title,
194+
mark-in-review, and mark-in-progress-on-wake. Deletion/removal uses tombstone
195+
compensation and does not rely on events length.
196+
197+
Projection writes run after the file write and must warn, not fail the business
198+
operation, if SQLite projection fails.
199+
200+
`RecentThreadProjectingStore` must:
201+
202+
- Upsert task projection when `project_thread()` sees a task.
203+
- Remove task projection when `project_thread()` sees no task.
204+
- Remove task projection from `delete()`.
205+
- Remove task projection from archived/deleted projection cleanup helpers.
206+
207+
## Backfill And Reconcile
208+
209+
Backfill state uses `projection_states`:
210+
211+
- Missing state means backfill is required.
212+
- Version mismatch means backfill is required.
213+
- Previous `source_row_count > 0` and current table count `0` means backfill is
214+
required.
215+
- A zero-task install records `(version=1, source_row_count=0)` and must not
216+
rescan on every startup.
217+
218+
`source_row_count` records the number of task rows written by the task
219+
projection backfill, not the number of thread files.
220+
221+
Backfill must be single-flight. The background warmup path and the first request
222+
that observes `reader.is_current() == false` share the same lock, recheck
223+
`needs_backfill`, and ensure only one scan runs.
224+
225+
Snapshot writes must not clear the table. They upsert each scanned draft with
226+
the revision guard and clean only rows whose `projection_version` is not current.
227+
This prevents a snapshot from deleting current realtime writes.
228+
229+
Realtime deletes during backfill must record tombstones in process memory.
230+
After the snapshot writes, backfill applies tombstone compensation deletes so
231+
that a scanned stale row is not resurrected after the file truth removed it.
232+
233+
Task projection reconcile is mandatory:
234+
235+
- It rides the existing `reconcile_active_recent_thread_projection` periodic
236+
loop and does not introduce a separate cadence.
237+
- It removes stale rows whose file truth no longer contains a task.
238+
- It fills missing rows and updates rows whose source revision is behind.
239+
- It runs immediately once after successful backfill.
240+
241+
## Router Reader
242+
243+
Define in `garyx-router`:
244+
245+
```rust
246+
#[async_trait]
247+
pub trait TaskProjectionReader: Send + Sync {
248+
async fn is_current(&self) -> bool;
249+
async fn task_index_rows(&self) -> Vec<(u64, String)>;
250+
async fn thread_id_for_number(&self, number: u64) -> Option<String>;
251+
async fn has_running_subtask_targeting(&self, thread_id: &str) -> bool;
252+
async fn list_task_summaries(
253+
&self,
254+
filter: &TaskListFilter,
255+
) -> Option<(Vec<TaskSummary>, usize, bool)>;
256+
async fn max_number(&self) -> Option<u64>;
257+
}
258+
```
259+
260+
Reader registration is store-scoped, not process-global:
261+
262+
```rust
263+
static TASK_PROJECTION_READERS:
264+
OnceLock<StdMutex<HashMap<usize, Arc<dyn TaskProjectionReader>>>>;
265+
```
266+
267+
The key is the same `store_id` used by `TASK_INDEX` (`Arc::as_ptr` partitioning).
268+
This keeps multiple AppState instances, memory databases, and tests isolated,
269+
and lets free functions find the reader for their current `ThreadStore`.
270+
271+
`TaskService` keeps a `with_projection_reader` builder for explicit injection,
272+
but free functions and service methods also look up the store-scoped registry.
273+
If no reader exists, behavior falls back to the existing file-backed path.
274+
275+
`ensure_task_index` rules:
276+
277+
- Reader exists and `is_current()` is true: load `task_index_rows()` into the
278+
memory index and mark bootstrapped.
279+
- Reader exists and is not current: synchronously trigger single-flight
280+
backfill, then load from projection only if it succeeds and is current.
281+
- Backfill failure must not mark the memory index bootstrapped.
282+
- No reader: use the existing full-file scan fallback.
283+
284+
`find_task_by_number` may use `thread_id_for_number`, but it must still read the
285+
file truth and verify the task number. If the file is stale or mismatched, remove
286+
the projection row and fall back according to projection state.
287+
288+
`thread_task_has_running_subtasks` must use SQL when a reader exists:
289+
290+
```sql
291+
status = 'in_progress'
292+
AND notification_thread_id = ?
293+
AND thread_id <> ?
294+
LIMIT 1
295+
```
296+
297+
This is required for bridge/workflow parent-task gating after restart.
298+
299+
## SQL List Semantics
300+
301+
`list_task_summaries` pushes down all existing filters:
302+
303+
- `status`.
304+
- `include_done=false` as `status <> 'done'`.
305+
- `assignee` by canonical `assignee_json`.
306+
- `creator` by canonical `creator_json`.
307+
- `source_thread_id` matching `source_thread_id OR source_task_thread_id`.
308+
- `source_task_id` with `COLLATE NOCASE`.
309+
- `source_bot_id`, including the existing `channel:account` fallback.
310+
311+
List must dedupe duplicate task numbers:
312+
313+
```sql
314+
ROW_NUMBER() OVER (
315+
PARTITION BY number
316+
ORDER BY updated_at DESC, thread_id ASC
317+
) = 1
318+
```
319+
320+
`total` and `has_more` are computed after dedupe. Duplicate numbers should log a
321+
warning. List is intentionally zero-file-read and therefore eventually
322+
consistent: stale rows may be briefly visible until delete projection,
323+
single-read cleanup, or periodic reconcile removes them.
324+
325+
`TaskSummary.runtime_agent_id` and `reply_count` come from a `LEFT JOIN` to
326+
`thread_meta(agent_id, message_count)` and are not duplicated into
327+
`task_projection`.
328+
329+
## Parent And Ancestor Traversal
330+
331+
Recursive CTEs use `thread_id` as the recursive identity. `source_task_thread_id`
332+
is the preferred parent pointer; `source_task_id` and `parent_task_number` are
333+
fallback filters only. CTEs must include depth or visited-path guards, with a
334+
maximum depth of 64, so malformed self-references cannot loop forever.
335+
336+
Subtree traversal starts at the root task thread id, then follows children whose
337+
`source_task_thread_id` is the current node thread id, or whose
338+
`source_task_id` matches the current node task id case-insensitively.
339+
340+
Ancestor traversal starts at the leaf task thread id, then follows
341+
`source_task_thread_id` or `source_task_id` back to the parent projection row.
342+
343+
## Gateway Integration
344+
345+
Gateway registers a store-scoped reader for each AppState/ThreadStore using the
346+
same store identity as `TASK_INDEX`.
347+
348+
Reader coverage must include:
349+
350+
- `TaskService` used by task routes.
351+
- Automation task service creation.
352+
- Bridge `run_management` free functions.
353+
- Workflow lifecycle free functions.
354+
355+
`app_state.rs` must run task projection backfill in sync warmup and run one
356+
task projection reconcile pass immediately after successful backfill. The
357+
periodic active recent-thread reconcile loop also runs task projection
358+
reconcile.
359+
360+
## Validation Requirements
361+
362+
Required tests:
363+
364+
- Gateway memory DB DDL, upsert, remove, count, and version backfill predicate.
365+
- Conditional upsert read-then-write interleaving where a stale snapshot cannot
366+
overwrite a newer realtime write.
367+
- Tombstone compensation where a backfill snapshot cannot resurrect a deleted
368+
task row.
369+
- SQL list filter pushdown, duplicate-number dedupe, total/has_more after
370+
dedupe, and stale-row cleanup.
371+
- Recursive subtree and ancestor CTEs with thread-id identity and cycle defense.
372+
- Router tests with no reader still pass through the existing file-backed path.
373+
- First request before warmup triggers single-flight backfill and does not mark
374+
`TASK_INDEX` bootstrapped on failure.
375+
- Warmup/write race.
376+
- Headless cold-start list uses SQL and does not scan all task files after
377+
clearing the in-memory index.
378+
- Bridge running-subtask gating works after restart without a warmed
379+
`TASK_INDEX`.
380+
- R4 invariant: every mutation that changes projected task fields increments
381+
`events.len()`.
382+
383+
Before handoff, run focused tests for the touched crates plus `cargo build` and
384+
`cargo clippy`.

0 commit comments

Comments
 (0)