Skip to content

perf: make a date-filtered search bound its own work - #84

Merged
detour1999 merged 5 commits into
mainfrom
perf/date-bounded-fts-search
Oct 5, 2026
Merged

detour1999 merged 5 commits into
mainfrom
perf/date-bounded-fts-search

Conversation

@detour1999

@detour1999 detour1999 commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Closes #80. Repairs #81 as a side effect — see "What this does to #81" below.

The goal, and whether it was met

Met. A date-filtered search's cost now tracks the window asked for rather than the size of the archive. Measured on a copy of the author's 951,548-turn / 264,199-tool-use / 5.37 GB archive:

git with a date filter window turns in window today this branch
after:2026-10-05 1 day 130 0.20s 0.02s
after:2026-10-01 5 days 6,148 1.21s 0.02s
after:2026-09-28 8 days 16,687 1.21s 0.03s
after:2026-09-01 35 days 89,071 1.21s 0.06s
after:2026-08-01 66 days 321,348 1.30s 0.14s
no filter 265 days 951,548 2.04s 0.55s

Today's column is flat at 1.21s from a five-day window to a thirty-five-day one — the fixed cost #80 is about. This branch's column slopes with the window: subtract the 0.02s floor (process start plus opening a 6 GB database, measured with a query that matches nothing) and the query costs 0.00s, 0.01s, 0.04s, 0.12s, 0.53s against window sizes of 0.6%, 1.8%, 9.4%, 33.8%, 100% of the archive. Cost is now roughly proportional to the window, where before it was roughly constant.

The one-day row is cheap today for a reason worth naming: it returns 1 result rather than 20, and most of today's fixed cost is per returned row, not per match. That is half of what this PR fixes and it was not in #80's diagnosis.

The queries in #80's table

query #80 reports measured today this branch
"git commit" all-time 2.49s 0.16s 0.06s
"git commit" after:2026-09-28 0.60s 0.14s 0.03s

I could not reproduce #80's absolute numbers for this pair and I am not going to pretend otherwise. "git commit" as a phrase matches 1,412 turns and 652 tool uses on the current archive — not the 2,432 and 10,429 the issue reports — so whatever was measured there was matching about sixteen times as much material as the phrase does now. The issue's signature reproduces exactly, though: 0.16s all-time against 0.14s for a window holding 1.8% of the archive is the same "the filter prunes results but not the scan" shape, just with smaller absolute numbers. Single-word queries are where it bites, so the table above uses git, and two more for breadth:

query today this branch
commit all-time 1.47s 0.39s
commit after:2026-09-28 0.88s 0.03s
error all-time 1.75s 0.91s
error after:2026-10-01 0.67s 0.03s

All numbers are the minimum of 7 runs of the real CLI against the real archive, warmed first. CPU time (user+sys) tracked wall clock to within 0.01s throughout, so these are not I/O artefacts; the machine carries a permanent 95%-CPU Spotlight process, which is why the minimum rather than the mean is quoted. Spread was within 10% except where noted in the thread.

Two causes, not one

#80 diagnosed the text_hits CTE: a UNION of every all-time match materialised into a temp B-tree before the date predicate is consulted. That is real, and the fix is the period token. But it is a third of the problem. Timed piece by piece, git after:2026-10-01:

text_hits CTE, as today 0.110s
text_hits CTE, period-pruned 0.001s
whole statement, as today 1.19s
whole statement, period terms only 1.07s

The missing second is the pair of correlated subqueries #28 added to label a payload hit with the tool that matched and to cut its snippet. They are scoped to one turn, but they were written FTS-table-first with turn_id as a filter on the output, and tool_uses had no index on turn_id — so SQLite answered "the matching tool use of this turn" by walking all 27,315 all-time matches, twice per returned row. Per returned row, so pruning the index cannot reach it, and it does not shrink when the window does.

Both fixes are needed and they compose:

git after:2026-10-01
today 1.19s
period terms only 1.07s
turn-first attribution only 0.11s
both 0.003s

Design decisions

Granularity: day and month, with the range decomposed. Month alone keeps a year to twelve terms but is five times coarser than the window people ask for: after:2026-09-28 admits 16,687 turns where September and October together hold 89,071. Day alone makes a year 365 terms. Carrying both and spending days only on the partial month at each end covers the range exactly — a week is seven terms, a year about seventy, and the pruned set is the window rather than a rounding of it. For the measured query the compiled terms admit 885 documents where the unpruned match admits 35,067.

Namespace: ccvd20261005 / ccvym202610, plus a column filter. The prefixes are so the tokens read as tokens in an EXPLAIN; they are not what prevents collision, because unicode61 tokenizes a query the same way it tokenizes content and any term a document holds is a term a user can type. What prevents collision is that every MATCH this codebase builds is now column-scoped: the caller's text against the content columns, period terms against search_period. So a message containing ccvym202610 is found by searching for it and contributes nothing to any date filter, and a date filter cannot be answered by anything but the period column. db.SearchTurns and SearchTurnsWithFilters are scoped the same way — they are test-only today, but they are the other door into turns_fts. Measured: scoping costs nothing (0.072s against 0.074s for the same CTE).

Storage: a VIRTUAL generated column, plus one new FTS column. No bytes in the table, no backfill UPDATE at all, and the token cannot drift from the timestamp it describes. Sliced out of the stored text rather than read with strftime, which is the load-bearing detail: the date filter is a lexicographic comparison of that same text, so deriving the token from the text ties the token to the comparison. If a row's date prefix sorted below the filter's bound, the whole string would too — which is the proof that the terms can only ever admit more rows than the predicate keeps, never fewer. An extra FTS column rather than appending to the indexed text, because appending would put the token inside snippet()'s output.

Both indexes. turns_fts as well as tool_uses_fts. A UNION is only as bounded as its looser branch, and leaving one unbounded leaves the cost growing with the archive — which is the property being fixed, not the absolute number.

Correctness

Not negotiable for a performance change, so it is asserted three ways.

  • Same rows on the real archive. Nine queries run under both binaries against all 951,548 turns — narrow windows, both-bounded ranges, a before:-only range, and an all-time control. Identical turn ids in identical order every time, and identical tool attribution and snippet text across 595 attributed rows.
  • Boundaries, explicitly. TestSearch_DateFilterReturnsTheSameRowsAtPeriodBoundaries seeds a turn at 23:59:59.999999999 on the last day of a month and another 1 ns past the following midnight, then runs eight queries across that edge, including a payload-only match and a window that compiles to a single month term. It was written against the unwired code and passed there first, which is what makes it a control rather than a tautology: the rows are the same before and after.
  • The predicate stays. The terms bound the scan; WHERE timestamp > ? is what decides, and it is what trims the partial first and last day to the hour. A timestamp the period column cannot read as a date gets a ccvymx sentinel that every compiled term set includes, so such a row stays a candidate and the predicate decides it exactly as before — a missing token would be a wrong answer, where this change is only meant to buy a faster one.

How the pruning is proved, separately from correctness

A test that asserts "the right rows come back" passes before and after, so it proves nothing about pruning. Two tests do that job:

  • TestSearch_DateFilterPrunesInsideTheIndex builds a fixture of 246 matching turns, 243 of them outside the window, takes the MATCH expression the searcher actually binds, and counts the documents the index yields for it. Unfiltered: 246. Filtered: 3. The control is in the same test and the same fixture, so the two counts are the pruning.
  • TestSearch_PayloadAttributionDrivesFromTheTurn asserts the query plan, because the attribution defect is invisible in everything but the wall clock — both shapes return identical rows, and 1.08s against 0.001s only exists at a million turns. The assertion is that idx_tool_uses_turn_id appears twice, once per subquery. It fails against the old shape, and it fails if either the index or the CROSS JOIN that pins the join order is removed.

CROSS JOIN is load-bearing. SQLite has no row estimate for an fts5 MATCH, so left to choose it puts the virtual table first and the scan comes back: the SQLite that modernc.org/sqlite bundles happens to pick the index for the old shape too once it exists, but system SQLite 3.51 keeps the scan even with the index present (1.19s, plan verified). Pinning the order makes the plan a property of the query rather than of the planner's mood. It also makes the index a hard dependency rather than an optimisation — pinned order with no index to seek is a scan of all 264,199 tool uses per returned row, 3.0s, worse than today.

What this does to #81

It repairs it, and that is announced rather than quiet because it changes the issue's disposition.

Migration 010 cannot alter an FTS5 column list, so it drops and recreates both indexes and derives them from the content tables. On the author's archive turns_fts held 1,004,353 documents for 951,548 turns — 52,805 orphans (#81 counted 29,268 when it was filed; the archive has grown since). After the migration:

  • turns_fts_docsize holds exactly 951,548 rows, tool_uses_fts_docsize exactly 264,199. Zero orphans by the NOT EXISTS check in both.
  • FTS5's strict integrity-check with rank 1 passes on both — 15.8s for turns_fts, 4.3s for tool_uses_fts. That is the check Live archive has 29,268 orphaned turns_fts entries; strict integrity-check reports malformed #81 reports as returning "database disk image is malformed", and it is the only check that can see this: COUNT(*) on an external-content table resolves through the content table and agrees with itself however far the index has drifted, and plain PRAGMA integrity_check returns clean too.

So #81 can be closed by applying this migration. What it does not do is explain how the orphans got there or prevent a recurrence — that was the pre-recursive_triggers era, already fixed, and this is a repair of the damage rather than of the cause.

Migration cost, measured

Timed end to end on a copy of the author's 5.37 GB archive, going from schema 9 to schema 10:

  • 35.9s wall (11.6s user, 3.6s sys) for the two generated columns, the dropped and recreated indexes, and one rebuild each.
  • The file does not grow. 6,008,029,184 bytes before and after. The old indexes' pages are freed and the new ones take them back; the period column adds one term per document to each index and costs about what the orphans it discarded were costing.
  • idx_tool_uses_turn_id is 0.3s to build and 2,940 pages — 12 MB — of which only 4.8 MB shows up as file growth, the rest coming out of the free list the rebuild left.
  • No backfill UPDATE, because the column is generated. Tool inputs and results are parsed then discarded — not stored, not searchable #28 measured the alternative at 9.2s and 377 MB of dead FTS segments for a full-table update with triggers live.
  • Replay-safe: the ADD COLUMNs are absorbed by the migrator's existing duplicate-column tolerance, and everything else is DROP ... IF EXISTS plus a rebuild that re-derives rather than duplicating.

Verification

go build ./...                                    clean
go clean -testcache && go test ./...              21/21 packages ok
go test -race ./internal/db/... ./internal/search/...   ok (31.7s, 5.6s)
pre-commit (golangci-lint, go vet, gofmt, unit)   passed on every commit

Out of scope, found on the way

Four things this PR deliberately does not fix:

  1. A turn whose stored timestamp is not a parseable date makes Search fail outright — sql: Scan error on column "timestamp": unsupported Scan, storing driver.Value type string into type *time.Time — so the whole query errors rather than the row being skipped. Pre-existing, unrelated to this change, and the reason the sentinel test asserts on the index rather than through Search.
  2. before: is exclusive at midnight of the named day, so before:2026-09-30 excludes everything that happened on the 30th. Long-standing, surprising, and the period terms deliberately agree with the predicate rather than with what the operator sounds like.
  3. tool_uses holds 6,378 rows whose turn_id names no turn. They can never be returned (the search joins through turns), so they are invisible rather than wrong, but they are 2.4% of the table and they are indexed.
  4. The date filter compares a bound rendered in the caller's timezone against text stored in UTC. after:today means "after local midnight, compared as a string against UTC timestamps", which is off by the offset. Pre-existing; the period terms are derived from the same rendering so they agree with it exactly rather than papering over it.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Summary by CodeRabbit

  • Search
    • Date filters now narrow text searches to the selected time period, including searches across turn content and tool payloads.
    • Searches continue to match text independently of date-filter terms, and results at date boundaries are preserved.
    • Searches also retain candidates with unreadable timestamps rather than excluding them.
  • Reliability
    • Search indexes are refreshed when timestamps change, helping date-filtered results reflect updated records.

detour1999 and others added 4 commits October 5, 2026 15:01
Migration 010. Both FTS indexes gain a search_period column, fed by a
VIRTUAL generated column on turns and tool_uses that slices the day and
month out of the stored timestamp. Nothing queries it yet; the next
commit compiles date filters into terms that match it.

Derived rather than stored, so there is no backfill UPDATE and the token
cannot drift from the timestamp it describes. Sliced out of the text
rather than read with strftime because the date filter is a lexicographic
comparison of that same text — deriving the token from it is what ties the
token to the comparison, so a row the predicate admits always carries a
token in the compiled set.

A timestamp that is not a padded date gets the 'ccvymx' sentinel, which
every compiled term set includes. Without it such a row would vanish from
date-filtered searches instead of being decided by the predicate, which
would trade a slow answer for a wrong one.

An FTS5 column list cannot be altered, so both indexes are dropped and
rebuilt. turns_fts as well as tool_uses_fts: the UNION has two branches
and leaving one unbounded leaves the cost growing with the archive. That
rebuild also discards the orphaned turns_fts entries issue #81 reports —
29,268 of them on the author's archive, from the era before the turns_ad
trigger's recursive_triggers dependency was understood.

turns_au and tool_uses_au now fire on timestamp as well. The period token
derives from it, so an UPDATE that moved a row in time and left its text
alone would otherwise leave the index claiming the wrong month.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… for

Both branches of the text_hits UNION now carry the period terms migration
010 indexed, so a date filter prunes inside the postings-list
intersection instead of after the whole all-time match set has been
materialised into a temp B-tree. The timestamp predicate stays exactly
where it was: the terms make the scan bounded, the predicate keeps the
answer right, and it is the predicate that trims the partial first and
last day to the hour.

A one-sided filter takes its other side from the archive's own date
range, read with two single-aggregate queries that SQLite answers with a
seek on idx_turns_timestamp. after: with no before: is the common case
and has no upper bound of its own; using "now" for it would silently drop
any row stamped in the future, and a clock-skewed transcript happens.

Every MATCH this package builds is now column-scoped — the caller's text
against the content columns, period terms against search_period. That is
what keeps a turn whose text happens to hold a period token out of a date
filter, and a caller searching for that token from matching the whole
month. db.SearchTurns and SearchTurnsWithFilters are scoped the same way
for the same reason.

The match-attribution subqueries keep the unpruned expression. They are
already scoped to a single turn, so they have nothing to prune, and
leaving the period terms out of them keeps snippet()'s automatic column
choice looking only at the two columns that hold payload text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ndex

Pruning the index turned out to buy 12% rather than the order of magnitude
it should have, because the index scan was not where the time was going.
Measured on the author's archive, `git after:2026-10-01`:

  text_hits CTE, unpruned      0.110s
  text_hits CTE, pruned        0.001s
  whole statement, before      1.19s
  whole statement, pruned CTE  1.07s

The missing 1.08s is the pair of subqueries that label a payload hit with
its tool name and its snippet. They are correlated to one turn, but they
were written FTS-table-first with turn_id as a filter on the output, and
tool_uses had no index on turn_id — so SQLite answered "the matching tool
use of this turn" by walking all 27,315 all-time matches for `git`, twice
per returned row. That cost is per returned row, so it neither shrinks
with the window nor shows up in the CTE's plan.

Turning each subquery around — seek tool_uses by turn_id, then probe the
index for that one document — makes the work the turn's own tool uses.
The two compose:

  before                              1.19s
  period terms only                   1.07s
  turn-first attribution only         0.11s
  both                                0.003s

CROSS JOIN is load-bearing. SQLite has no row estimate for an fts5 MATCH,
so left to choose it puts the virtual table first and the scan comes back;
the plan assertion in TestSearch_PayloadAttributionDrivesFromTheTurn fails
if either the CROSS JOIN or the index goes away. A plan assertion because
both shapes return identical rows and the difference is invisible on a
fixture — 1.08s against 0.001s only exists at a million turns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measured rather than assumed. Dropping idx_tool_uses_turn_id and keeping
the CROSS JOIN turns each attribution lookup into a scan of all 264,199
tool uses per returned row: 3.0s for `git after:2026-10-01` against the
1.2s the unpinned shape costs without the index. So the index is a
dependency of this query shape, not a tuning of it, and the comment says
so where the shape is written.

Also corrects the index's measured size in migration 010. It is 2,940
pages — 12 MB — where the comment said 5 MB; 5 MB was the file's growth,
which is smaller because the FTS rebuild above it leaves free pages for
the index to take.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 2285c8a2-5f8d-4f6a-a775-f8fed77628d2
📥 Commits

Reviewing files that changed from the base of the PR and between d2cd709 and 9ed201a.

📒 Files selected for processing (8)
  • internal/db/migrations/010_add_search_period.sql
  • internal/db/schema.sql
  • internal/db/searchperiod_test.go
  • internal/db/turns.go
  • internal/search/datefilter_test.go
  • internal/search/period.go
  • internal/search/period_test.go
  • internal/search/search.go

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.


Walkthrough

The change adds timestamp-derived day and month tokens to turn and tool-use FTS indexes. Search compiles date bounds into scoped FTS expressions and resolves missing bounds from archive timestamps. Database and search tests cover token indexing, date bounds, expression scoping, and query behavior.

Changes

Date-Filtered Search

Layer / File(s) Summary
Index period tokens
internal/db/schema.sql, internal/db/migrations/010_add_search_period.sql, internal/db/searchperiod_test.go
Turns and tool uses gain generated search_period columns and FTS indexes that include those columns. Triggers maintain the indexed values when rows are inserted, deleted, or updated. Migration 010 rebuilds both indexes. Database tests check token derivation, index integrity, timestamp updates, and orphan removal.
Compile date bounds into period terms
internal/search/period.go, internal/search/period_test.go
Date ranges compile into day and month terms, with a term for timestamps that do not match the padded-date pattern. FTS expressions scope text and period terms to separate columns. Tests cover range compilation and date formatting.
Apply period terms in search queries
internal/search/search.go, internal/db/turns.go, internal/search/datefilter_test.go
Search resolves missing date bounds from archive timestamps and applies period terms to turn and payload FTS expressions. Payload attribution probes tool-use matches for each page turn. Direct turn searches scope text to the content column. Tests cover date boundaries, expression scoping, unreadable timestamps, and query plans.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Bug fix · Severity of issue fixed: Medium

Sequence Diagram(s)

sequenceDiagram
  participant Search
  participant periodTermsFor
  participant ArchiveDB
  participant buildQuery
  participant turns_fts
  participant tool_uses_fts
  Search->>periodTermsFor: Resolve date bounds and period terms
  periodTermsFor->>ArchiveDB: Read oldest or newest turn timestamp for a missing bound
  periodTermsFor-->>Search: Return compiled period terms
  Search->>buildQuery: Pass period terms
  buildQuery-->>Search: Return scoped FTS expressions and query arguments
  Search->>turns_fts: Match turn content with period terms
  Search->>tool_uses_fts: Match tool payloads with period terms
Loading

Merge Risk: ⚪ Minimal · up to 9ed20

The reviewed date-filtered search change is mergeable after normal checks. No demonstrated regression remains.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 9ed20

Existing access controls are preserved, but very wide date queries can bypass the intended work limit and produce oversized search expressions. The confirmed exposure is to callers already able to search the archive.

Retained concerns

  • Low · security · observed: The new date compiler does not enforce its term budget on whole-month ranges. Its month branch appends a token and continues before the size check, so a short query such as “git after:0002-01-01 before:9999-12-31” can generate roughly 120,000 period terms and bind oversized expressions to both search indexes. This introduces caller-controlled resource amplification beyond the intended fallback behavior. Process-level denial of service has not been demonstrated.
Security review details

Security Blast Radius

  • inferred — The confirmed resource-amplification boundary is the existing archive-search process, including its agent-facing MCP search tool. Triggering the new expansion requires search-query access, not database-write authority. Cross-tenant exposure or remote network reachability was not established.

Security Findings and Attack Paths

  • inferred — A caller can supply a compact text query with distant date bounds ending on a month's last day. Whole-month expansion bypasses the term-budget check and feeds large OR expressions into both indexes. This can increase compilation and database parsing work despite a small result limit; actual disruption severity remains unmeasured.

Trust Boundaries and Controls

  • observed — Date inputs are parsed into time values and converted to fixed-prefix period tokens rather than interpolated into SQL structure. Bound parameters and separate FTS column scopes protect query structure and content isolation, but do not limit the number of generated tokens.

Resilience and Maintainability Implications

  • observed — Failed migration statements return an error and leave the transaction uncommitted; migration-version recording occurs only after all statements succeed. This contains partial index-replacement failures instead of committing mismatched schema and index state.

Hardening Proposals

  • proposed — Apply the period-term budget after every token append, including the whole-month branch, and fall back to timestamp-only filtering once the budget is exceeded. Cover complete-month wide ranges and extreme accepted dates when validating this resource boundary.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: date-filtered search now limits its work to the requested date range.
Linked Issues check ✅ Passed [#80] The PR adds day- and month-based period terms to both FTS indexes and compiles date bounds into column-scoped MATCH expressions. It retains the timestamp predicate for exact results. Tests cover…
Out of Scope Changes check ✅ Passed The changes support the search-performance objective in [#80]. Rebuilding the FTS indexes is required to add the period column; removal of orphaned index entries is a consequence of that rebuild. The …
Docstring Coverage ✅ Passed Docstring coverage is 85.71% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 28 functions across 6 files. (2 skipped: 2 …
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

I’m a rabbit with dates tucked under my paw,
I hop through the months, then pause to explore.
Day tokens go thumping through FTS with a bounce,
Unknown days join in when the dates don’t announce.
I nibble a carrot and cheer for the search!

Comment @coderabbitai help to get the list of available commands.

The "why" block quoted an early, noisier run. The final figures, minimum
of seven runs of the real CLI against the real archive: `git` all-time
2.04s and `git after:2026-10-01` 1.21s, which is also what the same query
costs for a thirty-five-day window — the flatness is the point and it was
missing from the note.

Also fixes a wrong direction: the index takes its free pages from the two
rebuilds below it, not above it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@detour1999

Copy link
Copy Markdown
Contributor Author

Interaction with #88, worked through

#88 (fix(search): local-day date filters and tolerate bad timestamps) lands in the same file and changes the semantics this PR's period terms are derived from. I read its diff rather than guess, and the two compose — but whichever merges second has three expectations to update, and one of them is a test of mine that will go red for a correct reason.

The period terms stay correct under #88's semantics

The safety argument here is that a row the timestamp predicate admits always carries a token in the compiled set, because the predicate is a lexicographic comparison of text whose first ten characters are the calendar date: if a row's date prefix sorted below the bound's, the whole string would too. #88 changes the bounds but not that property.

  • parseDate returning local rather than UTC midnight — fine, and specifically because periodFilterDate formats the bound in its own location rather than converting to UTC. The driver binds a time.Time as its Go String(), which renders in that same location, so the token and the comparison keep naming the same day. Converting to UTC first is the thing that would have broken, and the test TestPeriodFilterDate pins it against a +14:00 bound for exactly this reason.
  • after: going from > to >= — the lowest admitted date prefix is still the bound's own date prefix, which is still from.
  • before:DATE becoming inclusive by advancing the bound a day — periodTerms treats to as inclusive, so this emits one extra day term. Over-inclusion costs a term and is trimmed by the predicate; it cannot lose a row. Worth tightening afterwards, not urgent.
  • coerceTimestamp tolerating unparseable timestamps — orthogonal, and an improvement for this PR: it means TestSearch_DateFilterKeepsAnUnreadableTimestamp could assert through Search instead of at the index, which is where it had to live because a bad timestamp currently fails the whole query.

What breaks on merge, and it is tests rather than behaviour

TestSearch_DateFilterReturnsTheSameRowsAtPeriodBoundaries asserts exact row sets under today's semantics. Two of its eight cases encode what #88 is deliberately changing:

  • before:2026-10-01 currently excludes everything on Oct 1; under fix(search): local-day date filters and tolerate bad timestamps #88 it includes it, so turn-oct-first and turn-oct-noon join that case's expectation.
  • after:2026-09-01 before:2026-09-30 carries a comment calling the exclusivity "the predicate's own long-standing reading of before:". That comment becomes false and should be rewritten to describe the inclusive reading.

And the fixture's instants are UTC while #88 parses bounds as local, so on a non-UTC machine the day boundaries shift relative to each other. The fixture should pin a location explicitly once #88's reading is the real one.

Nothing in query.go is touched here, and the hunks in search.go are in different parts of the function, so the textual merge should be clean. The semantic merge is the part above.

@detour1999

Copy link
Copy Markdown
Contributor Author

Verified interaction with #88 — clean textual merge, three failing tests

#88 (fix/search-local-date-filters-and-bad-timestamps, from a fork) fixes #85 and #86 and touches internal/search/search.go, as does this PR. I tested the combination rather than reasoning about it.

git merge-tree reports zero conflicts. The two PRs touch different hunks of search.go and this PR does not touch query.go at all. So git will merge them silently.

Actually merging them and running the tests gives three failures:

--- FAIL: TestSearch_DateFilterReturnsTheSameRowsAtPeriodBoundaries
    /boundaryneedle_before:2026-10-01
        got [turn-oct-noon turn-oct-first turn-sep-last turn-sep-mid turn-aug]
        want [turn-sep-last turn-sep-mid turn-aug]
    /boundaryneedle_after:2026-09-30_before:2026-10-01
        got [turn-oct-noon turn-oct-first turn-sep-last]   want [turn-sep-last]
    /boundaryneedle_after:2026-09-01_before:2026-09-30
        got [turn-sep-last turn-sep-mid]                   want [turn-sep-mid]

The failures are correct. #88 makes before:DATE inclusive of the named day by advancing the bound to the next local midnight, and switches after: from > to >=. This PR's boundary fixture encodes the previous exclusive reading, so its expectations are what's now stale — not the behaviour.

Nothing in the pruning is broken. Every failure is a case gaining rows it should now gain; no case lost a row. That is the outcome the period-token design predicts: #88's inclusive before: makes this PR's inclusive to emit one extra day term, which over-admits candidates that the outer WHERE then trims exactly. Over-inclusion is safe by construction; under-inclusion would not be, and does not occur.

Two further points of composition worth recording:

What whoever merges second needs to do

Update three expectations in internal/search/datefilter_test.go, and the comment in the whole-month case that describes exclusivity as "the predicate's own long-standing reading" — that sentence becomes false. Mechanical, but it must not be done by loosening the assertions: the point of that test is exact row-for-row equivalence at a period edge, and it is the only thing standing between a pruning bug and silent data loss.

CI status on this PR

Build and Lint pass; CodeRabbit passes. The three Test (1.21/1.22/1.23) jobs have now failed to acquire a GitHub hosted runner three times (The job was not acquired by Runner of type hosted even after multiple attempts), which is the same condition cancelling main's runs. No code is implicated: locally go test ./... is 21/21 with a cleared cache and -race is clean on internal/db and internal/search.

@detour1999
detour1999 merged commit 20cbb95 into main Oct 5, 2026
10 of 17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Date-filtered search does all-time FTS work, so latency scales with archive size not window

1 participant