feat: persist authored action idempotency (#201)
This commit is contained in:
parent
4cc00d2e48
commit
a5f63a6fa6
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,3 +2,4 @@ __pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.release-engine/
|
.release-engine/
|
||||||
|
.stackchain-state/
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,11 @@ Pull-request replies and mobile My Work issue and PR comments use Gitea's
|
||||||
issue-comment API; mobile issue capture requires issue
|
issue-comment API; mobile issue capture requires issue
|
||||||
creation and assignment permission. Issue capture and authored mobile actions (issue
|
creation and assignment permission. Issue capture and authored mobile actions (issue
|
||||||
comments, pull-request comments, notification replies, and reviews) persist per-draft
|
comments, pull-request comments, notification replies, and reviews) persist per-draft
|
||||||
idempotency keys, so retrying after a timeout or reload replays a confirmed result instead
|
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
|
||||||
of posting duplicate content. Direct API callers should preserve the `Idempotency-Key`
|
another worker replays a confirmed result instead of posting duplicate content. Results
|
||||||
|
are coordinated through a bounded SQLite ledger. Set `STACKCHAIN_STATE_DIR` to a
|
||||||
|
persistent, writable service directory (or set `STACKCHAIN_IDEMPOTENCY_DB` to an explicit
|
||||||
|
SQLite path); the local default is `.stackchain-state/idempotency.sqlite3`. Direct API callers should preserve the `Idempotency-Key`
|
||||||
header with the unchanged route and payload until a `201` response is confirmed. Closing an assigned issue, native Comment,
|
header with the unchanged route and payload until a `201` response is confirmed. Closing an assigned issue, native Comment,
|
||||||
Approve, and Request changes reviews, and assigned-PR merge require repository
|
Approve, and Request changes reviews, and assigned-PR merge require repository
|
||||||
write permission. Native Comment, Approve, and Request changes reviews support
|
write permission. Native Comment, Approve, and Request changes reviews support
|
||||||
|
|
|
||||||
114
src/idempotency.py
Normal file
114
src/idempotency.py
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Reservation:
|
||||||
|
state: str
|
||||||
|
response: Any = None
|
||||||
|
|
||||||
|
|
||||||
|
class IdempotencyLedger:
|
||||||
|
"""Small durable ledger for replaying successful Gitea mutations."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
path: str | Path,
|
||||||
|
*,
|
||||||
|
ttl_seconds: float,
|
||||||
|
max_entries: int,
|
||||||
|
clock: Callable[[], float] = time.time,
|
||||||
|
) -> None:
|
||||||
|
self.path = Path(path)
|
||||||
|
self.ttl_seconds = ttl_seconds
|
||||||
|
self.max_entries = max_entries
|
||||||
|
self.clock = clock
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._initialize()
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
connection = sqlite3.connect(self.path, timeout=5)
|
||||||
|
connection.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
return connection
|
||||||
|
|
||||||
|
def _initialize(self) -> None:
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS idempotency_operations (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
fingerprint TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK(status IN ('pending', 'completed')),
|
||||||
|
response_json TEXT,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
completed_at REAL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idempotency_completed_at_idx "
|
||||||
|
"ON idempotency_operations(completed_at)"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fingerprint(value: tuple[Any, ...]) -> str:
|
||||||
|
return json.dumps(value, separators=(",", ":"), sort_keys=True)
|
||||||
|
|
||||||
|
def reserve(self, key: str, fingerprint: tuple[Any, ...]) -> Reservation:
|
||||||
|
encoded = self._fingerprint(fingerprint)
|
||||||
|
now = self.clock()
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
connection.execute(
|
||||||
|
"DELETE FROM idempotency_operations "
|
||||||
|
"WHERE status = 'completed' AND completed_at <= ?",
|
||||||
|
(now - self.ttl_seconds,),
|
||||||
|
)
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT fingerprint, status, response_json FROM idempotency_operations "
|
||||||
|
"WHERE key = ?",
|
||||||
|
(key,),
|
||||||
|
).fetchone()
|
||||||
|
if row is not None:
|
||||||
|
if row[0] != encoded:
|
||||||
|
return Reservation("conflict")
|
||||||
|
if row[1] == "completed":
|
||||||
|
return Reservation("completed", json.loads(row[2]))
|
||||||
|
return Reservation("pending")
|
||||||
|
count = connection.execute(
|
||||||
|
"SELECT COUNT(*) FROM idempotency_operations"
|
||||||
|
).fetchone()[0]
|
||||||
|
if count >= self.max_entries:
|
||||||
|
completed = connection.execute(
|
||||||
|
"SELECT key FROM idempotency_operations "
|
||||||
|
"WHERE status = 'completed' ORDER BY completed_at, rowid LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
if completed is None:
|
||||||
|
return Reservation("busy")
|
||||||
|
connection.execute(
|
||||||
|
"DELETE FROM idempotency_operations WHERE key = ?",
|
||||||
|
(completed[0],),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO idempotency_operations "
|
||||||
|
"(key, fingerprint, status, created_at) VALUES (?, ?, 'pending', ?)",
|
||||||
|
(key, encoded, now),
|
||||||
|
)
|
||||||
|
return Reservation("reserved")
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("DELETE FROM idempotency_operations")
|
||||||
|
|
||||||
|
def complete(self, key: str, response: Any) -> None:
|
||||||
|
encoded = json.dumps(response, separators=(",", ":"), sort_keys=True)
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE idempotency_operations SET status = 'completed', "
|
||||||
|
"response_json = ?, completed_at = ? WHERE key = ?",
|
||||||
|
(encoded, self.clock(), key),
|
||||||
|
)
|
||||||
136
src/main.py
136
src/main.py
|
|
@ -1,5 +1,6 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import math
|
import math
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
from collections.abc import Awaitable, Coroutine
|
from collections.abc import Awaitable, Coroutine
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
@ -24,6 +25,7 @@ from src.gitea_proxy import (
|
||||||
pull_review_detail,
|
pull_review_detail,
|
||||||
repos,
|
repos,
|
||||||
)
|
)
|
||||||
|
from src.idempotency import IdempotencyLedger
|
||||||
from src.models import Issue, PullRequest, Repo, User
|
from src.models import Issue, PullRequest, Repo, User
|
||||||
from src.suggestion_engine import compute
|
from src.suggestion_engine import compute
|
||||||
from src.views import router as frontend_router
|
from src.views import router as frontend_router
|
||||||
|
|
@ -90,12 +92,18 @@ _live_section_retry_at: dict[str, float | None] = {
|
||||||
}
|
}
|
||||||
_live_snapshot_refreshing_sections: set[str] = set()
|
_live_snapshot_refreshing_sections: set[str] = set()
|
||||||
_read_notification_ids: set[int] = set()
|
_read_notification_ids: set[int] = set()
|
||||||
_issue_creation_operations: dict[
|
|
||||||
str, tuple[tuple[Any, ...], asyncio.Task, float]
|
|
||||||
] = {}
|
|
||||||
_authored_action_operations: dict[
|
_authored_action_operations: dict[
|
||||||
str, tuple[tuple[Any, ...], asyncio.Task, float]
|
str, tuple[tuple[Any, ...], asyncio.Task, float]
|
||||||
] = {}
|
] = {}
|
||||||
|
_state_dir = Path(os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state"))
|
||||||
|
_idempotency_ledger = IdempotencyLedger(
|
||||||
|
os.getenv("STACKCHAIN_IDEMPOTENCY_DB", str(_state_dir / "idempotency.sqlite3")),
|
||||||
|
ttl_seconds=AUTHORED_ACTION_IDEMPOTENCY_TTL_SECONDS,
|
||||||
|
max_entries=(
|
||||||
|
AUTHORED_ACTION_IDEMPOTENCY_MAX_ENTRIES
|
||||||
|
+ ISSUE_CREATION_IDEMPOTENCY_MAX_ENTRIES
|
||||||
|
),
|
||||||
|
)
|
||||||
_available_issue_snapshot_task: asyncio.Task | None = None
|
_available_issue_snapshot_task: asyncio.Task | None = None
|
||||||
_available_issue_snapshot_value: list[dict] | None = None
|
_available_issue_snapshot_value: list[dict] | None = None
|
||||||
_available_issue_snapshot_created_at: float | None = None
|
_available_issue_snapshot_created_at: float | None = None
|
||||||
|
|
@ -236,39 +244,52 @@ async def _run_idempotent_authored_action(
|
||||||
if not idempotency_key:
|
if not idempotency_key:
|
||||||
return await asyncio.wait_for(operation, timeout=timeout)
|
return await asyncio.wait_for(operation, timeout=timeout)
|
||||||
|
|
||||||
now = time.monotonic()
|
reservation = _idempotency_ledger.reserve(idempotency_key, fingerprint)
|
||||||
expired = [
|
if reservation.state == "conflict":
|
||||||
key for key, (_, task, created_at) in _authored_action_operations.items()
|
operation.close()
|
||||||
if task.done() and now - created_at >= AUTHORED_ACTION_IDEMPOTENCY_TTL_SECONDS
|
raise HTTPException(status_code=409, detail="Idempotency key already used")
|
||||||
]
|
if reservation.state == "completed":
|
||||||
for key in expired:
|
operation.close()
|
||||||
_authored_action_operations.pop(key, None)
|
return reservation.response
|
||||||
|
if reservation.state == "busy":
|
||||||
|
operation.close()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Authored action queue is busy; please retry",
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
|
||||||
existing = _authored_action_operations.get(idempotency_key)
|
existing = _authored_action_operations.get(idempotency_key)
|
||||||
if existing is not None:
|
if reservation.state == "pending":
|
||||||
operation.close()
|
operation.close()
|
||||||
if existing[0] != fingerprint:
|
if existing is None or existing[0] != fingerprint:
|
||||||
raise HTTPException(status_code=409, detail="Idempotency key already used")
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail=(
|
||||||
|
"This action may still be completing; verify its result before retrying"
|
||||||
|
),
|
||||||
|
headers={"Retry-After": "5"},
|
||||||
|
)
|
||||||
task = existing[1]
|
task = existing[1]
|
||||||
else:
|
else:
|
||||||
while len(_authored_action_operations) >= AUTHORED_ACTION_IDEMPOTENCY_MAX_ENTRIES:
|
async def persist_result():
|
||||||
completed = [
|
result = await operation
|
||||||
key for key, (_, task, _) in _authored_action_operations.items()
|
_idempotency_ledger.complete(idempotency_key, result)
|
||||||
if task.done()
|
return result
|
||||||
]
|
|
||||||
if not completed:
|
task = asyncio.create_task(persist_result())
|
||||||
operation.close()
|
_authored_action_operations[idempotency_key] = (
|
||||||
raise HTTPException(
|
fingerprint,
|
||||||
status_code=503,
|
task,
|
||||||
detail="Authored action queue is busy; please retry",
|
time.monotonic(),
|
||||||
headers={"Retry-After": "1"},
|
)
|
||||||
)
|
|
||||||
oldest = min(
|
def discard_completed(completed: asyncio.Task) -> None:
|
||||||
completed, key=lambda key: _authored_action_operations[key][2]
|
existing_operation = _authored_action_operations.get(idempotency_key)
|
||||||
)
|
if existing_operation is not None and existing_operation[1] is completed:
|
||||||
_authored_action_operations.pop(oldest)
|
_authored_action_operations.pop(idempotency_key, None)
|
||||||
task = asyncio.create_task(operation)
|
|
||||||
_authored_action_operations[idempotency_key] = (fingerprint, task, now)
|
task.add_done_callback(discard_completed)
|
||||||
return await asyncio.wait_for(asyncio.shield(task), timeout=timeout)
|
return await asyncio.wait_for(asyncio.shield(task), timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1269,48 +1290,19 @@ async def create_assigned_issue(
|
||||||
repository, creation.title, creation.body, login, creation.label_ids
|
repository, creation.title, creation.body, login, creation.label_ids
|
||||||
)
|
)
|
||||||
|
|
||||||
operation = create_issue()
|
|
||||||
if idempotency_key:
|
|
||||||
now = time.monotonic()
|
|
||||||
expired = [
|
|
||||||
key for key, (_, task, created_at) in _issue_creation_operations.items()
|
|
||||||
if task.done()
|
|
||||||
and now - created_at >= ISSUE_CREATION_IDEMPOTENCY_TTL_SECONDS
|
|
||||||
]
|
|
||||||
for key in expired:
|
|
||||||
_issue_creation_operations.pop(key, None)
|
|
||||||
fingerprint = (
|
|
||||||
repository, creation.title, creation.body, tuple(creation.label_ids)
|
|
||||||
)
|
|
||||||
existing = _issue_creation_operations.get(idempotency_key)
|
|
||||||
if existing is not None:
|
|
||||||
operation.close()
|
|
||||||
if existing[0] != fingerprint:
|
|
||||||
raise HTTPException(status_code=409, detail="Idempotency key already used")
|
|
||||||
task = existing[1]
|
|
||||||
else:
|
|
||||||
while len(_issue_creation_operations) >= ISSUE_CREATION_IDEMPOTENCY_MAX_ENTRIES:
|
|
||||||
completed = [
|
|
||||||
key for key, (_, task, _) in _issue_creation_operations.items()
|
|
||||||
if task.done()
|
|
||||||
]
|
|
||||||
if not completed:
|
|
||||||
operation.close()
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail="Issue creation is busy; please retry",
|
|
||||||
headers={"Retry-After": "1"},
|
|
||||||
)
|
|
||||||
oldest = min(
|
|
||||||
completed, key=lambda key: _issue_creation_operations[key][2]
|
|
||||||
)
|
|
||||||
_issue_creation_operations.pop(oldest)
|
|
||||||
task = asyncio.create_task(operation)
|
|
||||||
_issue_creation_operations[idempotency_key] = (fingerprint, task, now)
|
|
||||||
operation = asyncio.shield(task)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await asyncio.wait_for(operation, timeout=ISSUE_ACTION_TIMEOUT_SECONDS)
|
result = await _run_idempotent_authored_action(
|
||||||
|
create_issue(),
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
fingerprint=(
|
||||||
|
"issue-create",
|
||||||
|
repository,
|
||||||
|
creation.title,
|
||||||
|
creation.body,
|
||||||
|
tuple(creation.label_ids),
|
||||||
|
),
|
||||||
|
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,16 @@ import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src import main
|
from src import main
|
||||||
|
from src.idempotency import IdempotencyLedger
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def clear_authored_operations():
|
def clear_authored_operations():
|
||||||
main._authored_action_operations.clear()
|
main._authored_action_operations.clear()
|
||||||
|
main._idempotency_ledger.clear()
|
||||||
yield
|
yield
|
||||||
main._authored_action_operations.clear()
|
main._authored_action_operations.clear()
|
||||||
|
main._idempotency_ledger.clear()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
@ -140,3 +143,88 @@ async def test_review_replays_same_key_and_rejects_changed_head(monkeypatch):
|
||||||
|
|
||||||
assert [created.status_code, replayed.status_code, conflict.status_code] == [201, 201, 409]
|
assert [created.status_code, replayed.status_code, conflict.status_code] == [201, 201, 409]
|
||||||
assert calls == [("abc", "approve", "Good")]
|
assert calls == [("abc", "approve", "Good")]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_completed_comment_replays_after_ledger_reconstruction(monkeypatch, tmp_path):
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
async def assigned(_repository, _number):
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def comment(_repository, _number, body):
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
return {"id": 201, "body": body}
|
||||||
|
|
||||||
|
database = tmp_path / "actions.sqlite3"
|
||||||
|
monkeypatch.setattr(
|
||||||
|
main,
|
||||||
|
"_idempotency_ledger",
|
||||||
|
IdempotencyLedger(database, ttl_seconds=600, max_entries=256),
|
||||||
|
raising=False,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
headers = {"Idempotency-Key": "restart-comment-201"}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
created = await client.post(
|
||||||
|
"/api/v1/repos/stackchain/api/issues/7/comments",
|
||||||
|
json={"body": "Persist me"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
main._authored_action_operations.clear()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
main,
|
||||||
|
"_idempotency_ledger",
|
||||||
|
IdempotencyLedger(database, ttl_seconds=600, max_entries=256),
|
||||||
|
)
|
||||||
|
replayed = await client.post(
|
||||||
|
"/api/v1/repos/stackchain/api/issues/7/comments",
|
||||||
|
json={"body": "Persist me"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [created.status_code, replayed.status_code] == [201, 201]
|
||||||
|
assert replayed.json() == {"id": 201, "body": "Persist me"}
|
||||||
|
assert calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_orphaned_pending_comment_fails_closed_without_upstream_retry(
|
||||||
|
monkeypatch, tmp_path
|
||||||
|
):
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
async def assigned(_repository, _number):
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def comment(_repository, _number, body):
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
return {"id": 202, "body": body}
|
||||||
|
|
||||||
|
ledger = IdempotencyLedger(
|
||||||
|
tmp_path / "orphan.sqlite3", ttl_seconds=600, max_entries=256
|
||||||
|
)
|
||||||
|
fingerprint = ("issue-comment", "stackchain/api", 7, "Verify first")
|
||||||
|
assert ledger.reserve("orphan-comment-201", fingerprint).state == "reserved"
|
||||||
|
monkeypatch.setattr(main, "_idempotency_ledger", ledger)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/repos/stackchain/api/issues/7/comments",
|
||||||
|
json={"body": "Verify first"},
|
||||||
|
headers={"Idempotency-Key": "orphan-comment-201"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 503
|
||||||
|
assert response.json()["detail"] == (
|
||||||
|
"This action may still be completing; verify its result before retrying"
|
||||||
|
)
|
||||||
|
assert calls == 0
|
||||||
|
|
|
||||||
73
tests/test_idempotency_ledger.py
Normal file
73
tests/test_idempotency_ledger.py
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import sqlite3
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from threading import Barrier
|
||||||
|
|
||||||
|
from src.idempotency import IdempotencyLedger
|
||||||
|
|
||||||
|
|
||||||
|
def test_completed_result_replays_after_ledger_reconstruction(tmp_path):
|
||||||
|
database = tmp_path / "idempotency.sqlite3"
|
||||||
|
first = IdempotencyLedger(database, ttl_seconds=600, max_entries=256)
|
||||||
|
|
||||||
|
reservation = first.reserve("create-201", ("issue-create", "stackchain/api", "Ship it"))
|
||||||
|
assert reservation.state == "reserved"
|
||||||
|
first.complete("create-201", {"number": 42, "title": "Ship it"})
|
||||||
|
|
||||||
|
reconstructed = IdempotencyLedger(database, ttl_seconds=600, max_entries=256)
|
||||||
|
replay = reconstructed.reserve(
|
||||||
|
"create-201", ("issue-create", "stackchain/api", "Ship it")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert replay.state == "completed"
|
||||||
|
assert replay.response == {"number": 42, "title": "Ship it"}
|
||||||
|
assert sqlite3.connect(database).execute(
|
||||||
|
"SELECT status FROM idempotency_operations WHERE key = ?", ("create-201",)
|
||||||
|
).fetchone() == ("completed",)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reservation_expires_completed_rows_and_enforces_capacity(tmp_path):
|
||||||
|
now = [1_000.0]
|
||||||
|
ledger = IdempotencyLedger(
|
||||||
|
tmp_path / "bounded.sqlite3",
|
||||||
|
ttl_seconds=10,
|
||||||
|
max_entries=2,
|
||||||
|
clock=lambda: now[0],
|
||||||
|
)
|
||||||
|
assert ledger.reserve("old", ("comment", "old")).state == "reserved"
|
||||||
|
ledger.complete("old", {"id": 1})
|
||||||
|
now[0] += 11
|
||||||
|
|
||||||
|
assert ledger.reserve("new-1", ("comment", "new-1")).state == "reserved"
|
||||||
|
ledger.complete("new-1", {"id": 2})
|
||||||
|
assert ledger.reserve("new-2", ("comment", "new-2")).state == "reserved"
|
||||||
|
ledger.complete("new-2", {"id": 3})
|
||||||
|
replacement = ledger.reserve("new-3", ("comment", "new-3"))
|
||||||
|
|
||||||
|
assert replacement.state == "reserved"
|
||||||
|
with sqlite3.connect(ledger.path) as connection:
|
||||||
|
keys = connection.execute(
|
||||||
|
"SELECT key FROM idempotency_operations ORDER BY key"
|
||||||
|
).fetchall()
|
||||||
|
indexes = connection.execute(
|
||||||
|
"PRAGMA index_list(idempotency_operations)"
|
||||||
|
).fetchall()
|
||||||
|
assert keys == [("new-2",), ("new-3",)]
|
||||||
|
assert any(index[1] == "idempotency_completed_at_idx" for index in indexes)
|
||||||
|
|
||||||
|
|
||||||
|
def test_independent_connections_atomically_reserve_one_operation(tmp_path):
|
||||||
|
database = tmp_path / "workers.sqlite3"
|
||||||
|
ledgers = [
|
||||||
|
IdempotencyLedger(database, ttl_seconds=600, max_entries=256)
|
||||||
|
for _ in range(2)
|
||||||
|
]
|
||||||
|
barrier = Barrier(2)
|
||||||
|
|
||||||
|
def reserve(ledger):
|
||||||
|
barrier.wait()
|
||||||
|
return ledger.reserve("worker-key-201", ("review", "stackchain/api", 7)).state
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||||
|
states = list(executor.map(reserve, ledgers))
|
||||||
|
|
||||||
|
assert sorted(states) == ["pending", "reserved"]
|
||||||
|
|
@ -5,6 +5,7 @@ import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src import gitea_proxy, main
|
from src import gitea_proxy, main
|
||||||
|
from src.idempotency import IdempotencyLedger
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
@ -226,9 +227,11 @@ async def test_edit_assigned_issue_reports_revision_conflict_without_mutation(mo
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def clear_issue_creation_operations():
|
def clear_issue_creation_operations():
|
||||||
main._issue_creation_operations.clear()
|
main._authored_action_operations.clear()
|
||||||
|
main._idempotency_ledger.clear()
|
||||||
yield
|
yield
|
||||||
main._issue_creation_operations.clear()
|
main._authored_action_operations.clear()
|
||||||
|
main._idempotency_ledger.clear()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
@ -331,6 +334,61 @@ async def test_create_issue_replays_one_upstream_result_for_concurrent_idempoten
|
||||||
assert calls == 1
|
assert calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_completed_issue_creation_replays_after_ledger_reconstruction(monkeypatch, tmp_path):
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
async def user():
|
||||||
|
return {"login": "timmy"}
|
||||||
|
|
||||||
|
async def available_repos():
|
||||||
|
return [{"full_name": "stackchain/api"}]
|
||||||
|
|
||||||
|
async def create(repository, title, body, assignee, label_ids):
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
return {
|
||||||
|
"id": 201, "number": 201, "title": title, "state": "open",
|
||||||
|
"repository": repository, "labels": [], "assignees": [assignee],
|
||||||
|
"updated_at": "2026-08-07T15:00:00Z",
|
||||||
|
"url": "https://forge.example/stackchain/api/issues/201",
|
||||||
|
}
|
||||||
|
|
||||||
|
database = tmp_path / "issues.sqlite3"
|
||||||
|
monkeypatch.setattr(
|
||||||
|
main,
|
||||||
|
"_idempotency_ledger",
|
||||||
|
IdempotencyLedger(database, ttl_seconds=600, max_entries=256),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
headers = {"Idempotency-Key": "restart-create-201"}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
created = await client.post(
|
||||||
|
"/api/v1/repos/stackchain/api/issues",
|
||||||
|
json={"title": "Durable capture"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
main._authored_action_operations.clear()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
main,
|
||||||
|
"_idempotency_ledger",
|
||||||
|
IdempotencyLedger(database, ttl_seconds=600, max_entries=256),
|
||||||
|
)
|
||||||
|
replayed = await client.post(
|
||||||
|
"/api/v1/repos/stackchain/api/issues",
|
||||||
|
json={"title": "Durable capture"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [created.status_code, replayed.status_code] == [201, 201]
|
||||||
|
assert replayed.json()["number"] == 201
|
||||||
|
assert calls == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_create_issue_rejects_changed_payload_for_an_existing_idempotency_key(monkeypatch):
|
async def test_create_issue_rejects_changed_payload_for_an_existing_idempotency_key(monkeypatch):
|
||||||
calls = []
|
calls = []
|
||||||
|
|
@ -412,7 +470,9 @@ async def test_create_issue_retry_recovers_result_after_the_first_request_times_
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_create_issue_idempotency_registry_evicts_oldest_entry_at_size_limit(monkeypatch):
|
async def test_create_issue_durable_ledger_evicts_completed_entry_at_size_limit(
|
||||||
|
monkeypatch, tmp_path
|
||||||
|
):
|
||||||
async def user():
|
async def user():
|
||||||
return {"login": "timmy"}
|
return {"login": "timmy"}
|
||||||
|
|
||||||
|
|
@ -427,7 +487,10 @@ async def test_create_issue_idempotency_registry_evicts_oldest_entry_at_size_lim
|
||||||
"url": "https://forge.example/stackchain/api/issues/17",
|
"url": "https://forge.example/stackchain/api/issues/17",
|
||||||
}
|
}
|
||||||
|
|
||||||
monkeypatch.setattr(main, "ISSUE_CREATION_IDEMPOTENCY_MAX_ENTRIES", 2)
|
ledger = IdempotencyLedger(
|
||||||
|
tmp_path / "bounded-issues.sqlite3", ttl_seconds=600, max_entries=2
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(main, "_idempotency_ledger", ledger)
|
||||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||||
|
|
@ -441,8 +504,11 @@ async def test_create_issue_idempotency_registry_evicts_oldest_entry_at_size_lim
|
||||||
)
|
)
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
|
|
||||||
assert len(main._issue_creation_operations) == 2
|
replay = ledger.reserve(
|
||||||
assert "capture-177-bounded-0" not in main._issue_creation_operations
|
"capture-177-bounded-0",
|
||||||
|
("issue-create", "stackchain/api", "Capture 0", "", ()),
|
||||||
|
)
|
||||||
|
assert replay.state == "reserved"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user