Add design doc for live recipe watching via fsnotify
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
# Live Recipe Watching
|
||||
|
||||
**Date:** 2026-05-19
|
||||
**Status:** Approved
|
||||
|
||||
## Problem
|
||||
|
||||
The server loads all recipes once at startup and serves them read-only for the
|
||||
duration of the server process. Since the recipe directory is the source of
|
||||
truth, any edits to `.cook` files on disk require a server restart to be
|
||||
reflected on the site.
|
||||
|
||||
## Goal
|
||||
|
||||
Watch the recipe directory for file changes (additions, modifications, deletions)
|
||||
and update the in-memory recipe index live, without a restart.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Real-time push to open browser tabs (a page refresh will show new content).
|
||||
- Watching subdirectories (all recipes are flat in one directory).
|
||||
- Watcher restart on failure (log and let it die; restart the container if needed).
|
||||
|
||||
## Architecture
|
||||
|
||||
### State
|
||||
|
||||
Replace the current `[RecipeInfo]` closure with an `IORef (Map FilePath RecipeInfo)`:
|
||||
|
||||
- **`IORef`** — provides atomic reads and swaps; in `base`, no extra dependency.
|
||||
- **`Map`** — `Data.Map.Strict`, already a project dependency.
|
||||
- **Key** — lowercased filename (e.g., `"pad-thai.cook"`) for O(log n) case-insensitive
|
||||
lookups, matching the current `lookupRecipe` behavior.
|
||||
- **Value** — the existing `RecipeInfo`, which includes the original (mixed-case)
|
||||
filename in `riFilename`.
|
||||
|
||||
### Watcher Thread
|
||||
|
||||
A `forkIO`'d thread uses the `fsnotify` package to subscribe to all events on the
|
||||
recipe directory. Three event types are handled:
|
||||
|
||||
| Event | Action |
|
||||
|------------|-----------------------------------------------------|
|
||||
| `Added` | Read & parse the file → upsert in the Map |
|
||||
| `Modified` | Read & parse the file → upsert in the Map |
|
||||
| `Removed` | Delete from the Map |
|
||||
|
||||
All mutations go through `atomicModifyIORef'` so HTTP handlers always see a
|
||||
consistent snapshot.
|
||||
|
||||
### Debouncing
|
||||
|
||||
Text editors fire multiple filesystem events per save (e.g., delete+create for
|
||||
atomic saves, multiple metadata events). A simple 200ms debounce collects
|
||||
events from `fsnotify`'s callback into a set, then processes them after 200ms of
|
||||
silence. This prevents redundant re-parses.
|
||||
|
||||
### Startup Flow
|
||||
|
||||
1. Scan the recipe directory with `Idx.scanRecipes` (same as today).
|
||||
2. Build the initial `IORef (Map FilePath RecipeInfo)`.
|
||||
3. `forkIO` the watcher thread.
|
||||
4. Return the WAI `Application` (now closing over the `IORef`).
|
||||
|
||||
### Request Handling
|
||||
|
||||
- `handleRequest` reads from the `IORef` to get the current `[RecipeInfo]`
|
||||
(via `Map.elems`) or a single `RecipeInfo` (via `Map.lookup`).
|
||||
- The index page continues to receive `[RecipeInfo]` and re-renders on each
|
||||
request — no caching changes needed.
|
||||
- `lookupRecipe` becomes a `Map.lookup` with a normalized key instead of a
|
||||
linear scan.
|
||||
|
||||
### Error Handling
|
||||
|
||||
- **Parse error on modification:** log the error (with `[roux]` prefix), keep
|
||||
the old recipe in the Map. The last valid version stays accessible.
|
||||
- **File read error (permissions, race):** log and skip.
|
||||
- **Watcher thread crash:** log and let it die. The server continues serving
|
||||
the last known state. A container restart would restore watching.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Add `fsnotify` to `package.yaml` (and the `.cabal` file via `hpack`).
|
||||
|
||||
No new concurrency libraries needed — `IORef` and `forkIO` are in `base`.
|
||||
|
||||
## Changes by File
|
||||
|
||||
### `package.yaml`
|
||||
- Add `fsnotify` to library dependencies.
|
||||
|
||||
### `src/Roux/RecipeIndex.hs`
|
||||
- No changes (the scanning/loading functions are already correct).
|
||||
|
||||
### `src/Roux/Server.hs`
|
||||
- `app :: FilePath -> IO Wai.Application` — same signature, new internals:
|
||||
- Return type is already `IO Wai.Application`, so signature stays the same.
|
||||
- Scan recipes → populate `IORef`.
|
||||
- Fork watcher thread (with debounce).
|
||||
- Return handler closing over the `IORef`.
|
||||
- `handleRequest :: IORef (Map FilePath RecipeInfo) -> Wai.Application`
|
||||
— updated to accept the `IORef`.
|
||||
- `lookupRecipe :: FilePath -> Map FilePath RecipeInfo -> Maybe RecipeInfo`
|
||||
— simplified to a `Map.lookup` with normalized key.
|
||||
- New module-level imports: `Data.IORef`, `Control.Concurrent (forkIO)`,
|
||||
`System.FSNotify`, `Data.Map.Strict`.
|
||||
|
||||
### `app/Main.hs`
|
||||
- No changes needed (it just calls `Roux.app` as before).
|
||||
|
||||
## Testing
|
||||
|
||||
- **Manual test:** edit a `.cook` file while the server runs and confirm the
|
||||
change is reflected on a page refresh.
|
||||
- **Invalid recipe test:** introduce a parse error and confirm the old version
|
||||
stays live.
|
||||
- **Delete test:** remove a `.cook` file and confirm a 404 on refresh.
|
||||
Reference in New Issue
Block a user