Skip to content

Commit 5eee9e9

Browse files
committed
Add script for retrying all missed job webhooks
1 parent a9c0f0e commit 5eee9e9

File tree

1 file changed

+121
-0
lines changed

1 file changed

+121
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
from collections import deque
2+
from concurrent.futures import Future, ThreadPoolExecutor, wait
3+
from dataclasses import dataclass
4+
from datetime import timedelta
5+
import re
6+
7+
from django.db import connections
8+
import djclick as click
9+
10+
from analytics.core.models import JobDataDimension
11+
from analytics.job_processor.utils import get_gitlab_handle
12+
13+
# The URL of the webhook handler service specified in the GitLab project settings.
14+
# This is the URL in the web_hook_logs table in the GitLab DB.
15+
WEBHOOK_URL = "http://webhook-handler.custom.svc.cluster.local"
16+
17+
18+
@dataclass
19+
class WebhookEvent:
20+
build_id: int
21+
project_id: int
22+
webhook_id: int
23+
webhook_event_id: int
24+
25+
def __str__(self) -> str:
26+
return f"build_id: {self.build_id}, project_id: {self.project_id}, webhook_id: {self.webhook_id}, webhook_event_id: {self.webhook_event_id}"
27+
28+
29+
def retry_webhook(webhook_event: WebhookEvent, dry_run: bool) -> None:
30+
if dry_run:
31+
click.echo(f"Would retry webhook {webhook_event}")
32+
return
33+
34+
click.echo(f"Retrying webhook {webhook_event}")
35+
gl = get_gitlab_handle()
36+
37+
# https://docs.gitlab.com/ee/api/project_webhooks.html#resend-a-project-webhook-event
38+
retry_url = f"/projects/{webhook_event.project_id}/hooks/{webhook_event.webhook_id}/events/{webhook_event.webhook_event_id}/resend"
39+
gl.http_post(retry_url)
40+
41+
42+
@click.command()
43+
@click.option(
44+
"--seconds",
45+
type=int,
46+
default=timedelta(days=1).total_seconds(),
47+
help="Retry webhooks that failed in the last N seconds",
48+
)
49+
@click.option(
50+
"--dry-run",
51+
is_flag=True,
52+
default=False,
53+
help="Print the webhooks that would be retried without actually retrying them",
54+
)
55+
def retry_failed_job_webhooks(seconds: int, dry_run: bool) -> None:
56+
with connections["gitlab"].cursor() as cursor:
57+
cursor.execute("BEGIN;")
58+
59+
cursor.execute(
60+
"""
61+
DECLARE webhook_cursor CURSOR FOR
62+
SELECT request_data, web_hook_id, id
63+
FROM public.web_hook_logs
64+
WHERE url = %s
65+
AND created_at > NOW() - INTERVAL %s;
66+
""",
67+
[WEBHOOK_URL, f"{seconds} seconds"],
68+
)
69+
70+
build_ids: deque[int] = deque([])
71+
72+
futures: list[Future] = []
73+
74+
with ThreadPoolExecutor() as executor:
75+
while True:
76+
# Fetch a batch of rows from the cursor
77+
cursor.execute("FETCH FORWARD %s FROM webhook_cursor", [5000])
78+
rows = cursor.fetchall()
79+
if not rows:
80+
break
81+
82+
webhook_events = [
83+
WebhookEvent(
84+
build_id=int(re.search(r"build_id: (\d+)", row[0]).group(1)),
85+
project_id=int(re.search(r"project_id: (\d+)", row[0]).group(1)),
86+
webhook_id=row[1],
87+
webhook_event_id=row[2],
88+
)
89+
for row in rows
90+
]
91+
92+
# Build a mapping of build ID to webhook event object for fast lookup by build ID
93+
build_id_to_webhook_mapping: dict[int, WebhookEvent] = {
94+
event.build_id: event for event in webhook_events
95+
}
96+
97+
# Collect all build IDs
98+
build_ids: set[int] = set(build_id_to_webhook_mapping.keys())
99+
100+
# Filter out build IDs that already have a corresponding JobDataDimension record
101+
existing_build_ids: set[int] = set(
102+
JobDataDimension.objects.filter(job_id__in=build_ids).values_list(
103+
"job_id", flat=True
104+
)
105+
)
106+
107+
# Calculate the missing build IDs
108+
missing_build_ids: set[int] = build_ids - existing_build_ids
109+
110+
# Retry the webhooks for the missing build IDs
111+
for build_id in missing_build_ids:
112+
futures.append(
113+
executor.submit(
114+
retry_webhook, build_id_to_webhook_mapping[build_id], dry_run
115+
)
116+
)
117+
118+
cursor.execute("CLOSE webhook_cursor;")
119+
cursor.execute("COMMIT;")
120+
121+
wait(futures)

0 commit comments

Comments
 (0)