Skip to content

Conversation

@onewland
Copy link
Contributor

@onewland onewland commented Nov 14, 2025

This PR enables (gated behind a runtime-config) deletions of EAP items by attributes.

Changes:

  • add support for attribute_conditions in the lightweight deletions consumer
    • this means the consumer does resolution of attribute_conditions to "columns" internally
    • changed the query creation to support true columns or column-like subscript expressions (e.g. attributes_string_x['key_name']
    • the wire format of DeleteQueryMessage has two new fields: attribute_conditions and attribute_conditions_item_type
  • fixed the unimplemented API validation of item types, and the respective allowed attributes
  • actually passing attribute_conditions queries through is gated behind a new run-time config permit_delete_by_attribute, defaulting to off

End-to-end tested locally, verification walk-through: https://www.notion.so/sentry/e2e-attribute-testing-2ab8b10e4b5d80cea5cfdc255cff5305

@codecov
Copy link

codecov bot commented Nov 14, 2025

❌ 1 Tests Failed:

Tests completed Failed Passed Skipped
2960 1 2959 16
View the top 1 failed test(s) by shortest run time
tests.web.test_bulk_delete_query::test_attribute_conditions_feature_flag_enabled
Stack Traces | 0.053s run time
Traceback (most recent call last):
  File "/.venv/lib/python3.11........./site-packages/_pytest/runner.py", line 341, in from_call
    result: TResult | None = func()
                             ^^^^^^
  File "/.venv/lib/python3.11........./site-packages/_pytest/runner.py", line 242, in <lambda>
    lambda: runtest_hook(item=item, **kwds), when=when, reraise=reraise
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/.venv/lib/python3.11....../site-packages/pluggy/_hooks.py", line 513, in __call__
    return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/.venv/lib/python3.11....../site-packages/pluggy/_manager.py", line 120, in _hookexec
    return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/.venv/lib/python3.11.........................../site-packages/pluggy/_callers.py", line 182, in _multicall
    return outcome.get_result()
           ^^^^^^^^^^^^^^^^^^^^
  File "/.venv/lib/python3.11.../site-packages/pluggy/_result.py", line 100, in get_result
    raise exc.with_traceback(exc.__traceback__)
  File "/.venv/lib/python3.11.........................../site-packages/pluggy/_callers.py", line 167, in _multicall
    teardown.throw(outcome._exception)
  File "/.venv/lib/python3.11....../site-packages/_pytest/threadexception.py", line 92, in pytest_runtest_call
    yield from thread_exception_runtest_hook()
  File "/.venv/lib/python3.11....../site-packages/_pytest/threadexception.py", line 68, in thread_exception_runtest_hook
    yield
  File "/.venv/lib/python3.11.........................../site-packages/pluggy/_callers.py", line 167, in _multicall
    teardown.throw(outcome._exception)
  File "/.venv/lib/python3.11....../site-packages/_pytest/unraisableexception.py", line 95, in pytest_runtest_call
    yield from unraisable_exception_runtest_hook()
  File "/.venv/lib/python3.11....../site-packages/_pytest/unraisableexception.py", line 70, in unraisable_exception_runtest_hook
    yield
  File "/.venv/lib/python3.11.........................../site-packages/pluggy/_callers.py", line 167, in _multicall
    teardown.throw(outcome._exception)
  File "/.venv/lib/python3.11....../site-packages/_pytest/logging.py", line 846, in pytest_runtest_call
    yield from self._runtest_for(item, "call")
  File "/.venv/lib/python3.11....../site-packages/_pytest/logging.py", line 829, in _runtest_for
    yield
  File "/.venv/lib/python3.11.........................../site-packages/pluggy/_callers.py", line 167, in _multicall
    teardown.throw(outcome._exception)
  File "/.venv/lib/python3.11.../site-packages/_pytest/capture.py", line 880, in pytest_runtest_call
    return (yield)
            ^^^^^
  File "/.venv/lib/python3.11.........................../site-packages/pluggy/_callers.py", line 167, in _multicall
    teardown.throw(outcome._exception)
  File "/.venv/lib/python3.11.../site-packages/_pytest/skipping.py", line 257, in pytest_runtest_call
    return (yield)
            ^^^^^
  File "/.venv/lib/python3.11.........................../site-packages/pluggy/_callers.py", line 103, in _multicall
    res = hook_impl.function(*args)
          ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/.venv/lib/python3.11........./site-packages/_pytest/runner.py", line 174, in pytest_runtest_call
    item.runtest()
  File "/.venv/lib/python3.11....../site-packages/_pytest/python.py", line 1627, in runtest
    self.ihook.pytest_pyfunc_call(pyfuncitem=self)
  File "/.venv/lib/python3.11....../site-packages/pluggy/_hooks.py", line 513, in __call__
    return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/.venv/lib/python3.11....../site-packages/pluggy/_manager.py", line 120, in _hookexec
    return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/.venv/lib/python3.11.........................../site-packages/pluggy/_callers.py", line 139, in _multicall
    raise exception.with_traceback(exception.__traceback__)
  File "/.venv/lib/python3.11.........................../site-packages/pluggy/_callers.py", line 103, in _multicall
    res = hook_impl.function(*args)
          ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/.venv/lib/python3.11....../site-packages/_pytest/python.py", line 159, in pytest_pyfunc_call
    result = testfunction(**testargs)
             ^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../tests/web/test_bulk_delete_query.py", line 221, in test_attribute_conditions_feature_flag_enabled
    assert mock_produce.call_count == 1
AssertionError: assert 0 == 1
 +  where 0 = <MagicMock name='produce_delete_query' id='140033197111312'>.call_count

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

Comment on lines 273 to 280
def _execute_query(
query: Query,
storage: WritableTableStorage,
table: str,
cluster_name: Optional[str],
attribution_info: AttributionInfo,
query_settings: HTTPQuerySettings,
) -> Result:
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This method (and dependencies) should probably move out of snuba/web/delete_query.py since we no longer want to support the synchronous delete from an endpoint (everything should flow through Kafka and the LW deletion consumer). But it can be follow-up work.

@onewland onewland changed the title [wip] feat(deletes): support item attribute conditions in consumer feat(deletes): support item attribute conditions in consumer Nov 17, 2025
@onewland onewland changed the title feat(deletes): support item attribute conditions in consumer feat(deletes): support item attribute conditions in API and consumer Nov 17, 2025
@onewland onewland marked this pull request as ready for review November 17, 2025 23:16
@onewland onewland requested a review from a team as a code owner November 17, 2025 23:16
Comment on lines +59 to +61
# Number of attribute buckets used in eap_items storage
# TODO: find a way to wire this in from a canonical source
NUM_ATTRIBUTE_BUCKETS = 40
Copy link

Choose a reason for hiding this comment

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

Bug: EAPItemsFormatter uses a hardcoded NUM_ATTRIBUTE_BUCKETS, causing incorrect bucket indexing if configuration changes, leading to silent delete failures.
Severity: CRITICAL | Confidence: 1.00

🔍 Detailed Analysis

The EAPItemsFormatter uses a hardcoded NUM_ATTRIBUTE_BUCKETS = 40 for calculating attribute bucket indices. If the num_attribute_buckets configuration changes (e.g., to 64), but the hardcoded value is not updated, delete queries will incorrectly calculate bucket indices. For example, an attribute hashing to 45 would target attributes_string_5 instead of attributes_string_45, leading to silent failures where data is not deleted as intended, causing data inconsistency.

💡 Suggested Fix

Parameterize EAPItemsFormatter to receive num_attribute_buckets as a constructor argument, injecting the value from the canonical storage configuration, similar to other transformers.

🤖 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: snuba/lw_deletions/formatters.py#L59-L61

Potential issue: The `EAPItemsFormatter` uses a hardcoded `NUM_ATTRIBUTE_BUCKETS = 40`
for calculating attribute bucket indices. If the `num_attribute_buckets` configuration
changes (e.g., to 64), but the hardcoded value is not updated, delete queries will
incorrectly calculate bucket indices. For example, an attribute hashing to 45 would
target `attributes_string_5` instead of `attributes_string_45`, leading to silent
failures where data is not deleted as intended, causing data inconsistency.

Did we get this right? 👍 / 👎 to inform future reviews.

Reference_id: 2749980

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