Skip to content

Commit a13b896

Browse files
authored
feat(unmerge): Additions to support hierarchical grouping (#1876)
1 parent 49202be commit a13b896

3 files changed

Lines changed: 220 additions & 12 deletions

File tree

snuba/datasets/errors_replacer.py

Lines changed: 100 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ def process_message(self, message: ReplacementMessage) -> Optional[Replacement]:
251251
"start_delete_groups",
252252
"start_merge",
253253
"start_unmerge",
254+
"start_unmerge_hierarchical",
254255
"start_delete_tag",
255256
):
256257
return None
@@ -260,6 +261,10 @@ def process_message(self, message: ReplacementMessage) -> Optional[Replacement]:
260261
processed = process_merge(event, self.__all_columns)
261262
elif type_ == "end_unmerge":
262263
processed = process_unmerge(event, self.__all_columns, self.__state_name)
264+
elif type_ == "end_unmerge_hierarchical":
265+
processed = process_unmerge_hierarchical(
266+
event, self.__all_columns, self.__state_name
267+
)
263268
elif type_ == "end_delete_tag":
264269
processed = process_delete_tag(
265270
event,
@@ -680,9 +685,9 @@ def process_unmerge(
680685
)
681686

682687
where = """\
683-
PREWHERE group_id = %(previous_group_id)s
684-
WHERE project_id = %(project_id)s
685-
AND primary_hash IN (%(hashes)s)
688+
PREWHERE primary_hash IN (%(hashes)s)
689+
WHERE group_id = %(previous_group_id)s
690+
AND project_id = %(project_id)s
686691
AND received <= CAST('%(timestamp)s' AS DateTime)
687692
AND NOT deleted
688693
"""
@@ -710,16 +715,103 @@ def process_unmerge(
710715
"previous_group_id": message["previous_group_id"],
711716
"project_id": message["project_id"],
712717
"timestamp": timestamp.strftime(DATETIME_FORMAT),
718+
"hashes": ", ".join(_convert_hash(h, state_name) for h in hashes),
713719
}
714720

721+
query_time_flags = (NEEDS_FINAL, message["project_id"])
722+
723+
return LegacyReplacement(
724+
count_query_template, insert_query_template, query_args, query_time_flags
725+
)
726+
727+
728+
def _convert_hash(
729+
hash: str, state_name: ReplacerState, convert_types: bool = False
730+
) -> str:
715731
if state_name == ReplacerState.ERRORS:
716-
query_args["hashes"] = ", ".join(
717-
["'%s'" % str(uuid.UUID(_hashify(h))) for h in hashes]
718-
)
732+
if convert_types:
733+
return "toUUID('%s')" % str(uuid.UUID(_hashify(hash)))
734+
else:
735+
return "'%s'" % str(uuid.UUID(_hashify(hash)))
719736
else:
720-
query_args["hashes"] = ", ".join("'%s'" % _hashify(h) for h in hashes)
737+
if convert_types:
738+
return "toFixedString('%s', 32)" % _hashify(hash)
739+
else:
740+
return "'%s'" % _hashify(hash)
721741

722-
query_time_flags = (NEEDS_FINAL, message["project_id"])
742+
743+
def process_unmerge_hierarchical(
744+
message: Mapping[str, Any],
745+
all_columns: Sequence[FlattenedColumn],
746+
state_name: ReplacerState,
747+
) -> Optional[Replacement]:
748+
all_column_names = [c.escaped for c in all_columns]
749+
select_columns = map(
750+
lambda i: i if i != "group_id" else str(message["new_group_id"]),
751+
all_column_names,
752+
)
753+
754+
try:
755+
timestamp = datetime.strptime(
756+
message["datetime"], settings.PAYLOAD_DATETIME_FORMAT
757+
)
758+
759+
primary_hash = message["primary_hash"]
760+
assert isinstance(primary_hash, str)
761+
762+
hierarchical_hash = message["hierarchical_hash"]
763+
assert isinstance(hierarchical_hash, str)
764+
765+
uuid.UUID(primary_hash)
766+
uuid.UUID(hierarchical_hash)
767+
except Exception as exc:
768+
# TODO(markus): We're sacrificing consistency over uptime as long as
769+
# this is in development. At some point this piece of code should be
770+
# stable enough to remove this.
771+
logger.error("process_unmerge_hierarchical.failed", exc_info=exc)
772+
return None
773+
774+
where = """\
775+
PREWHERE primary_hash = %(primary_hash)s
776+
WHERE group_id = %(previous_group_id)s
777+
AND has(hierarchical_hashes, %(hierarchical_hash)s)
778+
AND project_id = %(project_id)s
779+
AND received <= CAST('%(timestamp)s' AS DateTime)
780+
AND NOT deleted
781+
"""
782+
783+
count_query_template = (
784+
"""\
785+
SELECT count()
786+
FROM %(table_name)s FINAL
787+
"""
788+
+ where
789+
)
790+
791+
insert_query_template = (
792+
"""\
793+
INSERT INTO %(table_name)s (%(all_columns)s)
794+
SELECT %(select_columns)s
795+
FROM %(table_name)s FINAL
796+
"""
797+
+ where
798+
)
799+
800+
query_args = {
801+
"all_columns": ", ".join(all_column_names),
802+
"select_columns": ", ".join(select_columns),
803+
"previous_group_id": message["previous_group_id"],
804+
"project_id": message["project_id"],
805+
"timestamp": timestamp.strftime(DATETIME_FORMAT),
806+
"primary_hash": _convert_hash(primary_hash, state_name),
807+
"hierarchical_hash": _convert_hash(
808+
hierarchical_hash, state_name, convert_types=True
809+
),
810+
}
811+
812+
# Sentry is expected to send an `exclude_groups` message after unsplit is
813+
# done, and we can live with data inconsistencies while this is ongoing.
814+
query_time_flags = (None, message["project_id"])
723815

724816
return LegacyReplacement(
725817
count_query_template, insert_query_template, query_args, query_time_flags

tests/datasets/test_errors_replacer.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,11 +287,11 @@ def test_unmerge_process(self) -> None:
287287

288288
assert (
289289
re.sub("[\n ]+", " ", replacement.count_query_template).strip()
290-
== "SELECT count() FROM %(table_name)s FINAL PREWHERE group_id = %(previous_group_id)s WHERE project_id = %(project_id)s AND primary_hash IN (%(hashes)s) AND received <= CAST('%(timestamp)s' AS DateTime) AND NOT deleted"
290+
== "SELECT count() FROM %(table_name)s FINAL PREWHERE primary_hash IN (%(hashes)s) WHERE group_id = %(previous_group_id)s AND project_id = %(project_id)s AND received <= CAST('%(timestamp)s' AS DateTime) AND NOT deleted"
291291
)
292292
assert (
293293
re.sub("[\n ]+", " ", replacement.insert_query_template).strip()
294-
== "INSERT INTO %(table_name)s (%(all_columns)s) SELECT %(select_columns)s FROM %(table_name)s FINAL PREWHERE group_id = %(previous_group_id)s WHERE project_id = %(project_id)s AND primary_hash IN (%(hashes)s) AND received <= CAST('%(timestamp)s' AS DateTime) AND NOT deleted"
294+
== "INSERT INTO %(table_name)s (%(all_columns)s) SELECT %(select_columns)s FROM %(table_name)s FINAL PREWHERE primary_hash IN (%(hashes)s) WHERE group_id = %(previous_group_id)s AND project_id = %(project_id)s AND received <= CAST('%(timestamp)s' AS DateTime) AND NOT deleted"
295295
)
296296
assert replacement.query_args == {
297297
"all_columns": "project_id, timestamp, event_id, platform, environment, release, dist, ip_address_v4, ip_address_v6, user, user_id, user_name, user_email, sdk_name, sdk_version, http_method, http_referer, tags.key, tags.value, contexts.key, contexts.value, transaction_name, span_id, trace_id, partition, offset, message_timestamp, retention_days, deleted, group_id, primary_hash, hierarchical_hashes, received, message, title, culprit, level, location, version, type, exception_stacks.type, exception_stacks.value, exception_stacks.mechanism_type, exception_stacks.mechanism_handled, exception_frames.abs_path, exception_frames.colno, exception_frames.filename, exception_frames.function, exception_frames.lineno, exception_frames.in_app, exception_frames.package, exception_frames.module, exception_frames.stack_level, sdk_integrations, modules.name, modules.version",
@@ -307,6 +307,44 @@ def test_unmerge_process(self) -> None:
307307
self.project_id,
308308
)
309309

310+
def test_unmerge_hierarchical_process(self) -> None:
311+
timestamp = datetime.now(tz=pytz.utc)
312+
313+
message = (
314+
2,
315+
"end_unmerge_hierarchical",
316+
{
317+
"project_id": self.project_id,
318+
"previous_group_id": 1,
319+
"new_group_id": 2,
320+
"hierarchical_hash": "a" * 32,
321+
"primary_hash": "b" * 32,
322+
"datetime": timestamp.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
323+
},
324+
)
325+
326+
replacement = self.replacer.process_message(self._wrap(message))
327+
328+
assert (
329+
re.sub("[\n ]+", " ", replacement.count_query_template).strip()
330+
== "SELECT count() FROM %(table_name)s FINAL PREWHERE primary_hash = %(primary_hash)s WHERE group_id = %(previous_group_id)s AND has(hierarchical_hashes, %(hierarchical_hash)s) AND project_id = %(project_id)s AND received <= CAST('%(timestamp)s' AS DateTime) AND NOT deleted"
331+
)
332+
assert (
333+
re.sub("[\n ]+", " ", replacement.insert_query_template).strip()
334+
== "INSERT INTO %(table_name)s (%(all_columns)s) SELECT %(select_columns)s FROM %(table_name)s FINAL PREWHERE primary_hash = %(primary_hash)s WHERE group_id = %(previous_group_id)s AND has(hierarchical_hashes, %(hierarchical_hash)s) AND project_id = %(project_id)s AND received <= CAST('%(timestamp)s' AS DateTime) AND NOT deleted"
335+
)
336+
assert replacement.query_args == {
337+
"all_columns": "project_id, timestamp, event_id, platform, environment, release, dist, ip_address_v4, ip_address_v6, user, user_id, user_name, user_email, sdk_name, sdk_version, http_method, http_referer, tags.key, tags.value, contexts.key, contexts.value, transaction_name, span_id, trace_id, partition, offset, message_timestamp, retention_days, deleted, group_id, primary_hash, hierarchical_hashes, received, message, title, culprit, level, location, version, type, exception_stacks.type, exception_stacks.value, exception_stacks.mechanism_type, exception_stacks.mechanism_handled, exception_frames.abs_path, exception_frames.colno, exception_frames.filename, exception_frames.function, exception_frames.lineno, exception_frames.in_app, exception_frames.package, exception_frames.module, exception_frames.stack_level, sdk_integrations, modules.name, modules.version",
338+
"select_columns": "project_id, timestamp, event_id, platform, environment, release, dist, ip_address_v4, ip_address_v6, user, user_id, user_name, user_email, sdk_name, sdk_version, http_method, http_referer, tags.key, tags.value, contexts.key, contexts.value, transaction_name, span_id, trace_id, partition, offset, message_timestamp, retention_days, deleted, 2, primary_hash, hierarchical_hashes, received, message, title, culprit, level, location, version, type, exception_stacks.type, exception_stacks.value, exception_stacks.mechanism_type, exception_stacks.mechanism_handled, exception_frames.abs_path, exception_frames.colno, exception_frames.filename, exception_frames.function, exception_frames.lineno, exception_frames.in_app, exception_frames.package, exception_frames.module, exception_frames.stack_level, sdk_integrations, modules.name, modules.version",
339+
"primary_hash": "'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'",
340+
"hierarchical_hash": "toUUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')",
341+
"previous_group_id": 1,
342+
"project_id": self.project_id,
343+
"timestamp": timestamp.strftime(DATETIME_FORMAT),
344+
}
345+
346+
assert replacement.query_time_flags == (None, self.project_id,)
347+
310348
def test_delete_promoted_tag_process(self) -> None:
311349
timestamp = datetime.now(tz=pytz.utc)
312350
message = (

tests/test_replacer.py

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,11 +164,11 @@ def test_unmerge_process(self) -> None:
164164

165165
assert (
166166
re.sub("[\n ]+", " ", replacement.count_query_template).strip()
167-
== "SELECT count() FROM %(table_name)s FINAL PREWHERE group_id = %(previous_group_id)s WHERE project_id = %(project_id)s AND primary_hash IN (%(hashes)s) AND received <= CAST('%(timestamp)s' AS DateTime) AND NOT deleted"
167+
== "SELECT count() FROM %(table_name)s FINAL PREWHERE primary_hash IN (%(hashes)s) WHERE group_id = %(previous_group_id)s AND project_id = %(project_id)s AND received <= CAST('%(timestamp)s' AS DateTime) AND NOT deleted"
168168
)
169169
assert (
170170
re.sub("[\n ]+", " ", replacement.insert_query_template).strip()
171-
== "INSERT INTO %(table_name)s (%(all_columns)s) SELECT %(select_columns)s FROM %(table_name)s FINAL PREWHERE group_id = %(previous_group_id)s WHERE project_id = %(project_id)s AND primary_hash IN (%(hashes)s) AND received <= CAST('%(timestamp)s' AS DateTime) AND NOT deleted"
171+
== "INSERT INTO %(table_name)s (%(all_columns)s) SELECT %(select_columns)s FROM %(table_name)s FINAL PREWHERE primary_hash IN (%(hashes)s) WHERE group_id = %(previous_group_id)s AND project_id = %(project_id)s AND received <= CAST('%(timestamp)s' AS DateTime) AND NOT deleted"
172172
)
173173
assert replacement.query_args == {
174174
"all_columns": "event_id, project_id, group_id, timestamp, deleted, retention_days, platform, message, primary_hash, hierarchical_hashes, received, search_message, title, location, user_id, username, email, ip_address, geo_country_code, geo_region, geo_city, sdk_name, sdk_version, type, version, offset, partition, message_timestamp, os_build, os_kernel_version, device_name, device_brand, device_locale, device_uuid, device_model_id, device_arch, device_battery_level, device_orientation, device_simulator, device_online, device_charging, level, logger, server_name, transaction, environment, `sentry:release`, `sentry:dist`, `sentry:user`, site, url, app_device, device, device_family, runtime, runtime_name, browser, browser_name, os, os_name, os_rooted, tags.key, tags.value, _tags_flattened, contexts.key, contexts.value, http_method, http_referer, exception_stacks.type, exception_stacks.value, exception_stacks.mechanism_type, exception_stacks.mechanism_handled, exception_frames.abs_path, exception_frames.filename, exception_frames.package, exception_frames.module, exception_frames.function, exception_frames.in_app, exception_frames.colno, exception_frames.lineno, exception_frames.stack_level, culprit, sdk_integrations, modules.name, modules.version",
@@ -183,6 +183,42 @@ def test_unmerge_process(self) -> None:
183183
self.project_id,
184184
)
185185

186+
def test_unmerge_hierarchical_process(self) -> None:
187+
timestamp = datetime.now(tz=pytz.utc)
188+
message = (
189+
2,
190+
"end_unmerge_hierarchical",
191+
{
192+
"project_id": self.project_id,
193+
"previous_group_id": 1,
194+
"new_group_id": 2,
195+
"hierarchical_hash": "a" * 32,
196+
"primary_hash": "b" * 32,
197+
"datetime": timestamp.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
198+
},
199+
)
200+
201+
replacement = self.replacer.process_message(self._wrap(message))
202+
203+
assert (
204+
re.sub("[\n ]+", " ", replacement.count_query_template).strip()
205+
== "SELECT count() FROM %(table_name)s FINAL PREWHERE primary_hash = %(primary_hash)s WHERE group_id = %(previous_group_id)s AND has(hierarchical_hashes, %(hierarchical_hash)s) AND project_id = %(project_id)s AND received <= CAST('%(timestamp)s' AS DateTime) AND NOT deleted"
206+
)
207+
assert (
208+
re.sub("[\n ]+", " ", replacement.insert_query_template).strip()
209+
== "INSERT INTO %(table_name)s (%(all_columns)s) SELECT %(select_columns)s FROM %(table_name)s FINAL PREWHERE primary_hash = %(primary_hash)s WHERE group_id = %(previous_group_id)s AND has(hierarchical_hashes, %(hierarchical_hash)s) AND project_id = %(project_id)s AND received <= CAST('%(timestamp)s' AS DateTime) AND NOT deleted"
210+
)
211+
assert replacement.query_args == {
212+
"all_columns": "event_id, project_id, group_id, timestamp, deleted, retention_days, platform, message, primary_hash, hierarchical_hashes, received, search_message, title, location, user_id, username, email, ip_address, geo_country_code, geo_region, geo_city, sdk_name, sdk_version, type, version, offset, partition, message_timestamp, os_build, os_kernel_version, device_name, device_brand, device_locale, device_uuid, device_model_id, device_arch, device_battery_level, device_orientation, device_simulator, device_online, device_charging, level, logger, server_name, transaction, environment, `sentry:release`, `sentry:dist`, `sentry:user`, site, url, app_device, device, device_family, runtime, runtime_name, browser, browser_name, os, os_name, os_rooted, tags.key, tags.value, _tags_flattened, contexts.key, contexts.value, http_method, http_referer, exception_stacks.type, exception_stacks.value, exception_stacks.mechanism_type, exception_stacks.mechanism_handled, exception_frames.abs_path, exception_frames.filename, exception_frames.package, exception_frames.module, exception_frames.function, exception_frames.in_app, exception_frames.colno, exception_frames.lineno, exception_frames.stack_level, culprit, sdk_integrations, modules.name, modules.version",
213+
"select_columns": "event_id, project_id, 2, timestamp, deleted, retention_days, platform, message, primary_hash, hierarchical_hashes, received, search_message, title, location, user_id, username, email, ip_address, geo_country_code, geo_region, geo_city, sdk_name, sdk_version, type, version, offset, partition, message_timestamp, os_build, os_kernel_version, device_name, device_brand, device_locale, device_uuid, device_model_id, device_arch, device_battery_level, device_orientation, device_simulator, device_online, device_charging, level, logger, server_name, transaction, environment, `sentry:release`, `sentry:dist`, `sentry:user`, site, url, app_device, device, device_family, runtime, runtime_name, browser, browser_name, os, os_name, os_rooted, tags.key, tags.value, _tags_flattened, contexts.key, contexts.value, http_method, http_referer, exception_stacks.type, exception_stacks.value, exception_stacks.mechanism_type, exception_stacks.mechanism_handled, exception_frames.abs_path, exception_frames.filename, exception_frames.package, exception_frames.module, exception_frames.function, exception_frames.in_app, exception_frames.colno, exception_frames.lineno, exception_frames.stack_level, culprit, sdk_integrations, modules.name, modules.version",
214+
"hierarchical_hash": "toFixedString('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 32)",
215+
"primary_hash": "'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'",
216+
"previous_group_id": 1,
217+
"project_id": self.project_id,
218+
"timestamp": timestamp.strftime(DATETIME_FORMAT),
219+
}
220+
assert replacement.query_time_flags == (None, self.project_id,)
221+
186222
def test_delete_promoted_tag_process(self) -> None:
187223
timestamp = datetime.now(tz=pytz.utc)
188224
message = (
@@ -373,6 +409,48 @@ def test_unmerge_insert(self) -> None:
373409

374410
assert self._issue_count(self.project_id) == [{"count": 1, "group_id": 2}]
375411

412+
def test_unmerge_hierarchical_insert(self) -> None:
413+
self.event["project_id"] = self.project_id
414+
self.event["group_id"] = 1
415+
self.event["primary_hash"] = "b" * 32
416+
self.event["data"]["hierarchical_hashes"] = ["a" * 32]
417+
write_unprocessed_events(self.storage, [self.event])
418+
419+
assert self._issue_count(self.project_id) == [{"count": 1, "group_id": 1}]
420+
421+
timestamp = datetime.now(tz=pytz.utc)
422+
423+
project_id = self.project_id
424+
425+
message: Message[KafkaPayload] = Message(
426+
Partition(Topic("replacements"), 1),
427+
42,
428+
KafkaPayload(
429+
None,
430+
json.dumps(
431+
(
432+
2,
433+
"end_unmerge_hierarchical",
434+
{
435+
"project_id": project_id,
436+
"previous_group_id": 1,
437+
"new_group_id": 2,
438+
"hierarchical_hash": "a" * 32,
439+
"primary_hash": "b" * 32,
440+
"datetime": timestamp.strftime(PAYLOAD_DATETIME_FORMAT),
441+
},
442+
)
443+
).encode("utf-8"),
444+
[],
445+
),
446+
datetime.now(),
447+
)
448+
449+
processed = self.replacer.process_message(message)
450+
self.replacer.flush_batch([processed])
451+
452+
assert self._issue_count(self.project_id) == [{"count": 1, "group_id": 2}]
453+
376454
def test_delete_tag_promoted_insert(self) -> None:
377455
self.event["project_id"] = self.project_id
378456
self.event["group_id"] = 1

0 commit comments

Comments
 (0)