Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,9 @@ class ClickEventsSettings(BaseSettings):
# a message is dead-lettered to dlq_stream.
claim_idle_ms: int = Field(default=60_000, ge=1000)
max_deliveries: int = Field(default=5, ge=1)
# DLQ bound — sized for hours of clicks (~600B/event → 100k ≈ 60MB);
# recover with scripts/replay_dlq.py.
dlq_maxlen: int = Field(default=100_000, ge=1000)
stats_interval_seconds: float = Field(default=30.0, gt=0)

# Which consumer groups this worker process runs. Lets a future
Expand Down
6 changes: 5 additions & 1 deletion repositories/indexes.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,11 @@ async def ensure_indexes(
timeseries={
"timeField": "clicked_at",
"metaField": "meta",
"granularity": "seconds",
# "hours" spans buckets over 30 days. Most links get sparse
# clicks, so finer granularity degenerates into one bucket
# per click. Existing deploys need a one-time collMod to
# match (the transition is one-way, coarser only).
"granularity": "hours",
Comment on lines +67 to +71

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- target file ---'
sed -n '1,130p' repositories/indexes.py
printf '%s\n' '--- related references ---'
rg -n -S 'create_collection|collMod|granularity|clicks|timeseries' . \
  -g '!node_modules' -g '!dist' -g '!build' | head -250
printf '%s\n' '--- repository metadata ---'
git status --short
git diff --stat

Repository: spoo-me/spoo

Length of output: 30656


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- index tests ---'
sed -n '1,230p' tests/unit/repositories/test_indexes.py
printf '%s\n' '--- bootstrap and migration files ---'
git ls-files infrastructure/bootstrap migrations 2>/dev/null || true
find infrastructure -maxdepth 3 -type f -print 2>/dev/null | sort
printf '%s\n' '--- migration/lifespan references ---'
rg -n -S 'bootstrap|migration|ensure_indexes|lifespan|MongoDB|MONGO|mongo' app.py infrastructure docker-compose*.yml README.md pyproject.toml requirements*.txt 2>/dev/null | head -300

Repository: spoo-me/spoo

Length of output: 12290


🌐 Web query:

MongoDB time series granularity collMod increase granularity supported versions existing collection createCollection behavior

💡 Result:

In MongoDB, you can modify the granularity of an existing time series collection using the collMod command, but you are restricted to only increasing the granularity [1][2]. Specifically, you can change the granularity from a shorter time unit to a longer one (e.g., from "seconds" to "minutes" or "hours") [1][3]. You cannot decrease the granularity [1][4]. Supported Versions and Requirements: - Changing granularity is supported in MongoDB 5.0.1 and later [3][4]. - For sharded time series collections, you must be running MongoDB 6.0 or later to modify granularity [1][3]. - When performing a collMod operation on a time series collection, the modification must be in a dedicated command, separate from any other collection modifications [2]. Modifying Granularity or Custom Bucketing: - If your collection uses the standard granularity parameter, you can increase it using: db.runCommand({ collMod: "collectionName", timeseries: { granularity: "minutes" } }) [1][2] - Starting in MongoDB 6.3, you can use custom bucketing parameters (bucketMaxSpanSeconds and bucketRoundingSeconds) instead of the standard granularity [1][5]. If you are using these custom parameters, you must include both in the collMod command and set them to the same value to increase the interval [1][2]. You cannot decrease these values [2]. Collection Creation (createCollection): - When creating a collection, you can specify granularity or use the newer custom bucketing parameters [5][6]. - The granularity options are "seconds" (default), "minutes", and "hours" [5][6]. - If you opt for custom bucketing, you must provide both bucketMaxSpanSeconds and bucketRoundingSeconds [6][7]. If you set these, you should not specify the granularity parameter [5][6]. Setting these parameters to the same value provides more precise control over bucket boundaries, which can optimize performance for fixed time-interval queries [1][8].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- startup ordering ---'
sed -n '70,165p' app.py
printf '%s\n' '--- dependency/version declarations ---'
sed -n '1,90p' requirements.txt
sed -n '1,55p' pyproject.toml
printf '%s\n' '--- all tests for index setup ---'
rg -n -C 8 'test_ensure_indexes|create_collection|CollectionInvalid|OperationFailure' tests/unit/repositories/test_indexes.py
printf '%s\n' '--- bootstrap implementation ---'
cat -n infrastructure/bootstrap/__init__.py infrastructure/bootstrap/system_default_domain.py

Repository: spoo-me/spoo

Length of output: 13314


Apply granularity: "hours" to existing clicks collections.

When clicks already exists, create_collection() raises an expected error and the code skips the change. Add a dedicated, idempotent collMod migration and test this path. MongoDB supports increasing time-series granularity with collMod on supported server versions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@repositories/indexes.py` around lines 67 - 71, Update the existing
clicks-collection setup around create_collection to catch the already-exists
case and run a dedicated, idempotent collMod migration that sets time-series
granularity to "hours". Preserve normal creation behavior for new collections,
avoid masking unexpected errors, and add coverage for the existing-collection
migration path.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The collMod note is right but nothing operational carries it; add the one-time collMod to the release checklist so it does not evaporate between merge and release. Also worth remembering that this and the exclude_none change only help buckets written afterwards. The existing fragmented backlog stays as it is unless the collection is rewritten; letting it age is probably fine, but that is a decision, not a default.

},
)
except (CollectionInvalid, OperationFailure) as e:
Expand Down
11 changes: 11 additions & 0 deletions schemas/models/click.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,14 @@ class ClickDoc(MongoBaseModel):
utm_source: str | None = None
utm_medium: str | None = None
utm_campaign: str | None = None

def to_mongo(self) -> dict:
"""Serialise for insertion, omitting None fields entirely.

Time-series buckets track one BSON type per field. An explicit
null following a string value (or vice versa) is a type conflict
that closes the bucket early, while an absent field packs fine.
Queries are unaffected: MongoDB treats missing and null the same
in $eq/$group, and the stats sentinels already map both.
"""
return self.model_dump(by_alias=True, exclude_none=True)
105 changes: 105 additions & 0 deletions scripts/replay_dlq.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "redis>=5",
# ]
# ///
"""Replay dead-lettered click events back onto the main stream.

Standalone — reads ``CLICK_EVENTS_QUEUE_REDIS_URI`` from the environment.
Replayed events fan out to every consumer group again (streams have no

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The fan-out note stops one sentence short of the consequence. A message dead-lettered by one group was already processed and acked by the others, so replaying re-delivers to every group: duplicate hotness increments, duplicate click inserts when stats had succeeded (no dedupe key on a time-series insert), and duplicate webhook fires under a fresh stream id, which consumer-side idempotency will not catch. After a global outage the replay is clean; after a partial-group failure it is not, and the per-group breakdown this script prints is the signal to read first. Worth saying that here so the operator runs --dry-run, checks the group counts, and decides with eyes open.

per-group publish); replayed entries leave the DLQ unless ``--keep``.

Usage::

uv run --env-file .env.production scripts/replay_dlq.py --dry-run
uv run --env-file .env.production scripts/replay_dlq.py --limit 500 --keep
"""

from __future__ import annotations

import argparse
import os
import sys

from redis import Redis

STREAM_FIELD_DATA = "__data__" # payload field the workers' parser consumes
DLQ_FIELD_SOURCE_ID = "dlq_source_id"
DLQ_FIELD_GROUP = "dlq_group"
DLQ_FIELD_REASON = "dlq_reason"

_BATCH = 200


def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be replayed without writing.",
)
parser.add_argument(
"--limit", type=int, default=0, help="Replay at most N entries (0 = all)."
)
parser.add_argument(
"--keep",
action="store_true",
help="Do not delete replayed entries from the DLQ.",
)
parser.add_argument("--stream", default="events:clicks")
parser.add_argument("--dlq-stream", default="events:clicks:dlq")
args = parser.parse_args()

uri = os.environ.get("CLICK_EVENTS_QUEUE_REDIS_URI")
if not uri:
sys.exit("CLICK_EVENTS_QUEUE_REDIS_URI not set in environment.")

redis = Redis.from_url(uri, decode_responses=True)
total = redis.xlen(args.dlq_stream)
print(f"DLQ {args.dlq_stream}: {total} entries")
if total == 0:
return

replayed = 0
by_group: dict[str, int] = {}
skipped = 0
cursor = "-"
while True:
entries = redis.xrange(args.dlq_stream, min=cursor, max="+", count=_BATCH)
if not entries:
break
for entry_id, fields in entries:
if args.limit and replayed >= args.limit:
break
data = fields.get(STREAM_FIELD_DATA)
if data is None:
skipped += 1
continue
Comment on lines +76 to +79

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Make DLQ replay preserve the click publication contract.

Before XADD, validate __data__ with the same ClickEvent contract; empty or malformed payloads must remain in the DLQ and be reported as skipped. Replay must pass the configured maxlen, approximate=True, and ref_policy="ACKED" so a large replay cannot bypass stream retention and pressure Redis into the inline fallback. When deleting entries (--keep is not set), make XADD and XDEL atomic, for example with one Lua script, so a partial failure cannot leave a replayable entry and cause duplicates.

📍 Affects 1 file
  • scripts/replay_dlq.py#L76-L79 (this comment)
  • scripts/replay_dlq.py#L88-L88
  • scripts/replay_dlq.py#L88-L90
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/replay_dlq.py` around lines 76 - 79, Update the replay loop in
scripts/replay_dlq.py to validate each __data__ payload with the same
ClickEvent.model_validate_json contract used by services/click/events.py before
XADD. Treat validation failures like missing data: increment skipped, continue
without replaying, and leave the invalid entry in the DLQ; only validated events
should be sent to consumer groups and counted as successful.

Apply the same fix in `@scripts/replay_dlq.py` at line 88.

Apply the same fix in `@scripts/replay_dlq.py` around lines 88 - 90.

group = fields.get(DLQ_FIELD_GROUP, "?")
if args.dry_run:
print(
f" would replay {entry_id} "
f"(source={fields.get(DLQ_FIELD_SOURCE_ID, '?')}, "
f"group={group}, reason={fields.get(DLQ_FIELD_REASON, '?')})"
)
else:
redis.xadd(args.stream, {STREAM_FIELD_DATA: data})
if not args.keep:
redis.xdel(args.dlq_stream, entry_id)
replayed += 1
by_group[group] = by_group.get(group, 0) + 1
if (args.limit and replayed >= args.limit) or len(entries) < _BATCH:
break
# xrange min is inclusive — nudge past the last-seen id
cursor = f"({entries[-1][0]}"

verb = "would replay" if args.dry_run else "replayed"
print(
f"{verb} {replayed} (skipped {skipped} without payload) — by group: {by_group}"
)


if __name__ == "__main__":
main()
39 changes: 33 additions & 6 deletions shared/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,23 @@
_ALLOWED_URL_SCHEMES = frozenset({"http", "https"})


def is_self_referential(host: str | None, blocked_self_domains: Sequence[str]) -> bool:
"""Return True when *host* is one of *blocked_self_domains* or a subdomain.

Host-scoped on purpose. A destination is only a redirect loop when the
request would come back to us, which is decided by the host alone — a
blocked name appearing in a path or query string ("?filter=spoo.me") is
someone else's URL that happens to mention us.
"""
if not host:
return False
host = host.lower().rstrip(".")
return any(
host == domain or host.endswith(f".{domain}")
for domain in (d.lower().strip().rstrip(".") for d in blocked_self_domains)
)


def validate_url(
url: str,
blocked_self_domains: Sequence[str] = ("spoo.me",),
Expand All @@ -27,18 +44,28 @@ def validate_url(

Args:
url: The URL string to validate.
blocked_self_domains: Bare hostnames whose substring presence in the
URL marks it as self-referential. Defaults to ``("spoo.me",)``
to prevent redirect loops.
blocked_self_domains: Bare hostnames that mark the URL as
self-referential when they are the destination's HOST (or a
parent of it). Defaults to ``("spoo.me",)`` to prevent redirect
loops. Matching is host-scoped: a destination that merely
mentions the name in its path or query is not a loop.
"""
# urlparse raises on a malformed IPv6 authority ("https://[::1"), and
# long_url reaches here as a plain str, so an unguarded parse escapes as
# a 500 on the shorten endpoints instead of a 400.
try:
parsed = urlparse(url)
except ValueError:
return False
# Scheme allowlist defends against ftp/file/data/etc even if the
# validators package widens its accepted schemes upstream.
if urlparse(url).scheme not in _ALLOWED_URL_SCHEMES:
if parsed.scheme not in _ALLOWED_URL_SCHEMES:
return False
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if not _validators.url(url, skip_ipv4_addr=True, skip_ipv6_addr=True):
return False
url_lower = url.lower()
return not any(domain in url_lower for domain in blocked_self_domains)
# hostname is already lowercased and strips any userinfo/port, so
# "https://spoo.me:443@evil.com" can't smuggle the check either way.
return not is_self_referential(parsed.hostname, blocked_self_domains)


def validate_url_password(password: str, min_length: int = 8) -> bool:
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/repositories/test_indexes.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ async def test_ensure_indexes_creates_timeseries_collection(self):
timeseries={
"timeField": "clicked_at",
"metaField": "meta",
"granularity": "seconds",
"granularity": "hours",
},
)

Expand Down
16 changes: 16 additions & 0 deletions tests/unit/schemas/models/test_click.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,19 @@ def test_to_mongo_round_trip(self):
restored = ClickDoc.from_mongo(doc.to_mongo())
assert restored.referrer == "google.com"
assert restored.redirect_ms == doc.redirect_ms

def test_to_mongo_omits_none_fields(self):
# Explicit nulls flip the per-field BSON type and close time-series
# buckets; None fields must be absent from the insert document.
data = self._make(referrer=None, bot_name=None).to_mongo()
assert "referrer" not in data
assert "bot_name" not in data
assert "device" not in data
assert "utm_source" not in data
assert "domain" not in data["meta"]

def test_to_mongo_keeps_set_fields(self):
data = self._make(referrer="google.com", device="mobile").to_mongo()
assert data["referrer"] == "google.com"
assert data["device"] == "mobile"
assert data["country"] == "Unknown"
16 changes: 9 additions & 7 deletions tests/unit/services/test_click_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,10 +303,11 @@ async def test_utm_tags_default_to_none(self):

await handler.handle(make_context(url_data))

# Absent, not null: explicit nulls close time-series buckets.
doc = d.click_repo.insert.call_args[0][0]
assert doc["utm_source"] is None
assert doc["utm_medium"] is None
assert doc["utm_campaign"] is None
assert "utm_source" not in doc
assert "utm_medium" not in doc
assert "utm_campaign" not in doc

@pytest.mark.asyncio
async def test_meta_carries_domain_from_cache(self):
Expand All @@ -320,17 +321,18 @@ async def test_meta_carries_domain_from_cache(self):
assert doc["meta"]["domain"] == "links.acme.com"

@pytest.mark.asyncio
async def test_meta_domain_none_when_cache_empty_string(self):
# Older cached entries pre-PR1 have domain="". Coerce to None so
# per-domain queries can distinguish "unknown" from a real value.
async def test_meta_domain_omitted_when_cache_empty_string(self):
# Older cached entries pre-PR1 have domain="". Coerced to None and
# then omitted from the insert doc ($eq: null matches missing, so
# per-domain queries still distinguish "unknown" from a real value).
d = make_deps()
handler = make_v2_handler(d.click_repo, d.url_repo, d.geoip, d.url_cache)
url_data = make_v2_cache(domain="")

await handler.handle(make_context(url_data))

doc = d.click_repo.insert.call_args[0][0]
assert doc["meta"]["domain"] is None
assert "domain" not in doc["meta"]

@pytest.mark.asyncio
async def test_blocked_bot_skips_analytics_no_error(self):
Expand Down
21 changes: 21 additions & 0 deletions tests/unit/shared/test_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,37 @@
("https://example.com/foo/bar?q=1", True),
("https://spoo.me/abc", False),
("https://SPOO.ME/abc", False), # case-insensitive block
("https://www.spoo.me/abc", False), # subdomain of a blocked host
("not-a-url", False),
("http://192.168.1.1/path", False), # IPv4 skipped by validator
("https://[::1", False), # urlparse raises on this, must not escape
# Host-scoped: a foreign destination that merely mentions the blocked
# name in its path, query or fragment is not a redirect loop. The
# dashboard hit this shortening a PostHog analytics URL filtered on
# spoo.me, which the old substring check rejected.
("https://eu.posthog.com/project/1?filter=spoo.me", True),
("https://example.com/spoo.me/guide", True),
("https://example.com/#spoo.me", True),
("https://notspoo.me/abc", True), # suffix must be a label boundary
# userinfo can't smuggle either direction — hostname wins.
("https://spoo.me@example.com/", True),
("https://example.com@spoo.me/", False),
],
ids=[
"valid",
"valid_with_path",
"self_ref",
"self_ref_uppercase",
"self_ref_subdomain",
"plain_text",
"ipv4",
"malformed_ipv6_authority",
"foreign_host_mentions_in_query",
"foreign_host_mentions_in_path",
"foreign_host_mentions_in_fragment",
"lookalike_host_not_blocked",
"userinfo_lookalike_allowed",
"userinfo_cannot_mask_self_host",
],
)
def test_validate_url(url, expected):
Expand Down
12 changes: 12 additions & 0 deletions tests/unit/workers/test_dlq_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,18 @@ async def test_past_budget_moves_to_dlq(self):
assert kwargs["maxlen"] == DLQ_MAXLEN
assert kwargs["approximate"] is True

async def test_custom_dlq_maxlen_is_used(self):
redis = _redis_with_deliveries(times=6)
guard = ClaimDeadLetterGuard(
stream="events:clicks",
group="stats",
dlq_stream="events:clicks:dlq",
max_deliveries=5,
dlq_maxlen=100_000,
)
assert await guard.intercept(redis, "5-0", {}) is True
assert redis.xadd.await_args.kwargs["maxlen"] == 100_000

async def test_bytes_delivery_metadata_is_handled(self):
"""The broker's internal client speaks bytes."""
redis = AsyncMock()
Expand Down
1 change: 1 addition & 0 deletions workers/click_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,7 @@ def _register_group(
group=group,
dlq_stream=ce.dlq_stream,
max_deliveries=ce.max_deliveries,
dlq_maxlen=ce.dlq_maxlen,
)

async def reader(body: Any) -> None:
Expand Down
4 changes: 3 additions & 1 deletion workers/dlq.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,13 @@ def __init__(
group: str,
dlq_stream: str,
max_deliveries: int,
dlq_maxlen: int = DLQ_MAXLEN,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Two bounds exist now: this constant (10k, still the constructor default) and the config default (100k). Prod always takes the config path, so the default here is a fallback nobody uses, and the comment above DLQ_MAXLEN tells a different sizing story than the one in config.py. Drop the parameter default and let config be the single source; the tests already pass it explicitly.

) -> None:
self._stream = stream
self._group = group
self._dlq_stream = dlq_stream
self._max_deliveries = max_deliveries
self._dlq_maxlen = dlq_maxlen

async def intercept(self, redis: Any, message_id: str, payload: Any) -> bool:
"""Return True when the message was dead-lettered (skip processing).
Expand All @@ -68,7 +70,7 @@ async def intercept(self, redis: Any, message_id: str, payload: Any) -> bool:
DLQ_FIELD_GROUP: self._group,
DLQ_FIELD_REASON: _REASON_MAX_DELIVERIES,
},
maxlen=DLQ_MAXLEN,
maxlen=self._dlq_maxlen,
approximate=True,
)
except Exception as exc:
Expand Down