How Realtime Updater Monitors File System Changes in code-graph-rag: A Deep Dive into watchdog-Based Event Processing
The realtime_updater uses the Python watchdog library with a hybrid debounce pipeline to guarantee one consistent graph update per logical file edit, preventing redundant recompilations during rapid save cycles.
The realtime_updater component in the vitali87/code-graph-rag repository provides continuous, low-latency monitoring of source code changes. This article examines how it leverages the watchdog library, implements intelligent debouncing, and maintains thread safety while synchronizing the Memgraph knowledge graph with filesystem state.
Core Architecture: Watchdog Observer Pattern
The monitoring system follows a classic observer pattern built on watchdog, Python's cross-platform filesystem event API. The entry point main() in realtime_updater.py parses CLI arguments and delegates to start_watcher() (lines 33-40), which initializes the full pipeline.
# From realtime_updater.py#L33-L40
def start_watcher(repo_path, debounce_seconds, max_wait_seconds, ignore_patterns, ignore_suffixes):
ingestor = create_memgraph_ingestor()
parsers = get_language_parsers()
_run_watcher_loop(repo_path, ingestor, parsers, debounce_seconds,
max_wait_seconds, ignore_patterns, ignore_suffixes)
The _run_watcher_loop() function (lines 70-76) performs three critical setup steps:
- Constructs a
GraphUpdater— the core engine for incremental graph mutations - Runs an initial full-repo scan via
updater.run()to establish baseline state - Registers a
CodeChangeEventHandlerwith a recursivewatchdog.Observer
# From realtime_updater.py#L70-L76
def _run_watcher_loop(...):
updater = GraphUpdater(ingestor, repo_path_obj, parsers, queries)
updater.run() # Full initial scan
event_handler = CodeChangeEventHandler(
updater, debounce_seconds, max_wait_seconds,
ignore_patterns, ignore_suffixes
)
observer = Observer()
observer.schedule(event_handler, str(repo_path_obj), recursive=True)
observer.start()
The recursive=True parameter ensures the observer monitors all subdirectories automatically.
Event Handling and Relevance Filtering
The CodeChangeEventHandler class (lines 62-124) inherits from watchdog's base handler and overrides dispatch() to intercept all FileSystemEvent objects.
Filtering Irrelevant Files
Before any processing occurs, _is_relevant() (lines 15-20) applies exclusion rules:
# From realtime_updater.py#L15-L20
def _is_relevant(self, path):
if any(path.endswith(suffix) for suffix in self.ignore_suffixes):
return False
if any(pattern in path for pattern in self.ignore_patterns):
return False
return True
These patterns originate from codebase_rag/constants.py, keeping temporary files, build artifacts, and version control metadata out of the graph update pipeline.
Hybrid Debounce Strategy for Rapid Edits
The most sophisticated aspect of realtime_updater monitoring is its debounce implementation, which solves the "save storm" problem caused by editors writing files multiple times during auto-save operations.
Debounce State Machine
When self.debounce_enabled is true, the handler tracks two timestamps:
self.first_event_time— when the first unprocessed event arrivedself.last_event_time— most recent trigger for the same path
The _handle_event_start() method (lines 70-88) implements adaptive scheduling:
# Conceptual flow from realtime_updater.py#L70-L88
elapsed = current_time - self.first_event_time[path]
remaining_max_wait = self.max_wait_seconds - elapsed
if remaining_max_wait <= 0:
# Force processing to guarantee eventual consistency
self._schedule_immediate_processing(path)
else:
# Schedule for the shorter of debounce interval or remaining max-wait
delay = min(self.debounce_seconds, remaining_max_wait)
self._timer_factory(delay, lambda: self._process_debounced_change(path))
This hybrid approach provides two guarantees:
- Responsiveness: Changes appear in the graph within
max_wait_seconds(default 30s) even during continuous editing - Efficiency: Rapid edits within
debounce_seconds(default 5s) collapse to a single graph rebuild
Processing the Debounced Event
When the timer fires, _process_debounced_change() (lines 9-22) clears the pending state and forwards to _process_change():
# From realtime_updater.py#L9-L22
def _process_debounced_change(self, path):
with self.lock:
event = self.pending_events.pop(path, None)
self.timers.pop(path, None)
self.first_event_time.pop(path, None)
if event:
self._process_change(event)
Five-Stage Graph Update Pipeline
The _process_change_locked() method (lines 24-33) executes a transaction that keeps the Memgraph database consistent with filesystem state:
| Stage | Operation | Source Location |
|---|---|---|
| 1. Delete old entities | Execute CYPHER_DELETE_MODULE and CYPHER_DELETE_FILE to remove stale nodes |
realtime_updater.py#L56-71 |
| 2. Invalidate caches | Call self.updater.remove_file_from_state(path) to purge in-memory parser caches |
realtime_updater.py#L74-76 |
| 3. Re-parse and ingest | Invoke definition_processor.process_file() and structure_processor.process_generic_file() for new content |
realtime_updater.py#L89-108 |
| 4. Rebuild call graph | Reset resolution caches, execute CYPHER_DELETE_CALLS, then re-resolve all function references |
realtime_updater.py#L121-127 |
| 5. Atomic commit | self.updater.ingestor.flush_all() persists batched Cypher queries to Memgraph |
realtime_updater.py#L128-130 |
This complete rebuild per file ensures referential integrity: deleting and recreating a module guarantees no orphaned edges reference stale AST node IDs.
Thread Safety Mechanisms
Concurrent filesystem events and timer callbacks require careful synchronization. The handler employs two locks:
self.lock— Protects the debounce timer map, pending events dictionary, and timestamp trackingself._update_lock— Ensures only one thread executes the full five-stage graph transaction at a time (lines 99-105)
# From realtime_updater.py#L99-L105
def _process_change_locked(self, event):
with self._update_lock:
# All five stages execute atomically
...
This prevents race conditions where simultaneous edits to interdependent files could create inconsistent graph states.
Graceful Shutdown Handling
The main loop captures KeyboardInterrupt to ensure clean resource release:
# From realtime_updater.py#L87-L92
try:
while True:
time.sleep(WATCHER_SLEEP_INTERVAL)
except KeyboardInterrupt:
observer.stop()
observer.join()
The observer.join() call blocks until the watchdog background thread terminates, guaranteeing in-flight graph updates complete before exit.
Command-Line and Programmatic Usage
Running the Realtime Updater
# Default configuration: 5s debounce, 30s max-wait
python realtime_updater.py /path/to/repo
# Faster feedback for demonstrations
python realtime_updater.py /path/to/repo --debounce 2 --max-wait 10
# Disable debouncing (process every event immediately)
python realtime_updater.py /path/to/repo --debounce 0
Integration Testing
from realtime_updater import CodeChangeEventHandler
from watchdog.events import FileModifiedEvent
handler = CodeChangeEventHandler(
updater=mock_graph_updater,
debounce_seconds=0.1,
max_wait_seconds=1
)
# Simulate rapid file modification
event = FileModifiedEvent(src_path="/tmp/repo/src/main.py")
handler.dispatch(event) # Debounce timer starts
Key Source Files Reference
| File | Purpose |
|---|---|
[realtime_updater.py](https://github.com/vitali87/code-graph-rag/blob/main/realtime_updater.py) |
Core implementation: CodeChangeEventHandler, debounce logic, main() entry point |
[codebase_rag/graph_updater.py](https://github.com/vitali87/code-graph-rag/blob/main/codebase_rag/graph_updater.py) |
GraphUpdater class: parsers, caches, Memgraph ingestor coordination |
[codebase_rag/constants.py](https://github.com/vitali87/code-graph-rag/blob/main/codebase_rag/constants.py) |
DEFAULT_DEBOUNCE_SECONDS, IGNORE_PATTERNS, Cypher query templates |
[codebase_rag/config.py](https://github.com/vitali87/code-graph-rag/blob/main/codebase_rag/config.py) |
Runtime configuration and credential management |
Summary
- Watchdog foundation: Recursive
Observermonitors all repository subdirectories viaCodeChangeEventHandler.dispatch() - Intelligent debouncing: Hybrid timer strategy collapses rapid edits into single updates while respecting
max_wait_secondsdeadlines - Transaction isolation: Five-stage rebuild pipeline (delete → cache purge → re-parse → call graph rebuild → flush) maintains graph consistency
- Thread safety: Dual-lock architecture separates debounce state management from graph mutation critical sections
- Operational flexibility: CLI arguments control debounce timing; zero-debounce mode available for legacy behavior
Frequently Asked Questions
What happens if I save a file multiple times in rapid succession?
The debounce mechanism captures the first event and starts a timer. Subsequent saves reset last_event_time but do not create new timers. When the debounce interval expires, only the final file state is processed, eliminating redundant graph rebuilds.
Can I disable debouncing for immediate updates?
Yes. Pass --debounce 0 when launching realtime_updater.py. This causes _schedule_immediate_processing() to trigger for every filesystem event, suitable for debugging or low-frequency change environments.
Why does the updater delete and recreate nodes instead of patching them?
AST node IDs are content-dependent. A modified file may produce entirely different node structures with new relationships. The delete-then-recreate approach (stage 1 of _process_change_locked) guarantees no orphaned edges or stale references remain in Memgraph.
How does the system handle nested directory creation?
The recursive=True parameter in observer.schedule() ensures watchdog monitors all subdirectories. New directories created after startup are not automatically added; restart the updater to pick up structural changes to the repository tree.
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 →