feat(uptime): Add ability to use queues to manage parallelism

getsentry/sentry#95633
Golden recall: 66.7% Extra findings: 9

Golden Comments (2/3 found)

# Comment Severity Status FriendlyReviewer Detail
#1 queue.shutdown() with immediate=False parameter may not exist in the standard Python queue module. This could cause AttributeError at runtime. Verify the correct API or implement a custom shutdown mechanism HIGH ✓ Found "FixedQueuePool.shutdown sets worker.shutdown = True then calls q.shutdown(immediate=False), which leaves remaining queued items unprocessed and never complete_offset-ed — silently re-delivered on restart; the pool is also shut down from two call sites (strategy.close() and factory.shutdown()), causing a double Queue.shutdown()" (queue_consumer.py L225). Judge's nuance: FR criticizes the exact call at the right place, but describes it as if it worked — the golden's precise diagnosis (the method does not exist in standard queue.QueueAttributeError) is not formulated; counted found on the shared call site
#2 The test test_thread_queue_parallel_error_handling has a docstring that doesn't match the test implementation LOW ✓ Found "test_thread_queue_parallel_error_handling asserts that a message whose processing raised is not committed, but the implementation commits the failed offset anyway. The test only passes when the commit thread hasn't fired within its 0.2s observation window, making it flaky and encoding the opposite of the actual behavior" (test_results_consumer.py L1874). Judge: FR identifies the same test's inconsistency without naming the docstring — same essence as the golden
#3 The magic number 50 for max_wait is used repeatedly throughout the tests. Consider extracting this as a named constant to improve maintainability LOW ✗ Not found No candidate mentions the magic constant 50 — FR prioritized correctness bugs (offsets committed on error, unbounded queues, shared-pool race). Localisation: test_results_consumer.py:1718 (max_wait = 50, repeated at lines 1765 and 1812).

Supplementary Findings (9 findings)

Finding File Severity Legitimate?
Exceptions from result_processor swallowed; the finally block commits offsets for failed messages (silent data loss) queue_consumer.py:149 HIGH ✓ Real contract violation — arguably golden-worthy
queue.Queue() created with no maxsize → no backpressure, memory can grow without limit queue_consumer.py:182 HIGH ✓ Contradicts the docstring's backpressure claim
Shared FixedQueuePool destroyed from multiple call sites (double-shutdown race; queue_pool = None breaks subsequent create_with_partitions) result_consumer.py:242 HIGH ✓ Real lifecycle race with an assert crash
hash(group_key) % num_queues relies on randomized per-process str hash → mapping not deterministic across restarts queue_consumer.py:199 MEDIUM ✓ Real; FR flags the same pattern elsewhere (error_upsampling cache keys)
Unit tests pass the raw commit_function, bypassing the production +1 offset wrapper — they validate a different contract test_queue_consumer.py:285 LOW ✓ Real test-fidelity issue
send_result always uses the same partition/offset, so parallel tests only exercise the worker FIFO path test_results_consumer.py:116 LOW ✓ Legitimate coverage observation
range(start, max_offset + 1) walks every integer offset (e.g. 100 to 100_000) on every commit tick while holding the partition lock queue_consumer.py:89 LOW ✓ Real performance concern for sparse offsets
uptime_options() always includes --max-batch-size/--max-batch-time, silently ignored in thread-queue-parallel mode consumers/__init__.py:121 LOW ✓ Legitimate CLI documentation concern
--max-workers help text doesn't convey that it selects the number of queues in queue mode (default 20) consumers/__init__.py:141 LOW ✓ Legitimate CLI contract gap, cosmetic

Honest Analysis

Recall is 2/3 (66.7%), and both hits are genuine — with one important nuance on golden #1. FR criticizes the exact q.shutdown(immediate=False) call at the right location (queue_consumer.py L225) — items left unprocessed and never complete_offset-ed, plus a double shutdown from two call sites — but describes the behavior as if the call worked. The golden's diagnosis is different: queue.Queue in the standard library has no shutdown method at all, so this raises AttributeError. The golden is counted found on the strength of the shared call site; that is generous, and the golden may itself be misaligned if the fork's queue implementation does have shutdown.

Strengths: the review's three high findings are correctness bugs that would be goldens in a better-curated dataset — offsets committed for failed messages (silent data loss, L149), unbounded queues contradicting the claimed backpressure (L182), and the shared FixedQueuePool double-shutdown race (result_consumer.py L242). The second hit (the error-handling test at L1874) matches golden #2's essence: the test encodes the opposite of the actual behavior, though FR frames it as a flaky test rather than a docstring mismatch.

Weaknesses: the miss — the repeated magic number 50 for max_wait — is a maintainability nit, and missing it is defensible: FR prioritized real correctness bugs over it. The nuance on golden #1 cuts the other way: on the highest-severity golden, FR's diagnosis was a different bug on the same line, and FR's own severity for that finding (medium) sits below the dataset's HIGH. This page's score is thus both generous (golden #1 counted) and earned (two real catches).

All 9 supplementary findings are legitimate. The hash() randomization finding (queue_consumer.py L199) is a notable catch: group→queue assignment relies on Python's randomized str hash, so the mapping is not deterministic across restarts despite the pool's 'consistent hashing' claim. The weakest supplementary items are the CLI help-text gaps (consumers/__init__.py L121/L141): real but cosmetic.

FriendlyReviewer's full review

As posted on the PR — the complete output from FriendlyReviewer.

friendly-reviewer bot commented now

Hi there 👋

🌥️ Tech 🌤️ Feat
3 high, 3 medium, 5 low 1 mitigated

This MR implements a queue-based parallelism strategy (SimpleQueueProcessingStrategy) that replaces batched processing with a fixed number of per-group FIFO queues and a background contiguous-offset commit loop, matching the overall intent of the PR (isolating slow items so they don't clog the entire batch). However, the implementation has several blocking correctness and lifecycle issues: messages that raise during processing are still acknowledged (offset committed), leading to silent data loss; the queues are unbounded, so the claimed backpressure does not exist and memory can grow without limit; the shared FixedQueuePool is torn down from multiple call sites, causing a race and double shutdown; and group→queue assignment uses Python's randomized hash(), so it is not deterministic across restarts. Several tests are flaky or validate a different contract than production (e.g., the +1 offset wrapper is bypassed in unit tests). While the design direction is sound, these defects must be resolved before the MR is ready to merge.


These might need a close look

  • 🔴 src/sentry/remote_subscriptions/consumers/queue_consumer.py (L149)
    In OrderedQueueWorker.run, exceptions from result_processor are swallowed and the finally block unconditionally calls complete_offset, committing offsets for failed messages. This violates the documented 'Only commits offsets after successful processing' contract and causes silent data loss (a failed subscription update is acknowledged). It also contradicts the error-handling test which expects the failed partition not to be committed.
  • 🔴 src/sentry/remote_subscriptions/consumers/queue_consumer.py (L182)
    queue.Queue() is created with no maxsize, so it is unbounded and work_queue.put() never blocks. The docstring claims 'Natural backpressure when queues fill up', but no backpressure exists; the consumer can drain the whole topic into memory whenever workers are slower than the input. Bound the queues (e.g. maxsize=...) and block in submit to apply real backpressure.
  • 🔴 src/sentry/remote_subscriptions/consumers/result_consumer.py (L242)
    FixedQueuePool is created once in ResultsStrategyFactory.__init__ and shared across every strategy instance, but each SimpleQueueProcessingStrategy starts its own commit thread over the same OffsetTracker, and close()/terminate() calls queue_pool.shutdown() which destroys workers still needed by other strategies. Also, ResultsStrategyFactory.shutdown() sets queue_pool = None, so a subsequent create_with_partitions hits the assert self.queue_pool is not None and raises. Tie the commit loop to the pool (single commit thread per pool) and coordinate teardown to avoid races and double-commit scenarios.

Worth checking

  • 🟡 src/sentry/remote_subscriptions/consumers/queue_consumer.py (L199)
    hash(group_key) % self.num_queues relies on Python's built-in str hash, which is randomized per interpreter run (PYTHONHASHSEED). The FixedQueuePool docstring calls this 'consistent hashing', but the mapping is not deterministic across processes/restarts. Use a stable hash such as int(hashlib.md5(group_key.encode()).hexdigest(), 16) % num_queues or zlib.crc32.
  • 🟡 src/sentry/remote_subscriptions/consumers/queue_consumer.py (L225)
    FixedQueuePool.shutdown sets worker.shutdown = True then calls q.shutdown(immediate=False), which leaves remaining queued items unprocessed and never complete_offset-ed — these are silently re-delivered on restart. The pool is also shut down from two call sites (strategy.close() and factory.shutdown()), causing a double Queue.shutdown(). If graceful semantics are intended, consolidate shutdown to a single owner and drain workers before terminating.
  • 🟡 tests/sentry/uptime/consumers/test_results_consumer.py (L1874)
    test_thread_queue_parallel_error_handling asserts that a message whose processing raised is not committed, but the implementation commits the failed offset anyway. The test only passes when the commit thread hasn't fired within its 0.2s observation window, making it flaky and encoding the opposite of the actual behavior. Rewrite after fixing the error-handling semantics to be deterministic.
Small things (take or leave)
  • 🔵 tests/sentry/remote_subscriptions/consumers/test_queue_consumer.py (L285)
    TestSimpleQueueProcessingStrategy passes its raw commit_function directly into the strategy, bypassing the production +1 offset wrapper (commit_offsets). The unit tests therefore assert the highest processed offset (104/100/103) while production commits 105/101/104, so they validate a different contract. Apply the same +1 in the unit commit function or add an assertion that exercises the production wrapper.
  • 🔵 tests/sentry/uptime/consumers/test_results_consumer.py (L116)
    send_result always uses Partition(Topic("test"), 1) and offset=1, so test_thread_queue_parallel, test_thread_queue_parallel_preserves_order, and test_thread_queue_parallel_concurrent_subscriptions all feed identical partition/offset, meaning they only exercise the worker FIFO path and never verify commit behavior or multi-offset contiguity. Consider allowing an explicit offset to widen coverage.
  • 🔵 src/sentry/remote_subscriptions/consumers/queue_consumer.py (L89)
    for offset in range(start, max_offset + 1) iterates over every integer between the last committed offset and the max seen offset, potentially walking huge spans (e.g. offsets 100 and 100_000) on every commit tick while holding the partition lock. Consider walking the known offsets set (sorted) instead of a numeric range, and/or capping the scan.
  • 🔵 src/sentry/consumers/__init__.py (L121)
    uptime_options() always includes --max-batch-size and --max-batch-time, which are silently ignored in the new thread-queue-parallel mode since SimpleQueueProcessingStrategy does not use batching. Operators may believe these settings still apply. Document that they are ignored in queue mode or gate/hide them when that mode is selected.
  • 🔵 src/sentry/consumers/__init__.py (L141)
    --max-workers defaults to None, and in thread-queue-parallel mode the factory translates this to num_queues=max_workers or 20. The new help text does not convey that the option selects the number of queues in queue mode or that omitting it yields 20, so the CLI contract and runtime default disagree. Include the default (e.g. 'defaults to 20 queues') in the help text.

✅ Feature-level checklist

Partially covered

  • ⚠️ Implement a queueing method instead of batch processing, with N queues each having their own workers, to prevent any one slow item from clogging the whole batch and use increased concurrency to reduce the chance of backlog.
    The MR introduces a queue-based strategy with per-group FIFO queues and one worker per queue, which partially fulfills the goal. However, it is not fully compliant: the queues are unbounded (no real backpressure, risking unbounded memory growth), failed messages are acknowledged (offset committed) causing silent data loss, and the shared pool lifecycle race/double shutdown can drop work or crash during rebalances. These issues undermine the intended reliability and parallelism benefits.