|
66 | 66 | 3. User opens a project → `GET /api/projects/<name>/sessions` → full `parse_session()` per file, exclusion filter, summary rows. |
67 | 67 | 4. User opens a session → `GET /api/sessions/<project>/<id>` → full session JSON for the message panel. |
68 | 68 | 5. Optional: `GET /api/sessions/.../stats` for sidebar metrics without loading all messages. |
69 | | -6. Search: `GET /api/search?q=...` scans all projects (brute force). |
| 69 | +6. Search: `GET /api/search?q=...` queries the local FTS index when usable, with live JSONL scan fallback (see [Search index](#search-index-fts5--locks-and-threading)). |
70 | 70 | 7. Export: `POST /api/export` or `GET /api/export/session/...` → Markdown/zip via exporters; state file updated on successful bulk export. |
71 | 71 |
|
72 | 72 | ## Dispatch table |
@@ -143,6 +143,56 @@ No bundler step — modern browsers load modules directly. Frontend unit tests u |
143 | 143 | - `integration-tests` — API integration subset + coverage artifact |
144 | 144 | - `js-tests` — `npm ci` + vitest |
145 | 145 |
|
| 146 | +## Search index (FTS5) — locks and threading |
| 147 | + |
| 148 | +`utils/search_index.py` maintains a local SQLite FTS5 index under `~/.claude-code-chat-browser/` (override with `CLAUDE_CODE_CHAT_BROWSER_SEARCH_INDEX_DIR`). Session JSONL on disk remains the source of truth; `GET /api/search` can fall back to a live scan when the index is missing, stale, or temporarily locked during rebuild. |
| 149 | + |
| 150 | +### Threading model |
| 151 | + |
| 152 | +| Role | Who | What | |
| 153 | +|------|-----|------| |
| 154 | +| **Readers** | Flask request threads (and synchronous callers such as `ensure_search_index` on the first request path) | Open the **active** index DB read-only via `_resolve_active_index_db_path()`; `query_index_hits` does not take any module `threading.Lock`. | |
| 155 | +| **Writer** | One daemon thread (`search-index-refresh`, started from `start_search_index_background` in `app.py`) | Periodically calls `ensure_search_index` → `build_search_index` when the projects fingerprint changes. | |
| 156 | +| **Publish** | Writer, at end of a successful rebuild | `_publish_active_index` writes a new `search_index.<id>.sqlite`, atomically replaces `search_index.active` (write temp + `Path.replace`), then prunes older DB files. Readers always open whichever DB name the pointer names — they see the **previous** or **new** index, never a half-written DB file. | |
| 157 | + |
| 158 | +Rebuild work happens on a **new** SQLite file; the active pointer switches only after the new file is committed. Concurrent FTS reads against the old file may fail with `sqlite3.OperationalError` (for example database locked); `query_index_hits` surfaces that as `index_locked=True` so `/api/search` can degrade to live scan instead of returning a silent empty success. |
| 159 | + |
| 160 | +### Lock inventory |
| 161 | + |
| 162 | +Four module-level synchronization primitives guard index lifecycle and caches (`utils/search_index.py`): |
| 163 | + |
| 164 | +| Primitive | Type | Guards | |
| 165 | +|-----------|------|--------| |
| 166 | +| `_index_lock` | `threading.Lock` | `_background_started` and startup/teardown of the background worker (`start_search_index_background`, `reset_background_for_tests`). | |
| 167 | +| `_index_build_lock` | `threading.Lock` | Entire `build_search_index` body — at most one rebuild at a time **in this process**. | |
| 168 | +| `_background_lock_fd` | OS advisory lock on `search_index.background.lock` | Cross-process **single background owner** for a shared index directory (`_try_acquire_cross_process_background_lock`). | |
| 169 | +| `_usability_cache_lock` | `threading.Lock` | In-memory `_usability_cache` used by `index_is_usable` and cleared after rebuild (`_clear_usability_cache`). | |
| 170 | + |
| 171 | +**Usage map (current code):** |
| 172 | + |
| 173 | +- `_index_build_lock` — wraps `build_search_index` (serializes rebuild and fingerprint short-circuit reads). |
| 174 | +- `_index_lock` — held briefly while deciding whether to start the daemon and while resetting test state; may call `_try_acquire_cross_process_background_lock` while held. |
| 175 | +- `_background_lock_fd` — non-blocking exclusive flock/msvcrt lock; failure means another process already owns background refresh for this cache dir. |
| 176 | +- `_usability_cache_lock` — `index_is_usable` and `_clear_usability_cache`. |
| 177 | +- `_publish_active_index` — pointer swap + `_prune_stale_index_files` (no module lock; relies on atomic pointer replace and build serialization). |
| 178 | + |
| 179 | +### Lock-acquisition contract (must stay so) |
| 180 | + |
| 181 | +The four primitives are **not nested arbitrarily**. New code must preserve this contract; the planned Week 30 concurrency suite (`tests/test_search_index_concurrency.py`, PR after this doc) will assert it and fail if lock order regresses. |
| 182 | + |
| 183 | +1. **`_index_lock`** may be taken alone, or together with acquiring the advisory background lock during `start_search_index_background`. Do **not** call `build_search_index` while holding `_index_lock`. |
| 184 | +2. **`_index_build_lock`** is taken only inside `build_search_index`. While held, code may open the active DB read-only and write a **new** sidecar SQLite file. The only permitted nested module lock is **`_usability_cache_lock`** via `_clear_usability_cache` at the end of a successful rebuild (`_index_build_lock` → `_usability_cache_lock`). |
| 185 | +3. **`_usability_cache_lock`** must never be held while waiting on `_index_build_lock` or `_index_lock`. Typical pattern: short cache read/update in `index_is_usable` without holding the build lock. |
| 186 | +4. **`_background_lock_fd`** is independent of the three `threading.Lock` instances except at startup: acquire the advisory lock only from `_try_acquire_cross_process_background_lock`, which is called under `_index_lock` once per process. |
| 187 | + |
| 188 | +**Forbidden:** holding `_usability_cache_lock` and then acquiring `_index_build_lock` or `_index_lock`; holding `_index_lock` across `build_search_index`; taking `_index_build_lock` from multiple threads without going through the existing `build_search_index` entry point. |
| 189 | + |
| 190 | +### Deployment assumptions |
| 191 | + |
| 192 | +The design targets **one WSGI worker process** (for example `python app.py` or `gunicorn --workers 1`). That gives one in-process writer thread, one advisory-lock owner, and consistent in-memory usability cache per operator session. |
| 193 | + |
| 194 | +See [Deployment — WSGI workers](../README.md#deployment-wsgi-workers) in the README for multi-worker warnings. |
| 195 | + |
146 | 196 | ## What this codebase is not |
147 | 197 |
|
148 | 198 | - **Not multi-user** — no authn/authz; single local operator. |
|
0 commit comments