Add event deduplication at presentation layer

When multiple receiver nodes are running, the same mesh events (messages,
advertisements) are reported multiple times. This causes duplicate entries
in the Web UI.

Changes:
- Add hash_utils.py with deterministic hash functions for each event type
- Add `dedupe` parameter to messages and advertisements API endpoints (default: True)
- Update dashboard stats to use distinct counts for messages/advertisements
- Deduplicate recent advertisements and channel messages in dashboard
- Add comprehensive tests for hash utilities

Hash strategy:
- Messages: hash of text + pubkey_prefix + channel_idx + sender_timestamp + txt_type
- Advertisements: hash of public_key + name + adv_type + flags + 5-minute time bucket
This commit is contained in:
Claude
2025-12-06 12:08:07 +00:00
parent 3a060f77cc
commit c80986fe67
5 changed files with 644 additions and 32 deletions
+268
View File
@@ -0,0 +1,268 @@
"""Tests for hash utilities for event deduplication."""
from datetime import datetime, timezone
import pytest
from meshcore_hub.common.hash_utils import (
compute_advertisement_hash,
compute_message_hash,
compute_telemetry_hash,
compute_trace_hash,
)
class TestComputeMessageHash:
"""Tests for compute_message_hash function."""
def test_same_content_produces_same_hash(self) -> None:
"""Identical messages should produce the same hash."""
timestamp = datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
hash1 = compute_message_hash(
text="Hello World",
pubkey_prefix="01ab2186c4d5",
channel_idx=4,
sender_timestamp=timestamp,
txt_type=1,
)
hash2 = compute_message_hash(
text="Hello World",
pubkey_prefix="01ab2186c4d5",
channel_idx=4,
sender_timestamp=timestamp,
txt_type=1,
)
assert hash1 == hash2
def test_different_text_produces_different_hash(self) -> None:
"""Messages with different text should have different hashes."""
timestamp = datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
hash1 = compute_message_hash(
text="Hello World",
pubkey_prefix="01ab2186c4d5",
sender_timestamp=timestamp,
)
hash2 = compute_message_hash(
text="Goodbye World",
pubkey_prefix="01ab2186c4d5",
sender_timestamp=timestamp,
)
assert hash1 != hash2
def test_different_sender_produces_different_hash(self) -> None:
"""Messages from different senders should have different hashes."""
timestamp = datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
hash1 = compute_message_hash(
text="Hello",
pubkey_prefix="01ab2186c4d5",
sender_timestamp=timestamp,
)
hash2 = compute_message_hash(
text="Hello",
pubkey_prefix="99ff8877aabb",
sender_timestamp=timestamp,
)
assert hash1 != hash2
def test_different_channel_produces_different_hash(self) -> None:
"""Messages on different channels should have different hashes."""
hash1 = compute_message_hash(text="Hello", channel_idx=1)
hash2 = compute_message_hash(text="Hello", channel_idx=2)
assert hash1 != hash2
def test_handles_none_values(self) -> None:
"""Hash function should handle None values gracefully."""
hash1 = compute_message_hash(
text="Test",
pubkey_prefix=None,
channel_idx=None,
sender_timestamp=None,
txt_type=None,
)
assert hash1 is not None
assert len(hash1) == 32 # MD5 hex digest length
class TestComputeAdvertisementHash:
"""Tests for compute_advertisement_hash function."""
def test_same_content_same_bucket_produces_same_hash(self) -> None:
"""Advertisements within the same time bucket should match."""
# Two times within the same 5-minute bucket
time1 = datetime(2024, 1, 15, 10, 31, 0, tzinfo=timezone.utc)
time2 = datetime(2024, 1, 15, 10, 33, 0, tzinfo=timezone.utc)
hash1 = compute_advertisement_hash(
public_key="a" * 64,
name="Node1",
adv_type="chat",
flags=128,
received_at=time1,
bucket_minutes=5,
)
hash2 = compute_advertisement_hash(
public_key="a" * 64,
name="Node1",
adv_type="chat",
flags=128,
received_at=time2,
bucket_minutes=5,
)
assert hash1 == hash2
def test_different_bucket_produces_different_hash(self) -> None:
"""Advertisements in different time buckets should not match."""
# Two times in different 5-minute buckets
time1 = datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
time2 = datetime(2024, 1, 15, 10, 36, 0, tzinfo=timezone.utc)
hash1 = compute_advertisement_hash(
public_key="a" * 64,
name="Node1",
received_at=time1,
bucket_minutes=5,
)
hash2 = compute_advertisement_hash(
public_key="a" * 64,
name="Node1",
received_at=time2,
bucket_minutes=5,
)
assert hash1 != hash2
def test_different_public_key_produces_different_hash(self) -> None:
"""Advertisements from different nodes should have different hashes."""
time = datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
hash1 = compute_advertisement_hash(
public_key="a" * 64,
received_at=time,
)
hash2 = compute_advertisement_hash(
public_key="b" * 64,
received_at=time,
)
assert hash1 != hash2
def test_configurable_bucket_size(self) -> None:
"""Bucket size should be configurable."""
time1 = datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
time2 = datetime(2024, 1, 15, 10, 35, 0, tzinfo=timezone.utc)
# With 5-minute bucket, these should be in different buckets
hash1_5min = compute_advertisement_hash(
public_key="a" * 64,
received_at=time1,
bucket_minutes=5,
)
hash2_5min = compute_advertisement_hash(
public_key="a" * 64,
received_at=time2,
bucket_minutes=5,
)
assert hash1_5min != hash2_5min
# With 10-minute bucket, these should be in the same bucket
hash1_10min = compute_advertisement_hash(
public_key="a" * 64,
received_at=time1,
bucket_minutes=10,
)
hash2_10min = compute_advertisement_hash(
public_key="a" * 64,
received_at=time2,
bucket_minutes=10,
)
assert hash1_10min == hash2_10min
class TestComputeTraceHash:
"""Tests for compute_trace_hash function."""
def test_same_tag_produces_same_hash(self) -> None:
"""Same initiator_tag should produce same hash."""
hash1 = compute_trace_hash(initiator_tag=123456789)
hash2 = compute_trace_hash(initiator_tag=123456789)
assert hash1 == hash2
def test_different_tag_produces_different_hash(self) -> None:
"""Different initiator_tag should produce different hash."""
hash1 = compute_trace_hash(initiator_tag=123456789)
hash2 = compute_trace_hash(initiator_tag=987654321)
assert hash1 != hash2
class TestComputeTelemetryHash:
"""Tests for compute_telemetry_hash function."""
def test_same_content_same_bucket_produces_same_hash(self) -> None:
"""Telemetry within the same time bucket should match."""
time1 = datetime(2024, 1, 15, 10, 31, 0, tzinfo=timezone.utc)
time2 = datetime(2024, 1, 15, 10, 33, 0, tzinfo=timezone.utc)
data = {"temperature": 22.5, "humidity": 65}
hash1 = compute_telemetry_hash(
node_public_key="a" * 64,
parsed_data=data,
received_at=time1,
bucket_minutes=5,
)
hash2 = compute_telemetry_hash(
node_public_key="a" * 64,
parsed_data=data,
received_at=time2,
bucket_minutes=5,
)
assert hash1 == hash2
def test_different_data_produces_different_hash(self) -> None:
"""Different sensor readings should produce different hashes."""
time = datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
hash1 = compute_telemetry_hash(
node_public_key="a" * 64,
parsed_data={"temperature": 22.5},
received_at=time,
)
hash2 = compute_telemetry_hash(
node_public_key="a" * 64,
parsed_data={"temperature": 25.0},
received_at=time,
)
assert hash1 != hash2
def test_deterministic_dict_serialization(self) -> None:
"""Dict serialization should be deterministic regardless of key order."""
time = datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
# Same data, different key order in source dicts
data1 = {"a": 1, "b": 2, "c": 3}
data2 = {"c": 3, "a": 1, "b": 2}
hash1 = compute_telemetry_hash(
node_public_key="a" * 64,
parsed_data=data1,
received_at=time,
)
hash2 = compute_telemetry_hash(
node_public_key="a" * 64,
parsed_data=data2,
received_at=time,
)
assert hash1 == hash2