How Daemon Mode and File Watching Maintain Graph Consistency in Code-Review-Graph
The code-review-graph daemon ensures continuous synchronization by spawning child watcher processes per repository, debouncing filesystem events into atomic incremental updates, and implementing self-healing health checks that automatically restart failed watchers.
The code-review-graph project maintains a persistent SQLite graph database that must remain perfectly synchronized with rapidly evolving source code across multiple repositories. Its daemon mode orchestrates a multi-process architecture that guarantees the on-disk graph reflects the current filesystem state without requiring expensive full rebuilds.
The Daemon Lifecycle: Initialization and Child Process Management
When you execute code-review-graph daemon start, the CLI entry point in code_review_graph/daemon_cli.py instantiates the WatchDaemon class and invokes daemonize() → start() (lines 588-596 in code_review_graph/daemon.py).
The start() method performs three critical operations to establish baseline consistency:
- Repository Registration: Each configured repository is added to the global
Registry(lines 20-25), establishing the authoritative set of watched paths. - Initial Graph Construction: For any repository lacking a
graph.dbfile,_initial_build()(lines 27-31) executes a one-time full parse to create the baseline graph database. - Watcher Spawning:
_start_watcher()(lines 32-40 and 88-96) forks a child process executingcode-review-graph watch --repo <path>for each repository, storing thePopenobjects inself._childrenwith PID tracking.
Child processes inherit the daemon's environment and write logs to ~/.code-review-graph/logs/, immediately beginning filesystem observation.
Per-Repository File Watching and Incremental Updates
Each child process executes the watch() function in code_review_graph/incremental.py (lines 58-71), establishing a watchdog.Observer with a custom GraphUpdateHandler. This handler implements the core consistency mechanism: converting filesystem events into precise graph mutations.
Debouncing Rapid Changes
To avoid redundant parsing during rapid save operations, the handler implements debouncing using threading.Timer with _DEBOUNCE_SECONDS = 0.3 (lines 33-38).
When filesystem events occur (on_modified, on_created), the absolute path is added to a self._pending set, and the timer resets. Only after 300 milliseconds of event inactivity does _flush() trigger, batching rapid changes into a single logical update.
Atomic Graph Mutations
When the debounce timer fires, _flush() iterates pending paths and calls _update_file() (lines 44-51), which performs:
- Parsing: The file is read, hashed, and processed by
CodeParserfor language-specific extraction. - Storage: Nodes and edges are written atomically via
store.store_file_nodes_edges()(lines 70-79). - Metadata Commit: The store's
last_updatedtimestamp refreshes and the transaction commits viastore.commit().
For deletions, the handler immediately invokes store.remove_file_data() and commits (lines 22-30), ensuring the graph never references non-existent files.
Handling Configuration Drift with ConfigWatcher
The daemon monitors ~/.code-review-graph/watch.toml for runtime configuration changes through the ConfigWatcher class (lines 998-1014). When the configuration file changes, _on_config_changed() triggers WatchDaemon.reconcile() (lines 744-751).
The reconcile() method computes the symmetric difference between the current in-memory repository set and the new configuration:
- New repositories undergo registration, initial building if needed, and fresh watcher spawning via
_start_watcher(). - Removed repositories trigger child process termination and removal from the persisted state file via
_save_state().
This ensures the daemon's process tree always matches the user's configuration intent, preventing orphaned watchers or missing repositories.
Self-Healing Through Health Checks
To prevent silent watcher failures from causing graph staleness, the daemon implements a background health checking thread _health_loop() (lines 40-47) that executes every _HEALTH_CHECK_INTERVAL = 30 seconds.
For each known repository alias, the loop checks proc.poll() to detect process termination. Dead processes trigger immediate respawning via _start_watcher() (lines 54-60), followed by _save_state() to update the PID mapping. This guarantees self-healing: even after crashes or out-of-memory kills, the incremental update loop resumes automatically.
Cross-Process State Persistence
The daemon maintains a JSON state file (daemon-state.json) mapping repository aliases to child PIDs and paths via _save_state() (lines 56-62).
When users query status via code-review-graph daemon status, the WatchDaemon.status() method reads this file (falling back to the in-memory _children dict) and verifies process liveness through pid_alive() (with platform-specific implementations in _pid_alive_windows()). This enables reliable external monitoring of graph synchronization health across multiple repositories.
Practical Examples
Launching the Daemon
$ code-review-graph daemon start
# Daemonizes, writes PID to ~/.code-review-graph/daemon.pid
# Spawns a child watcher for each repo in ~/.code-review-graph/watch.toml
Adding a Repository at Runtime
$ code-review-graph daemon add-repo ~/my-project
# Updates watch.toml; ConfigWatcher detects the change,
# reconciles the daemon, and spawns a fresh child watcher.
Manual File Watching (Development Mode)
from pathlib import Path
from code_review_graph.graph import GraphStore
from code_review_graph.incremental import watch
repo = Path("/path/to/repo")
store = GraphStore(incremental_path=repo / ".code-review-graph" / "graph.db")
watch(repo, store) # Blocks until Ctrl-C; performs same incremental updates as daemon mode
Checking Daemon Status Programmatically
from code_review_graph.daemon import WatchDaemon
daemon = WatchDaemon()
status = daemon.status()
print(status)
# {
# "session_name": "crg-watch",
# "running": True,
# "repos": [
# {"alias": "my-project", "path": "/home/user/my-project", "alive": True, "pid": 12345}
# ]
# }
Summary
- Daemon mode in
code_review_graph/daemon.pyorchestrates child watcher processes throughWatchDaemon.start(), ensuring each repository has a dedicated filesystem monitor. - Incremental updates in
code_review_graph/incremental.pyuse 300ms debouncing and atomic SQLite transactions viastore.store_file_nodes_edges()to keep the graph database synchronized with source changes. - Configuration drift is handled by
ConfigWatchermonitoringwatch.toml, triggeringreconcile()to spawn or terminate watchers as repositories are added or removed. - Process resilience is maintained through 30-second health checks in
_health_loop()that automatically restart failed child processes. - State persistence via
daemon-state.jsonenables cross-process status queries and verification that the graph remains up-to-date.
Frequently Asked Questions
How does the daemon handle rapid file saves without redundant parsing?
The file watcher implements debouncing through a threading.Timer set to _DEBOUNCE_SECONDS = 0.3 (300 milliseconds) in code_review_graph/incremental.py (lines 33-38). Each filesystem event resets this timer, and only when the timer expires does _flush() execute the incremental update. This batches rapid save operations into a single graph mutation, preventing redundant parsing while maintaining eventual consistency.
What happens if a repository watcher process crashes?
The daemon's _health_loop() thread runs every 30 seconds (_HEALTH_CHECK_INTERVAL = 30) to check child process health via proc.poll() (lines 40-47 in daemon.py). If a watcher has terminated, the daemon automatically invokes _start_watcher() to respawn the process and updates the state file via _save_state(). This self-healing mechanism ensures that temporary crashes do not result in stale graph data.
How does the daemon detect when repositories are added or removed from the configuration?
The ConfigWatcher class monitors ~/.code-review-graph/watch.toml using watchdog (with a polling fallback) at lines 998-1014 in daemon.py. When the file changes, _on_config_changed() invokes WatchDaemon.reconcile() (lines 744-751), which computes the configuration diff and either spawns new watchers for added repositories or terminates existing processes for removed ones.
Are graph updates atomic during file watching?
Yes. Each incremental update in code_review_graph/incremental.py writes nodes and edges through store.store_file_nodes_edges() followed by store.commit() (lines 70-79). The debouncing mechanism ensures that related changes are batched, and the SQLite transaction guarantees that the graph database transitions directly from one valid state to another without intermediate corruption, even if the update involves multiple dependent files.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →