Skip to content

ref(span-buffer): Introduce multiprocessed flusher - #1

Open
linxia0415 wants to merge 25 commits into
masterfrom
pr-93824
Open

ref(span-buffer): Introduce multiprocessed flusher#1
linxia0415 wants to merge 25 commits into
masterfrom
pr-93824

Conversation

@linxia0415

@linxia0415 linxia0415 commented Jun 4, 2026

Copy link
Copy Markdown

Refs STREAM-266

Compression helped a lot bring the time on the main thread down. We now think we can scale down the consumers. However, we also need to ensure the flusher can keep up. So let's make the flusher spawn a process per redis shard.

We think this is easier to do than making the process_spans/insert_spans multiprocessed. Over time we can offload mroe things like statistics/metrics to the flusher, and keep the main thread minimal.

Summary by CodeRabbit

  • New Features

    • Added --flusher-processes configuration option to control the number of parallel span flusher worker processes for the process-spans consumer.
  • Documentation

    • Updated Python anti-patterns guidance to recommend isinstance() over hasattr() for type checking.
  • Chores

    • Removed Dependabot configuration.

@coderabbitai

coderabbitai Bot commented Jun 4, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR refactors the Span Flusher from managing a single background worker to distributing span flushing across multiple configurable background workers (one per shard-group). A new --flusher-processes CLI option allows operators to set the maximum worker concurrency, and the factory wires this configuration through to the flusher implementation, which now tracks independent health, backpressure, and restart state per worker.

Changes

Multi-Process Span Flusher Architecture

Layer / File(s) Summary
CLI Configuration for Flusher Process Limit
src/sentry/consumers/__init__.py
Add --flusher-processes option (default 1) to the process-spans consumer, allowing operators to configure maximum flusher worker concurrency at startup.
Factory Wiring to Forward Flusher Process Configuration
src/sentry/spans/consumers/process/factory.py
ProcessSpansStrategyFactory accepts flusher_processes parameter, stores it, and forwards it to SpanFlusher as max_processes when constructing the flusher step.
SpanFlusher Multi-Worker Implementation
src/sentry/spans/consumers/process/flusher.py
Refactor from single background worker to multi-worker architecture: build shard-to-process mapping, allocate per-worker buffers and independent health/backpressure/restart state, update worker entrypoint to receive explicit shard lists, manage per-worker health checks and restarts, aggregate backpressure across all workers, and update shutdown to join each background worker independently.
Tests for Multi-Process Flusher Behavior
tests/sentry/spans/consumers/process/test_consumer.py, tests/sentry/spans/consumers/process/test_flusher.py
Update test_basic with transactional DB marker and sleep for flusher completion; add test_flusher_processes_limit to verify process limits, shard distribution, and multi-worker process count; update backpressure test assertion to check per-worker backpressure state.

Documentation Update

Layer / File(s) Summary
Python Anti-Pattern Example Correction
CLAUDE.md
Replace hasattr() example for union types with correct isinstance()-based example that narrows type before calling method.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

A rabbit hops through span-flush streams,
Now many workers chase the gleams,
From one lone thread to processes new,
Shards dance across, so fast so true—
Backpressure flows like rivers combined,
Load-balanced flushing of the kindest mind! 🐰✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.25% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'ref(span-buffer): Introduce multiprocessed flusher' accurately summarizes the main change: introducing multiprocessing to the span flusher component.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pr-93824

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/sentry/spans/consumers/process/flusher.py`:
- Around line 47-66: Validate and reject non-positive max_processes at the start
of the constructor: if max_processes is not None and max_processes <= 0 raise a
ValueError (or similar) before assigning self.max_processes or computing
self.num_processes; leave None handling unchanged so it still defaults to
len(buffer.assigned_shards). Update the logic near the assignment to
self.max_processes and before computing self.num_processes and building
self.process_to_shards_map to ensure only positive integers are accepted.
- Around line 336-347: The shutdown loop in the flusher that iterates over
self.processes may break when remaining_time <= 0 and skip terminating/joining
the current and remaining worker processes; update the loop in the flusher (the
code that checks process.is_alive() and uses multiprocessing.Process) so that if
the deadline has passed you do not simply break out — instead, for the current
and each remaining process call process.terminate() (for multiprocessing.Process
instances) and then attempt a short join (e.g., process.join(0.1)) to avoid
leaking background processes; ensure you still honor the existing wait loop when
time remains and only fall back to terminate/join when time has expired.

In `@tests/sentry/spans/consumers/process/test_consumer.py`:
- Line 15: The global monkeypatch of time.sleep at the top of the test file
makes the post-drift wait a no-op and causes test_basic to race the background
flusher; change the patch so it only overrides sleep where the consumer imports
it (or apply/restore the patch only around the specific sections that need a
no-op). Specifically, remove or replace the top-level
monkeypatch.setattr("time.sleep", ...) and instead monkeypatch the sleep used by
the consumer module (the time.sleep symbol imported/used by the code under test)
or scope the monkeypatch to the exact block in test_basic that must skip
sleeping so the post-drift wait (lines ~60-62 in test_basic) actually executes a
real wait. Ensure you reference the consumer module's time symbol (the import
used by the background flusher) when patching so only the intended sleep is
stubbed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 43881555-0a22-4f77-aac6-1ffb0f6ec467

📥 Commits

Reviewing files that changed from the base of the PR and between 38df614 and 9d6437b.

📒 Files selected for processing (7)
  • .github/dependabot.yml
  • CLAUDE.md
  • src/sentry/consumers/__init__.py
  • src/sentry/spans/consumers/process/factory.py
  • src/sentry/spans/consumers/process/flusher.py
  • tests/sentry/spans/consumers/process/test_consumer.py
  • tests/sentry/spans/consumers/process/test_flusher.py
💤 Files with no reviewable changes (1)
  • .github/dependabot.yml

Comment on lines +47 to +66
max_processes: int | None = None,
produce_to_pipe: Callable[[KafkaPayload], None] | None = None,
):
self.buffer = buffer
self.next_step = next_step
self.max_processes = max_processes or len(buffer.assigned_shards)

self.mp_context = mp_context = multiprocessing.get_context("spawn")
self.stopped = mp_context.Value("i", 0)
self.redis_was_full = False
self.current_drift = mp_context.Value("i", 0)
self.backpressure_since = mp_context.Value("i", 0)
self.healthy_since = mp_context.Value("i", 0)
self.process_restarts = 0
self.produce_to_pipe = produce_to_pipe

self._create_process()

def _create_process(self):
# Determine which shards get their own processes vs shared processes
self.num_processes = min(self.max_processes, len(buffer.assigned_shards))
self.process_to_shards_map: dict[int, list[int]] = {
i: [] for i in range(self.num_processes)
}
for i, shard in enumerate(buffer.assigned_shards):
process_index = i % self.num_processes
self.process_to_shards_map[process_index].append(shard)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject non-positive max_processes up front.

0 is silently converted to “all shards” by Line 51, and negative values make Lines 60-66 build an invalid process map before failing at runtime. Guard this before deriving self.num_processes.

Suggested fix
     def __init__(
         self,
         buffer: SpansBuffer,
         next_step: ProcessingStrategy[FilteredPayload | int],
         max_processes: int | None = None,
         produce_to_pipe: Callable[[KafkaPayload], None] | None = None,
     ):
         self.next_step = next_step
+        if max_processes is not None and max_processes < 1:
+            raise ValueError("max_processes must be >= 1")
         self.max_processes = max_processes or len(buffer.assigned_shards)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/sentry/spans/consumers/process/flusher.py` around lines 47 - 66, Validate
and reject non-positive max_processes at the start of the constructor: if
max_processes is not None and max_processes <= 0 raise a ValueError (or similar)
before assigning self.max_processes or computing self.num_processes; leave None
handling unchanged so it still defaults to len(buffer.assigned_shards). Update
the logic near the assignment to self.max_processes and before computing
self.num_processes and building self.process_to_shards_map to ensure only
positive integers are accepted.

Comment on lines +336 to +347
# Wait for all processes to finish
for process_index, process in self.processes.items():
if deadline is not None:
remaining_time = deadline - time.time()
if remaining_time <= 0:
break

while process.is_alive() and (deadline is None or deadline > time.time()):
time.sleep(0.1)

if isinstance(self.process, multiprocessing.Process):
self.process.terminate()
if isinstance(process, multiprocessing.Process):
process.terminate()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Timed joins can return with flusher workers still running.

When remaining_time <= 0, the loop breaks before terminating the current or remaining workers. join(timeout=...) can therefore leak background processes past shutdown.

Suggested fix
-        for process_index, process in self.processes.items():
-            if deadline is not None:
-                remaining_time = deadline - time.time()
-                if remaining_time <= 0:
-                    break
-
-            while process.is_alive() and (deadline is None or deadline > time.time()):
-                time.sleep(0.1)
-
-            if isinstance(process, multiprocessing.Process):
-                process.terminate()
+        timed_out = False
+        for process in self.processes.values():
+            while process.is_alive():
+                if deadline is not None and deadline <= time.time():
+                    timed_out = True
+                    break
+                time.sleep(0.1)
+
+        if timed_out:
+            for process in self.processes.values():
+                if isinstance(process, multiprocessing.Process) and process.is_alive():
+                    process.terminate()
🧰 Tools
🪛 Ruff (0.15.15)

[warning] 337-337: Loop control variable process_index not used within loop body

Rename unused process_index to _process_index

(B007)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/sentry/spans/consumers/process/flusher.py` around lines 336 - 347, The
shutdown loop in the flusher that iterates over self.processes may break when
remaining_time <= 0 and skip terminating/joining the current and remaining
worker processes; update the loop in the flusher (the code that checks
process.is_alive() and uses multiprocessing.Process) so that if the deadline has
passed you do not simply break out — instead, for the current and each remaining
process call process.terminate() (for multiprocessing.Process instances) and
then attempt a short join (e.g., process.join(0.1)) to avoid leaking background
processes; ensure you still honor the existing wait loop when time remains and
only fall back to terminate/join when time has expired.

@pytest.mark.django_db(transaction=True)
def test_basic(monkeypatch):
# Flush very aggressively to make test pass instantly
monkeypatch.setattr("time.sleep", lambda _: None)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

The post-drift wait is currently a no-op.

Line 15 monkeypatches time.sleep globally, so Line 62 never actually waits. This keeps test_basic racing the background flusher.

Suggested fix
 `@pytest.mark.django_db`(transaction=True)
 def test_basic(monkeypatch):
+    real_sleep = time.sleep
     # Flush very aggressively to make test pass instantly
     monkeypatch.setattr("time.sleep", lambda _: None)
@@
     step.poll()
     # Give flusher threads time to process after drift change
-    time.sleep(0.1)
+    real_sleep(0.1)

Also applies to: 60-62

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/sentry/spans/consumers/process/test_consumer.py` at line 15, The global
monkeypatch of time.sleep at the top of the test file makes the post-drift wait
a no-op and causes test_basic to race the background flusher; change the patch
so it only overrides sleep where the consumer imports it (or apply/restore the
patch only around the specific sections that need a no-op). Specifically, remove
or replace the top-level monkeypatch.setattr("time.sleep", ...) and instead
monkeypatch the sleep used by the consumer module (the time.sleep symbol
imported/used by the code under test) or scope the monkeypatch to the exact
block in test_basic that must skip sleeping so the post-drift wait (lines ~60-62
in test_basic) actually executes a real wait. Ensure you reference the consumer
module's time symbol (the import used by the background flusher) when patching
so only the intended sleep is stubbed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants