Skip to content

Commit 2daeabc

Browse files
author
Nathan Gillett
committed
fix(sdk): serialize chain reserve, sign, and persist
record_chained_event holds the outbox lock from chain read through signing to commit so concurrent workers cannot reuse the same chain position. Signed-off-by: Nathan Gillett <nathan@intentproof.io>
1 parent 36b96be commit 2daeabc

3 files changed

Lines changed: 129 additions & 51 deletions

File tree

src/intentproof/instrumentation.py

Lines changed: 30 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import ulid as _ulid
1313

1414
from intentproof import client
15-
from intentproof.signing import SENTINEL_PREV_HASH, event_content_hash, sign_event
15+
from intentproof.signing import event_content_hash, sign_event
1616

1717
_correlation_id: ContextVar[str | None] = ContextVar(
1818
"intentproof_correlation_id", default=None
@@ -71,43 +71,35 @@ def _record_execution(
7171
error_obj: dict[str, Any] | None,
7272
) -> None:
7373
outbox = client.get_outbox()
74-
chain_pos = 1
75-
prev_hash = SENTINEL_PREV_HASH
76-
state = outbox.get_chain_state(correlation_id)
77-
if state:
78-
chain_pos = state["position"] + 1
79-
prev_hash = state["hash"]
80-
81-
event: dict[str, Any] = {
82-
"schema": "intentproof.event.v1",
83-
"event_id": event_id,
84-
"tenant_id": client.get_tenant_id(),
85-
"instance_id": client.get_instance_id(),
86-
"correlation_id": correlation_id,
87-
"provenance_class": "sdk_attested_evidence",
88-
"prev_event_hash": prev_hash,
89-
"chain_position": chain_pos,
90-
"intent": intent,
91-
"action": action,
92-
"status": status,
93-
"started_at": _iso_timestamp(t0_ms),
94-
"completed_at": _iso_timestamp(t1_ms),
95-
"duration_ms": t1_ms - t0_ms,
96-
"inputs": inputs,
97-
"output": output if status == "ok" else None,
98-
"error": error_obj,
99-
"attributes": {},
100-
"untrusted_payload": _untrusted_payload(inputs, output, status),
101-
"spec_version": "1.0.0",
102-
"sdk_version": client.SDK_VERSION,
103-
}
104-
105-
signed = sign_event(event, client.get_private_key(), client.get_instance_id())
106-
event_hash = event_content_hash(signed)
107-
108-
outbox.append_with_chain_state(
109-
event_id, signed, correlation_id, chain_pos, event_hash
110-
)
74+
75+
def build_signed(chain_pos: int, prev_hash: str) -> tuple[dict[str, Any], str]:
76+
event: dict[str, Any] = {
77+
"schema": "intentproof.event.v1",
78+
"event_id": event_id,
79+
"tenant_id": client.get_tenant_id(),
80+
"instance_id": client.get_instance_id(),
81+
"correlation_id": correlation_id,
82+
"provenance_class": "sdk_attested_evidence",
83+
"prev_event_hash": prev_hash,
84+
"chain_position": chain_pos,
85+
"intent": intent,
86+
"action": action,
87+
"status": status,
88+
"started_at": _iso_timestamp(t0_ms),
89+
"completed_at": _iso_timestamp(t1_ms),
90+
"duration_ms": t1_ms - t0_ms,
91+
"inputs": inputs,
92+
"output": output if status == "ok" else None,
93+
"error": error_obj,
94+
"attributes": {},
95+
"untrusted_payload": _untrusted_payload(inputs, output, status),
96+
"spec_version": "1.0.0",
97+
"sdk_version": client.SDK_VERSION,
98+
}
99+
signed = sign_event(event, client.get_private_key(), client.get_instance_id())
100+
return signed, event_content_hash(signed)
101+
102+
signed = outbox.record_chained_event(correlation_id, event_id, build_signed)
111103

112104
exporter = client.get_exporter()
113105
if exporter is not None:

src/intentproof/outbox.py

Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55
import json
66
import sqlite3
77
import threading
8+
from collections.abc import Callable
89
from typing import Any
910

11+
from intentproof.signing import SENTINEL_PREV_HASH
12+
1013

1114
class Outbox:
1215
def __init__(self, db_path: str) -> None:
@@ -38,6 +41,58 @@ def append(self, event_id: str, body: dict[str, Any]) -> None:
3841
)
3942
self._db.commit()
4043

44+
def _next_chain_link(self, correlation_id: str) -> tuple[int, str]:
45+
row = self._db.execute(
46+
"SELECT last_position, last_hash FROM chains WHERE correlation_id = ?",
47+
(correlation_id,),
48+
).fetchone()
49+
if row is None:
50+
return 1, SENTINEL_PREV_HASH
51+
return row[0] + 1, row[1]
52+
53+
def _persist_chain_event(
54+
self,
55+
event_id: str,
56+
body: dict[str, Any],
57+
correlation_id: str,
58+
position: int,
59+
event_hash: str,
60+
) -> None:
61+
self._db.execute(
62+
"INSERT INTO events (event_id, body) VALUES (?, ?)",
63+
(event_id, json.dumps(body)),
64+
)
65+
self._db.execute(
66+
"""
67+
INSERT INTO chains (correlation_id, last_position, last_hash)
68+
VALUES (?, ?, ?)
69+
ON CONFLICT(correlation_id) DO UPDATE SET
70+
last_position = excluded.last_position,
71+
last_hash = excluded.last_hash
72+
""",
73+
(correlation_id, position, event_hash),
74+
)
75+
76+
def record_chained_event(
77+
self,
78+
correlation_id: str,
79+
event_id: str,
80+
build_signed: Callable[[int, str], tuple[dict[str, Any], str]],
81+
) -> dict[str, Any]:
82+
"""Reserve chain slot, sign, and persist under one lock."""
83+
with self._lock:
84+
chain_pos, prev_hash = self._next_chain_link(correlation_id)
85+
signed, event_hash = build_signed(chain_pos, prev_hash)
86+
try:
87+
self._persist_chain_event(
88+
event_id, signed, correlation_id, chain_pos, event_hash
89+
)
90+
self._db.commit()
91+
except Exception:
92+
self._db.rollback()
93+
raise
94+
return signed
95+
4196
def append_with_chain_state(
4297
self,
4398
event_id: str,
@@ -49,19 +104,8 @@ def append_with_chain_state(
49104
"""Persist event and chain head in one transaction."""
50105
with self._lock:
51106
try:
52-
self._db.execute(
53-
"INSERT INTO events (event_id, body) VALUES (?, ?)",
54-
(event_id, json.dumps(body)),
55-
)
56-
self._db.execute(
57-
"""
58-
INSERT INTO chains (correlation_id, last_position, last_hash)
59-
VALUES (?, ?, ?)
60-
ON CONFLICT(correlation_id) DO UPDATE SET
61-
last_position = excluded.last_position,
62-
last_hash = excluded.last_hash
63-
""",
64-
(correlation_id, position, event_hash),
107+
self._persist_chain_event(
108+
event_id, body, correlation_id, position, event_hash
65109
)
66110
self._db.commit()
67111
except Exception:

tests/test_sdk.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,48 @@ def test_produces_signed_event_with_sentinel_prev_hash(
7979
assert ev["untrusted_payload"] is True
8080

8181

82+
def test_concurrent_wrap_preserves_chain_positions(
83+
sdk_dirs: tuple[str, str],
84+
) -> None:
85+
from intentproof.signing import event_content_hash
86+
87+
db_path, data_dir = sdk_dirs
88+
configure(db_path=db_path, data_dir=data_dir, tenant_id="tnt_a")
89+
fn = wrap(intent="Test", action="test.action", fn=lambda x: x)
90+
errors: list[BaseException] = []
91+
barrier = threading.Barrier(2)
92+
93+
def worker() -> None:
94+
try:
95+
barrier.wait(timeout=2.0)
96+
for _ in range(25):
97+
run_with_correlation_id("corr-race", lambda: fn(1))
98+
except BaseException as exc:
99+
errors.append(exc)
100+
101+
threads = [threading.Thread(target=worker) for _ in range(2)]
102+
for thread in threads:
103+
thread.start()
104+
for thread in threads:
105+
thread.join(timeout=5.0)
106+
107+
assert not errors
108+
events = [
109+
e
110+
for e in client.get_outbox().get_events()
111+
if e["correlation_id"] == "corr-race"
112+
]
113+
assert len(events) == 50
114+
positions = sorted(e["chain_position"] for e in events)
115+
assert positions == list(range(1, 51))
116+
117+
by_pos = {e["chain_position"]: e for e in events}
118+
assert by_pos[1]["prev_event_hash"].startswith("sha256:")
119+
for pos in range(2, 51):
120+
prev_hash = event_content_hash(by_pos[pos - 1])
121+
assert by_pos[pos]["prev_event_hash"] == prev_hash
122+
123+
82124
def test_wrap_records_from_worker_thread(sdk_dirs: tuple[str, str]) -> None:
83125
db_path, data_dir = sdk_dirs
84126
configure(db_path=db_path, data_dir=data_dir, tenant_id="tnt_a")

0 commit comments

Comments
 (0)