feat(search_issues): rewrite the search issues consumer in Rust - #8216
feat(search_issues): rewrite the search issues consumer in Rust#8216phacops wants to merge 5 commits into
Conversation
Reimplement `SearchIssuesMessageProcessor` as a native Rust processor (`rust_snuba/src/processors/search_issues.rs`) so the search issues / issue-occurrence consumer runs in Rust instead of falling back to the Python processor over the multiprocessing bridge. The new processor mirrors the Python implementation exactly: - Parses the `[version, "insert", event, group_state?]` payload, ignoring the trailing post-process group state. - Promotes environment/release/dist/user tags, supports tags supplied either as an object or as a list of pairs, and sorts them like `_as_dict_safe` + `sorted(...)`. - Preserves payload key order for `contexts.key` / `contexts.value` (Python does not sort contexts) using order-preserving deserialization, since serde_json's `Map` is a `BTreeMap` without `preserve_order`. - Promotes `trace_id` / `profile_id` / `replay_id` from contexts and coerces them to UUIDs, extracts user/ip, sdk, http referer, and transaction duration, and derives `client_timestamp` / `timestamp_ms` from either `data.client_timestamp` or the `datetime` field. The processor is registered in `processors/mod.rs` against the `generic-events` topic, and the Python `SearchIssuesMessageProcessor` becomes a thin `RustCompatProcessor` shim (the `SearchIssueEvent` TypedDicts are retained for other importers). Testing: - 25 Rust unit tests ported from the Python processor tests. - Snapshot tests over the two `generic-events` schema examples. - The Python processor tests now exercise the Rust implementation through the compat shim. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WaxqQJj1RHRonojiRC3puS
| (Some(start), Some(finish)) => { | ||
| let start_secs = extract_valid_timestamp(start); | ||
| let finish_secs = extract_valid_timestamp(finish); | ||
| ((finish_secs - start_secs) * 1000).max(0) as u32 |
There was a problem hiding this comment.
Bug: The calculation for transaction_duration can overflow when casting to u32. A large difference between start_timestamp and timestamp will cause the value to wrap, resulting in an incorrect duration.
Severity: MEDIUM
Suggested Fix
Instead of casting directly with as u32, which truncates on overflow, the value should be clamped to the maximum value of u32. Use a saturating cast or clamp(0, u32::MAX as i64) as u32 to prevent the overflow and ensure the duration is capped at u32::MAX if it's too large.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.
Location: rust_snuba/src/processors/search_issues.rs#L549
Potential issue: The calculation `((finish_secs - start_secs) * 1000).max(0) as u32` is
used to determine the transaction duration in milliseconds. If the time difference
between `finish_secs` and `start_secs` exceeds approximately 49.7 days, the result of
the multiplication will be larger than `u32::MAX`. The subsequent cast `as u32` will
then silently truncate the value, causing it to wrap around and produce an incorrect
duration. This is a realistic scenario for events with a very old `start_timestamp` and
a recent `timestamp`.
Did we get this right? 👍 / 👎 to inform future reviews.
|
|
||
| let detection_timestamp = seconds_from_timestamp(occ.detection_time); | ||
| let receive_timestamp = seconds_from_timestamp(data.received); | ||
| let retention_days = enforce_retention(event.retention_days, env_config); |
There was a problem hiding this comment.
Old events no longer discarded
Medium Severity
The Python processor called enforce_retention with detection_timestamp and treated EventTooOld as a silent skip (return None). With DISCARD_OLD_EVENTS enabled by default, occurrences older than their retention window were never written. The Rust path only normalizes retention_days and always inserts, so delayed or replayed old issue occurrences that used to be dropped will now land in ClickHouse.
Reviewed by Cursor Bugbot for commit 9eefded. Configure here.
There was a problem hiding this comment.
@phacops this seems valid but not sure what we actually do for errors, if they are old enough i guess they will just get immediately TTL'd
There was a problem hiding this comment.
The observation is accurate, and it's consistent with the other Rust-migrated processors. None of them port the Python EventTooOld / DISCARD_OLD_EVENTS skip — the shared enforce_retention in rust_snuba only normalizes retention_days and always inserts. So errors (and the rest) behave the same way today: an old occurrence is written and then aged out by ClickHouse TTL via retention_days + the partition TTL, rather than dropped at ingest — which matches your intuition about it getting immediately TTL'd.
I left the behavior as-is to stay consistent with the fleet, but happy to add an explicit age check here if we'd prefer search_issues to drop at ingest instead.
Generated by Claude Code
…atic - Fix a potential u32 wraparound in transaction_duration by clamping the millisecond result into the u32 range (flagged in review). - Guarantee the processor cannot panic: all float->int conversions are saturating, integer casts operate on validated/clamped ranges, map lookups use get(), and list pairs use slice patterns instead of indexing. - Drop the Python-emulation helpers (utcfromtimestamp microsecond rounding, the _ensure_valid_date "now" fallback) in favor of direct, idiomatic timestamp math, and stringify JSON values via Display (booleans now render as "true"/"false", matching the errors processor). - Remove redundant pytest.raises(Exception) assertions from the Python tests (fixes ruff B017); the error paths are covered by Rust unit tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WaxqQJj1RHRonojiRC3puS
Two behaviors from the previous idiomatic pass changed the on-disk format versus the Python processor, which would split existing data: - Booleans in tags/contexts are stringified as "True"/"False" (matching Python's str(bool)) instead of "true"/"false", so tag/context filters keep matching rows written before the cutover. - transaction_duration truncates start/timestamp to whole seconds before computing the difference, matching the Python int() conversion (a 0.5s span is 0ms, not 500ms). The code stays idiomatic; only the emitted values are restored. Added tests covering both. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WaxqQJj1RHRonojiRC3puS
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 96cfce4. Configure here.
In the data.client_timestamp path the seconds column was truncated while the millisecond column was rounded, so near a whole-second boundary timestamp_ms // 1000 could land one second ahead of client_timestamp. Derive client_timestamp from the rounded timestamp_ms so the two columns are aligned by construction (matching the old datetime-based path). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WaxqQJj1RHRonojiRC3puS
| let naive = NaiveDateTime::parse_from_str(&raw, PAYLOAD_DATETIME_FORMAT) | ||
| .with_context(|| format!("group_first_seen has incompatible format: {raw}"))?; |
There was a problem hiding this comment.
Bug: Messages with a malformed group_first_seen datetime string are rejected, which is a stricter behavior than the previous Python implementation that likely ignored such values.
Severity: MEDIUM
Suggested Fix
To align with the likely previous behavior and prevent message rejection, wrap the date parsing logic in a match or use .ok() to convert parsing errors into None. This would gracefully handle malformed date strings instead of failing the entire message. Additionally, add a test case with an invalid group_first_seen value to verify the intended error-handling behavior.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.
Location: rust_snuba/src/processors/search_issues.rs#L412-L413
Potential issue: The Rust implementation for processing `group_first_seen` uses strict
datetime parsing. If the `group_first_seen` field is present but contains a string that
does not match the expected format (`%Y-%m-%dT%H:%M:%S%.fZ`), the `?` operator will
propagate the parsing error, causing the entire message to be rejected and potentially
sent to the dead-letter queue. The previous Python implementation likely handled such
malformed dates more gracefully, possibly by treating them as null. This discrepancy
introduces a risk of message processing failures for data that was previously accepted.
There was a problem hiding this comment.
This is actually parity-preserving, not stricter. The Python processor parsed group_first_seen with datetime.strptime(..., PAYLOAD_DATETIME_FORMAT), which raises ValueError on a malformed string. process_message re-raises ValueError (it's only caught to bump a metric), so a malformed group_first_seen was rejected/DLQ'd there too — it was never treated as null. Only an out-of-range-but-well-formed date became null (via _ensure_valid_date), and the Rust code mirrors that: a parse error bails, while an out-of-uint32-range timestamp yields None. So the reject-vs-null split matches the previous behavior.
(In practice group_first_seen is producer-generated via strftime, so a malformed value shouldn't occur in either implementation.)
Generated by Claude Code
The boundary test literal tripped clippy::inconsistent_digit_grouping under the CI lint (cargo clippy --all-targets -D warnings). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WaxqQJj1RHRonojiRC3puS


Reimplements
SearchIssuesMessageProcessoras a native Rust processor so the search issues / issue-occurrence consumer (snuba rust-consumer --storage=search_issues) runs in Rust instead of falling back to the Python processor over the multiprocessing bridge.What changed
rust_snuba/src/processors/search_issues.rs, registered inprocessors/mod.rsagainst thegeneric-eventstopic. Once registered,factory_v2.rsroutes the consumer to the Rust processor natively (previously it fell through toPythonTransformStep).SearchIssuesMessageProcessorbecomes a thinRustCompatProcessorshim, matching the pattern used byErrorsProcessor,ReplaysProcessor,OutcomesProcessor, etc. TheSearchIssueEvent/IssueEventData/IssueOccurrenceDataTypedDicts are retained since other modules import them.tests/datasets/test_search_issues_processor.pynow exercise the Rust implementation through the compat shim (timestamp columns are integers, invalid messages raise a generic exception).Parity notes
The Rust processor mirrors the Python one exactly:
[version, "insert", event, group_state?]payload, ignoring the trailing post-process group state.environment/sentry:release/sentry:dist/sentry:usertags; acceptstagsand requestheadersas either an object or a list of pairs (_as_dict_safe); sorts tags by key.contexts.key/contexts.value(the Python processor does not sort contexts) via order-preserving deserialization — necessary because serde_json'sMapis aBTreeMapwithout thepreserve_orderfeature.trace_id/profile_id/replay_idfrom contexts to UUID columns (erroring on invalid UUIDs); extracts user id/name/email + IPv4/IPv6, sdk name/version, http method/referer, and transaction duration.client_timestamp(second precision) andtimestamp_ms(millisecond precision) fromdata.client_timestampwhen present, otherwise from thedatetimefield.Testing
cargo test --lib search_issues).generic-eventsschema examples (test_schemas), reviewed for field-level parity with the Python output.cargo clippyandrustfmtclean.Legal Boilerplate
Look, I get it. The entity doing business as "Sentry" was incorporated in the State of Delaware in 2015 as Functional Software, Inc. and is gonna need some rights from me in order to utilize my contributions in this here PR. So here's the deal: I retain all rights, title and interest in and to my contributions, and by keeping this boilerplate intact I confirm that Sentry can use, modify, copy, and redistribute my contributions, under Sentry's choice of terms.
🤖 Generated with Claude Code
https://claude.ai/code/session_01WaxqQJj1RHRonojiRC3puS
Generated by Claude Code