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:

  1. Constructs a GraphUpdater — the core engine for incremental graph mutations
  2. Runs an initial full-repo scan via updater.run() to establish baseline state
  3. Registers a CodeChangeEventHandler with a recursive watchdog.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 arrived
  • self.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 tracking
  • self._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 Observer monitors all repository subdirectories via CodeChangeEventHandler.dispatch()
  • Intelligent debouncing: Hybrid timer strategy collapses rapid edits into single updates while respecting max_wait_seconds deadlines
  • 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →