fix(tasks): stop reindex from writing defaults over indexed columns - #265
Conversation
TaskManager.reindex() passes eight of upsert_task()'s nine parameters, but that
method replaces the whole row: its ON CONFLICT(id) DO UPDATE SET assigns status,
source, source_url, deadline and tags from excluded.* unconditionally. Every
argument the call omits or mis-keys is therefore written back as its signature
default, so a single reindex() corrupts four columns at once.
tags is omitted entirely, so the column that every tag reader filters on is
emptied. list_tasks(tag=), count_tasks(tag=) and all three search_tasks tag
paths share the predicate ',' || tags || ',' LIKE '%,<tag>,%', so the row stops
matching any tag, and the FTS mirror loses its tag tokens with it.
status falls back to the directory default, because fields.get("status", status)
reads a **Status:** frontmatter line that no writer emits. Unlike tags this is
unrecoverable: the value exists only in the database, so there is nothing on disk
to restore it from. On the workspace this was developed against, 392 of 395
active rows would reset to pending, which is the whole routing state. Reading
that field is wrong in any case. Twenty task files do contain such a line; most
hold prose, which was stored verbatim as the status, and four hold a value that
is exactly a configured status, so the read can also store a plausible but stale
one. The read is therefore removed rather than repaired.
source receives the frontmatter value from the **Source:** line, which holds the
source URL (handlers/tasks.py writes source_url there), while source itself is a
short vocabulary column. deadline and source_url are nulled whenever the file
omits their line.
parse_task_frontmatter is fixed in the same change, because the values reindex
now trusts have to mean what they say. Its value pattern was \s*(.+), where \s
matches newlines and (.+) demands a character, so a field written with no value
did not parse as empty: it consumed the blank line and captured the next
non-empty one. On this workspace, 84 of 1309 task files carry a tags value that
this changes, and a reindex would have stored titles, **Issue:** URLs and
markdown headings as a task's tags. The shape is not hypothetical:
task_update(tags="-lasttag") rewrites the line as **Tags:** with nothing after
it, mid-file. The pattern becomes [ \t]*(.*), which cannot cross a newline and
permits an empty value, so the key is still registered and the field reads as the
explicit clear it is. The value is optional rather than merely line-bounded
because 71 blank fields on disk have no trailing space either. This parser has
three production callers, so the change was measured against the whole suite
rather than argued: see the suite comparison below.
The fix supplies the values at the call site, using the idioms the six correct
upsert_task() callers already use, and leaves upsert_task()'s replacement
semantics untouched. That is the same shape and the same deliberate scoping as
PR ClickHouse#238, which fixed this class on the two completion paths and left reindex() as
the last caller treating a full-row replace as a partial update.
status now comes from the stored row, with the directory authoritative for
terminality: a file in done/ is terminal by definition, and a stored done on an
active/ file is the orphan state docs/tasks.md forbids, so it is corrected rather
than propagated. For the columns a file may carry, the rule is per column, each
matching the save path that already writes it: tags takes the file whenever the
field is present, so a present-but-empty field is an explicit clear, while
source_url and deadline take any non-empty file value and otherwise keep the
stored row. In every case an absent field means "no information", not "empty".
The tags discriminator is presence ("tags" in fields), not truthiness, which is
what makes an empty value a clear instead of a preserve. tags is canonicalized on
the way in, since the on-disk form is display text while every reader's predicate
needs the lowercase comma-joined form.
reindex() has no callers today, so nothing triggers this yet. It is kept rather
than deleted because it is the only index-rebuild path in the codebase and two
migrations, v005_tasks_fts and v023_tasks_fts_slug, both name it as how FTS
content gets filled.
Thirty-three tests are added, one behaviour per affected column and per blank
field position, so a partial fix cannot pass. Against the unfixed tree
twenty-three fail and ten pass, the ten being controls that assert unchanged
behaviour; with the fix all thirty-three pass. Two of them needed a **Status:**
line in their fixture to be meaningful at all: without one, the unfixed code
reaches the correct answer through its directory default, so the assertion was
decided by the default rather than by the rule under test. Every preservation
test also pins a file-derived value, because reindex logs and continues past a
per-file exception, so a total no-op satisfies "the value did not change" on its
own.
Coverage is pinned rather than asserted. Sixteen mutants, each weakening exactly
one clause of the change, are all killed by at least one test, and each of the
ten base-passing controls is killed by at least one mutant, so no shipped test is
unpinned. The mutant that skips every row before it is upserted kills all
twenty-six reindex tests, the seven survivors being the parser tests, which never
call reindex. An unmutated control ran green before and after the matrix.
The full suite reports 7 failed, 2966 passed against 7 failed, 2933 passed at
base, with byte-identical sorted FAILED-name sets and exactly thirty-three more
passes. The seven pre-existing failures are six in an LLM-dependent memory date
sweep and one timezone assertion; neither file references this code.
|
|
Pre-PR validation (a-i)
Coverage is pinned rather than asserted: sixteen mutants, each weakening exactly one clause of the change, are all killed by at least one test, with an unmutated control green before and after the matrix. Each of the 10 base-passing control tests is killed by at least one mutant, so no shipped test is unpinned. Every mutant asserts its anchor matched exactly once, that the anchor is gone afterwards and that the file still parses, before any verdict is recorded. The mutant that skips every row before it is upserted kills all 26 reindex tests; its 7 survivors are the parser tests, which never call No-regression proof by name rather than by count: base |
Internal second-model review - 2 rounds, 14 findings, 0 disagreed (click to expand)Before opening this PR I reviewed it in two rounds: an independent cold read of the
Two residuals are disclosed rather than fixed: finding 12 (a comment-only wording issue in Gate spend across both rounds: 4 runs, $14.29. |
|
cc @fallintoplace @constkolesnyak - could you review this? |
Problem
TaskManager.reindex()passes eight ofupsert_task()'s nine parameters, butthat method replaces the whole row (
ON CONFLICT(id) DO UPDATE SETassigns everycolumn unconditionally), so each omitted or mis-keyed argument is written back as
its signature default. One
reindex():tags(notags=kwarg), emptying the column every tag readerfilters on:
list_tasks,count_tasksand all threesearch_taskstag pathsshare one
',' || tags || ',' LIKE '%,<tag>,%'predicate.statusto the directory default, since nothing writes a**Status:**line. Unlike tags this is unrecoverable, the value existing onlyin the database: here, 392 of 395 active rows, all routing state. Reading that
field is wrong regardless: the 20 files carrying one mostly hold prose, stored
verbatim as the status.
source: it reads the frontmatter key holding thesource URL, while
sourceis a vocabulary column.Same class as #238, which fixed the completion paths and left
reindex()as thelast such caller. It has no callers today, but is the only index-rebuild path and
two migrations name it as how FTS content gets filled.
Fix
Supply the three values at the call site with the idioms the six correct callers
use, leaving
upsert_task()unchanged.statuscomes from the stored row, thedirectory deciding terminality: a
done/file is terminal, and a storeddoneon an
active/file is the orphan statedocs/tasks.mdforbids.tagstakes the file whenever the field is present, so a present-but-empty one is an
explicit clear, matching
task_write;source_urlanddeadlinetake anynon-empty file value and otherwise keep the row.
parse_task_frontmatteris fixed in the same change. Its value pattern used\s*(.+), whose\s*crosses a newline, so a field written blank captured thenext non-empty line instead of parsing as empty; it is line-bounded now. That
parser has three callers, so the change is validated against the full suite
below.
Verification
33 new tests, one behaviour per column: unfixed 23 fail, 10 pass as controls; all
33 pass fixed. Full suite
7 failed, 2966 passed, the same 7 failures by name asbase (pre-existing, unrelated), +33 passes. Sixteen mutants each weakening one
clause are all killed.