-
-
Notifications
You must be signed in to change notification settings - Fork 50
fix: scope the self-link check to the destination host #287
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
16e1548
59fa86d
1f65cc8
68539b0
5bad2b6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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", | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: | ||
|
|
||
| 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 | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 📍 Affects 1 file
🤖 Prompt for AI Agents |
||
| 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() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -42,11 +42,13 @@ def __init__( | |
| group: str, | ||
| dlq_stream: str, | ||
| max_deliveries: int, | ||
| dlq_maxlen: int = DLQ_MAXLEN, | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| ) -> 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). | ||
|
|
@@ -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: | ||
|
|
||
There was a problem hiding this comment.
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:
Repository: spoo-me/spoo
Length of output: 30656
🏁 Script executed:
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:
Repository: spoo-me/spoo
Length of output: 13314
Apply
granularity: "hours"to existingclickscollections.When
clicksalready exists,create_collection()raises an expected error and the code skips the change. Add a dedicated, idempotentcollModmigration and test this path. MongoDB supports increasing time-series granularity withcollModon supported server versions.🤖 Prompt for AI Agents