Skip to content

Commit 7fc8420

Browse files
authored
Merge pull request #35 from teams-notifier/feat/ooo-protection
feat: out-of-order protection
2 parents 6dc77f4 + 2dbf71b commit 7fc8420

33 files changed

Lines changed: 1459 additions & 395 deletions

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ misc
66
.coverage
77
.pytest_cache
88
*.pyc
9+
gitlab_mr_api.egg-info

‎.pre-commit-config.yaml‎

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
# See https://pre-commit.com for more information
22
# See https://pre-commit.com/hooks.html for more hooks
33
repos:
4-
- repo: https://github.com/psf/black
5-
rev: 24.10.0
4+
- repo: https://github.com/astral-sh/ruff-pre-commit
5+
rev: v0.8.3
66
hooks:
7-
- id: black
7+
- id: ruff
8+
args: [--fix, --exit-non-zero-on-fix]
9+
- id: ruff-format
810

911
- repo: https://github.com/pre-commit/pre-commit-hooks
1012
rev: v5.0.0
@@ -21,19 +23,6 @@ repos:
2123
- id: debug-statements
2224
language_version: python3
2325

24-
- repo: https://github.com/PyCQA/flake8
25-
rev: 7.1.1
26-
hooks:
27-
- id: flake8
28-
args: [--max-line-length, "110"]
29-
language_version: python3
30-
31-
- repo: https://github.com/asottile/reorder_python_imports
32-
rev: v3.13.0
33-
hooks:
34-
- id: reorder-python-imports
35-
args: ["--application-directories=.:src", "--py36-plus"]
36-
3726
- repo: https://github.com/asottile/pyupgrade
3827
rev: v3.17.0
3928
hooks:

‎app.py‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55
import sys
66
import traceback
77
import uuid
8+
89
from contextlib import asynccontextmanager
910
from typing import Annotated
1011

1112
import asyncpg
1213
import fastapi_structured_logging
1314
import httpx
15+
1416
from fastapi import FastAPI
1517
from fastapi import Header
1618
from fastapi import HTTPException
@@ -20,12 +22,15 @@
2022
from fastapi.responses import RedirectResponse
2123

2224
import webhook
25+
2326
from config import DefaultConfig
2427
from db import database
2528
from gitlab_model import EmojiPayload
2629
from gitlab_model import MergeRequestPayload
2730
from gitlab_model import PipelinePayload
2831
from periodic_cleanup import periodic_cleanup
32+
from webhook.merge_request import PartialMessageUpdateError
33+
2934

3035
config = DefaultConfig()
3136

@@ -163,7 +168,7 @@ async def handle_webhook(
163168
raise HTTPException(
164169
status_code=400,
165170
detail="filter_on_participant_ids must be a list of comma separated integers",
166-
)
171+
) from None
167172

168173
await webhook.merge_request(
169174
payload,
@@ -176,11 +181,22 @@ async def handle_webhook(
176181
if isinstance(payload, EmojiPayload):
177182
await webhook.emoji(payload, conversation_tokens)
178183
return {"status": "ok"}
184+
except PartialMessageUpdateError as exc:
185+
raise HTTPException(
186+
status_code=502,
187+
detail={
188+
"error": "partial_message_update_failure",
189+
"message": str(exc),
190+
"failed": exc.failed,
191+
"succeeded": exc.succeeded,
192+
"total": exc.total,
193+
},
194+
) from exc
179195
except httpx.HTTPStatusError as exc:
180196
raise HTTPException(
181197
status_code=exc.response.status_code,
182198
detail=exc.response.json(),
183-
)
199+
) from exc
184200

185201

186202
@app.get("/healthz", include_in_schema=False)
@@ -197,7 +213,7 @@ async def healthcheck():
197213
error_detail=str(e),
198214
exc_info=True,
199215
)
200-
raise HTTPException(status_code=500, detail=f"{type(e)}: {e}")
216+
raise HTTPException(status_code=500, detail=f"{type(e)}: {e}") from e
201217

202218

203219
if __name__ == "__main__":

‎cards/render.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
#!/usr/bin/env python3
22
import datetime
33
import json
4+
45
from enum import Enum
56
from typing import Any
67

78
import yaml
9+
810
from jinja2 import Environment
911
from jinja2 import FileSystemLoader
1012
from jinja2 import Template

‎config.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import dotenv
55

6+
67
dotenv.load_dotenv()
78

89
__all__ = ["DefaultConfig", "config"]
@@ -20,7 +21,7 @@ class DefaultConfig:
2021
_valid_tokens: list[str]
2122

2223
def __init__(self):
23-
self._valid_tokens = list([t.strip() for t in self.VALID_X_GITLAB_TOKEN.lower().split(",")])
24+
self._valid_tokens = [t.strip() for t in self.VALID_X_GITLAB_TOKEN.lower().split(",")]
2425
self.log_queries = False
2526
if len(self.LOG_QUERIES) and self.LOG_QUERIES[0].lower() in ("y", "t", "1"):
2627
self.log_queries = True

‎db.py‎

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,22 @@
22
import asyncio
33
import json
44
import urllib.parse
5+
56
from typing import Any
67
from typing import Literal
78

89
import asyncpg.connect_utils
910
import fastapi_structured_logging
11+
1012
from pydantic import BaseModel
1113

12-
from config import config
1314
from config import DefaultConfig
15+
from config import config
1416
from gitlab_model import GLEmojiAttributes
1517
from gitlab_model import MergeRequestPayload
1618
from gitlab_model import PipelinePayload
1719

20+
1821
log = fastapi_structured_logging.get_logger()
1922

2023
__all__ = ["database", "dbh"]
@@ -138,7 +141,77 @@ async def get_gitlab_instance_id_from_url(self, urlstr: str) -> int:
138141
assert isinstance(gli_id, int)
139142
return gli_id
140143

144+
async def get_or_create_merge_request_ref_id(self, merge_request: MergeRequestPayload) -> int:
145+
"""
146+
Get or create an MR ref without updating payload.
147+
148+
Used for OOO check - we need the ID to query message refs,
149+
but we don't want to corrupt the payload with stale data.
150+
Only sets initial state on INSERT, never updates existing records.
151+
"""
152+
gitlab_instance_id = await self.get_gitlab_instance_id_from_url(merge_request.object_attributes.url)
153+
154+
merge_ref_id = await self._generic_norm_upsert(
155+
table="merge_request_ref",
156+
identity_col="merge_request_ref_id",
157+
select_attrs={
158+
"gitlab_instance_id": gitlab_instance_id,
159+
"gitlab_project_id": merge_request.object_attributes.target_project_id,
160+
"gitlab_merge_request_iid": merge_request.object_attributes.iid,
161+
},
162+
insert_only_vals={
163+
"gitlab_merge_request_id": merge_request.object_attributes.id,
164+
"head_pipeline_id": merge_request.object_attributes.head_pipeline_id,
165+
"merge_request_payload": merge_request.model_dump(),
166+
"merge_request_extra_state": {
167+
"version": 1,
168+
"opener": {
169+
"id": merge_request.user.id,
170+
"name": merge_request.user.name,
171+
"username": merge_request.user.username,
172+
},
173+
"approvers": {},
174+
"pipeline_statuses": {},
175+
"emojis": {},
176+
},
177+
},
178+
)
179+
assert isinstance(merge_ref_id, int)
180+
return merge_ref_id
181+
182+
async def update_merge_request_ref_payload(
183+
self, merge_request_ref_id: int, merge_request: MergeRequestPayload
184+
) -> MergeRequestInfos:
185+
"""
186+
Update MR ref payload after OOO check has passed.
187+
188+
Called only for non-OOO events to update the stored payload.
189+
"""
190+
connection: asyncpg.Connection
191+
async with await database.acquire() as connection:
192+
row = await connection.fetchrow(
193+
"""UPDATE merge_request_ref
194+
SET gitlab_merge_request_id = $1,
195+
head_pipeline_id = $2,
196+
merge_request_payload = $3
197+
WHERE merge_request_ref_id = $4
198+
RETURNING merge_request_ref_id, merge_request_payload,
199+
merge_request_extra_state, head_pipeline_id""",
200+
merge_request.object_attributes.id,
201+
merge_request.object_attributes.head_pipeline_id,
202+
merge_request.model_dump(),
203+
merge_request_ref_id,
204+
)
205+
assert row is not None
206+
return MergeRequestInfos(**row)
207+
141208
async def get_merge_request_ref_infos(self, merge_request: MergeRequestPayload) -> MergeRequestInfos:
209+
"""
210+
Get or create MR ref AND update payload (legacy behavior).
211+
212+
Note: This updates payload on every call. For OOO-safe behavior,
213+
use get_or_create_merge_request_ref_id() + update_merge_request_ref_payload().
214+
"""
142215
gitlab_instance_id = await self.get_gitlab_instance_id_from_url(merge_request.object_attributes.url)
143216

144217
merge_ref = await self._generic_norm_upsert(
@@ -212,7 +285,6 @@ async def _generic_norm_upsert(
212285
insert_only_vals: dict[str, Any] | None = None,
213286
extra_sel_cols: list[str] | None = None,
214287
) -> Any:
215-
216288
if extra_insert_and_update_vals is None:
217289
extra_insert_and_update_vals = {}
218290

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-- migrate:up
2+
ALTER TABLE gitlab_mr_api.merge_request_message_ref
3+
ADD COLUMN last_processed_updated_at timestamp with time zone;
4+
5+
-- migrate:down
6+
ALTER TABLE gitlab_mr_api.merge_request_message_ref
7+
DROP COLUMN last_processed_updated_at;

‎db/schema.sql‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@ CREATE TABLE gitlab_mr_api.merge_request_message_ref (
8282
failure jsonb,
8383
created_at timestamp with time zone DEFAULT now() NOT NULL,
8484
updated_at timestamp with time zone,
85-
last_processed_fingerprint character varying(64)
85+
last_processed_fingerprint character varying(64),
86+
last_processed_updated_at timestamp with time zone
8687
);
8788

8889

‎gitlab_model.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class GLUser(BaseModel, extra="allow"):
2323

2424

2525
class GLProject(BaseModel, extra="allow"):
26+
id: int
2627
path_with_namespace: str
2728
web_url: str
2829

@@ -36,7 +37,7 @@ class GLMRAttributes(BaseModel, extra="allow"):
3637
state: str
3738
url: str
3839
action: str
39-
updated_at: str | None
40+
updated_at: str
4041
oldrev: str | None = None
4142

4243
# https://docs.gitlab.com/ee/api/merge_requests.html#merge-status
@@ -80,6 +81,7 @@ class PipelinePayload(BaseModel, extra="allow"):
8081
object_kind: Literal["pipeline"]
8182
object_attributes: GLPipelineAttributes
8283
builds: list[GLPipelineBuild]
84+
project: GLProject
8385

8486

8587
class GLEmojiAttributes(BaseModel, extra="allow"):

‎periodic_cleanup.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from config import DefaultConfig
1010
from db import DatabaseLifecycleHandler
1111

12+
1213
logger = fastapi_structured_logging.get_logger()
1314

1415
signal = asyncio.Event()

0 commit comments

Comments
 (0)