statepact 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- statepact/__about__.py +3 -0
- statepact/__init__.py +157 -0
- statepact/cli.py +82 -0
- statepact/concurrency/__init__.py +22 -0
- statepact/concurrency/cas.py +86 -0
- statepact/concurrency/conflict.py +121 -0
- statepact/concurrency/locking.py +314 -0
- statepact/concurrency/mvcc.py +98 -0
- statepact/concurrency/occ.py +66 -0
- statepact/concurrency/serializability.py +157 -0
- statepact/coordination/__init__.py +18 -0
- statepact/coordination/deadlock.py +80 -0
- statepact/coordination/fencing.py +60 -0
- statepact/coordination/lease.py +101 -0
- statepact/coordination/retry.py +132 -0
- statepact/coordination/wait_for_graph.py +85 -0
- statepact/core/__init__.py +83 -0
- statepact/core/context.py +51 -0
- statepact/core/exceptions.py +290 -0
- statepact/core/idempotency.py +254 -0
- statepact/core/interfaces.py +282 -0
- statepact/core/manager.py +274 -0
- statepact/core/resource.py +130 -0
- statepact/core/serde.py +47 -0
- statepact/core/session.py +584 -0
- statepact/core/transaction.py +231 -0
- statepact/core/version.py +62 -0
- statepact/integrations/__init__.py +21 -0
- statepact/integrations/autogen.py +123 -0
- statepact/integrations/base.py +155 -0
- statepact/integrations/crewai.py +139 -0
- statepact/integrations/langgraph.py +115 -0
- statepact/observability/__init__.py +20 -0
- statepact/observability/events.py +128 -0
- statepact/observability/metrics.py +159 -0
- statepact/observability/otel.py +188 -0
- statepact/observability/transaction_logs.py +173 -0
- statepact/py.typed +0 -0
- statepact/storage/__init__.py +5 -0
- statepact/storage/base.py +28 -0
- statepact/storage/memory.py +637 -0
- statepact/storage/postgres.py +906 -0
- statepact/storage/redis.py +837 -0
- statepact/testing/__init__.py +17 -0
- statepact/testing/concurrent_runner.py +110 -0
- statepact-0.1.0.dist-info/METADATA +447 -0
- statepact-0.1.0.dist-info/RECORD +50 -0
- statepact-0.1.0.dist-info/WHEEL +4 -0
- statepact-0.1.0.dist-info/entry_points.txt +2 -0
- statepact-0.1.0.dist-info/licenses/LICENSE +201 -0
statepact/__about__.py
ADDED
statepact/__init__.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""StatePact: transaction management for autonomous AI agents."""
|
|
2
|
+
|
|
3
|
+
from statepact.__about__ import __version__
|
|
4
|
+
from statepact.concurrency.cas import compare_and_delete, compare_and_swap
|
|
5
|
+
from statepact.concurrency.conflict import Conflict
|
|
6
|
+
from statepact.concurrency.locking import LockManager
|
|
7
|
+
from statepact.concurrency.serializability import SerializationGraphAudit
|
|
8
|
+
from statepact.coordination.fencing import FenceGuard
|
|
9
|
+
from statepact.coordination.lease import LeaseManager
|
|
10
|
+
from statepact.coordination.retry import RetryOutcome, RetryPolicy, run_with_retry
|
|
11
|
+
from statepact.core.context import current_session, require_session
|
|
12
|
+
from statepact.core.exceptions import (
|
|
13
|
+
AmbiguousResourceError,
|
|
14
|
+
BackendNotSupportedError,
|
|
15
|
+
BackendUnavailableError,
|
|
16
|
+
ConfigurationError,
|
|
17
|
+
ConflictError,
|
|
18
|
+
DeadlockError,
|
|
19
|
+
EffectFailedError,
|
|
20
|
+
EffectInProgressError,
|
|
21
|
+
IdempotencyError,
|
|
22
|
+
IdempotencyMismatchError,
|
|
23
|
+
KeyNotFoundError,
|
|
24
|
+
LeaseLostError,
|
|
25
|
+
LeaseNotAvailableError,
|
|
26
|
+
LockTimeoutError,
|
|
27
|
+
ReadWriteConflict,
|
|
28
|
+
ResourceError,
|
|
29
|
+
ResourceExistsError,
|
|
30
|
+
ResourceNotFoundError,
|
|
31
|
+
ResourceNotInSessionError,
|
|
32
|
+
RetryExhaustedError,
|
|
33
|
+
SerializationError,
|
|
34
|
+
SideEffectError,
|
|
35
|
+
StaleSnapshotError,
|
|
36
|
+
StatePactError,
|
|
37
|
+
StorageError,
|
|
38
|
+
TransactionAbortedError,
|
|
39
|
+
TransactionError,
|
|
40
|
+
TransactionStateError,
|
|
41
|
+
ValidationError,
|
|
42
|
+
WriteWriteConflict,
|
|
43
|
+
)
|
|
44
|
+
from statepact.core.idempotency import (
|
|
45
|
+
EffectRecord,
|
|
46
|
+
EffectState,
|
|
47
|
+
EffectStore,
|
|
48
|
+
IdempotentExecutor,
|
|
49
|
+
MemoryEffectStore,
|
|
50
|
+
run_idempotent,
|
|
51
|
+
)
|
|
52
|
+
from statepact.core.interfaces import (
|
|
53
|
+
ISOLATION_MODES,
|
|
54
|
+
CommitPlan,
|
|
55
|
+
CommitResult,
|
|
56
|
+
IsolationMode,
|
|
57
|
+
Lease,
|
|
58
|
+
ReadRecord,
|
|
59
|
+
ResourceSnapshot,
|
|
60
|
+
StorageBackend,
|
|
61
|
+
StoredValue,
|
|
62
|
+
WriteRecord,
|
|
63
|
+
)
|
|
64
|
+
from statepact.core.manager import (
|
|
65
|
+
TransactionManager,
|
|
66
|
+
default_manager,
|
|
67
|
+
reset_default_manager,
|
|
68
|
+
set_default_manager,
|
|
69
|
+
)
|
|
70
|
+
from statepact.core.resource import SharedResource
|
|
71
|
+
from statepact.core.session import AgentSession
|
|
72
|
+
from statepact.core.transaction import TransactionState
|
|
73
|
+
from statepact.observability.events import Event, EventBus, EventType
|
|
74
|
+
from statepact.observability.metrics import Metrics
|
|
75
|
+
from statepact.observability.transaction_logs import (
|
|
76
|
+
TransactionLog,
|
|
77
|
+
TransactionRecord,
|
|
78
|
+
TransactionStatus,
|
|
79
|
+
)
|
|
80
|
+
from statepact.storage.memory import MemoryBackend
|
|
81
|
+
|
|
82
|
+
__all__ = [
|
|
83
|
+
"ISOLATION_MODES",
|
|
84
|
+
"AgentSession",
|
|
85
|
+
"AmbiguousResourceError",
|
|
86
|
+
"BackendNotSupportedError",
|
|
87
|
+
"BackendUnavailableError",
|
|
88
|
+
"CommitPlan",
|
|
89
|
+
"CommitResult",
|
|
90
|
+
"ConfigurationError",
|
|
91
|
+
"Conflict",
|
|
92
|
+
"ConflictError",
|
|
93
|
+
"DeadlockError",
|
|
94
|
+
"EffectFailedError",
|
|
95
|
+
"EffectInProgressError",
|
|
96
|
+
"EffectRecord",
|
|
97
|
+
"EffectState",
|
|
98
|
+
"EffectStore",
|
|
99
|
+
"Event",
|
|
100
|
+
"EventBus",
|
|
101
|
+
"EventType",
|
|
102
|
+
"FenceGuard",
|
|
103
|
+
"IdempotencyError",
|
|
104
|
+
"IdempotencyMismatchError",
|
|
105
|
+
"IdempotentExecutor",
|
|
106
|
+
"IsolationMode",
|
|
107
|
+
"KeyNotFoundError",
|
|
108
|
+
"Lease",
|
|
109
|
+
"LeaseLostError",
|
|
110
|
+
"LeaseManager",
|
|
111
|
+
"LeaseNotAvailableError",
|
|
112
|
+
"LockManager",
|
|
113
|
+
"LockTimeoutError",
|
|
114
|
+
"MemoryBackend",
|
|
115
|
+
"MemoryEffectStore",
|
|
116
|
+
"Metrics",
|
|
117
|
+
"ReadRecord",
|
|
118
|
+
"ReadWriteConflict",
|
|
119
|
+
"ResourceError",
|
|
120
|
+
"ResourceExistsError",
|
|
121
|
+
"ResourceNotFoundError",
|
|
122
|
+
"ResourceNotInSessionError",
|
|
123
|
+
"ResourceSnapshot",
|
|
124
|
+
"RetryExhaustedError",
|
|
125
|
+
"RetryOutcome",
|
|
126
|
+
"RetryPolicy",
|
|
127
|
+
"SerializationError",
|
|
128
|
+
"SerializationGraphAudit",
|
|
129
|
+
"SharedResource",
|
|
130
|
+
"SideEffectError",
|
|
131
|
+
"StaleSnapshotError",
|
|
132
|
+
"StatePactError",
|
|
133
|
+
"StorageBackend",
|
|
134
|
+
"StorageError",
|
|
135
|
+
"StoredValue",
|
|
136
|
+
"TransactionAbortedError",
|
|
137
|
+
"TransactionError",
|
|
138
|
+
"TransactionLog",
|
|
139
|
+
"TransactionManager",
|
|
140
|
+
"TransactionRecord",
|
|
141
|
+
"TransactionState",
|
|
142
|
+
"TransactionStateError",
|
|
143
|
+
"TransactionStatus",
|
|
144
|
+
"ValidationError",
|
|
145
|
+
"WriteRecord",
|
|
146
|
+
"WriteWriteConflict",
|
|
147
|
+
"__version__",
|
|
148
|
+
"compare_and_delete",
|
|
149
|
+
"compare_and_swap",
|
|
150
|
+
"current_session",
|
|
151
|
+
"default_manager",
|
|
152
|
+
"require_session",
|
|
153
|
+
"reset_default_manager",
|
|
154
|
+
"run_idempotent",
|
|
155
|
+
"run_with_retry",
|
|
156
|
+
"set_default_manager",
|
|
157
|
+
]
|
statepact/cli.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Command line entry point: ``statepact``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import platform
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
|
|
12
|
+
from statepact.__about__ import __version__
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _info() -> int:
|
|
16
|
+
from statepact.observability.events import EventBus
|
|
17
|
+
from statepact.storage.memory import MemoryBackend
|
|
18
|
+
|
|
19
|
+
backend = MemoryBackend(event_bus=EventBus())
|
|
20
|
+
payload = {
|
|
21
|
+
"statepact": __version__,
|
|
22
|
+
"python": sys.version.split()[0],
|
|
23
|
+
"platform": platform.platform(),
|
|
24
|
+
"backend": backend.name,
|
|
25
|
+
"capabilities": {
|
|
26
|
+
"mvcc": backend.supports_mvcc,
|
|
27
|
+
"locking": backend.supports_locking,
|
|
28
|
+
"leases": backend.supports_leases,
|
|
29
|
+
"idempotency": backend.supports_idempotency,
|
|
30
|
+
"durable": backend.durable,
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
print(json.dumps(payload, indent=2))
|
|
34
|
+
return 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def _smoke() -> int:
|
|
38
|
+
from statepact import TransactionManager
|
|
39
|
+
|
|
40
|
+
manager = TransactionManager()
|
|
41
|
+
inventory = manager.resource("inventory", {"sku": 1})
|
|
42
|
+
async with manager.session(agent_id="smoke", resources=[inventory]) as session:
|
|
43
|
+
stock = await session.read("sku")
|
|
44
|
+
await session.write("sku", stock - 1)
|
|
45
|
+
result = await session.commit()
|
|
46
|
+
remaining = await inventory.read("sku")
|
|
47
|
+
ok = remaining == 0 and result.committed
|
|
48
|
+
print(
|
|
49
|
+
json.dumps(
|
|
50
|
+
{
|
|
51
|
+
"ok": ok,
|
|
52
|
+
"remaining": remaining,
|
|
53
|
+
"commit_version": result.commit_version,
|
|
54
|
+
"transaction_id": result.transaction_id,
|
|
55
|
+
},
|
|
56
|
+
indent=2,
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
await manager.close()
|
|
60
|
+
return 0 if ok else 1
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
64
|
+
parser = argparse.ArgumentParser(
|
|
65
|
+
prog="statepact", description="Transaction management for autonomous AI agents"
|
|
66
|
+
)
|
|
67
|
+
parser.add_argument("--version", action="version", version=f"statepact {__version__}")
|
|
68
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
69
|
+
subparsers.add_parser("info", help="print version, platform and backend capabilities")
|
|
70
|
+
subparsers.add_parser("smoke", help="run a one-transaction installation smoke test")
|
|
71
|
+
args = parser.parse_args(argv)
|
|
72
|
+
|
|
73
|
+
if args.command == "info":
|
|
74
|
+
return _info()
|
|
75
|
+
if args.command == "smoke":
|
|
76
|
+
return asyncio.run(_smoke())
|
|
77
|
+
parser.print_help()
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__": # pragma: no cover
|
|
82
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Concurrency control algorithms: OCC, MVCC, 2PL, conflict detection, CAS."""
|
|
2
|
+
|
|
3
|
+
from statepact.concurrency.cas import compare_and_swap
|
|
4
|
+
from statepact.concurrency.conflict import Conflict, detect
|
|
5
|
+
from statepact.concurrency.locking import LockManager
|
|
6
|
+
from statepact.concurrency.mvcc import VersionEntry, snapshot_values, visible_entry
|
|
7
|
+
from statepact.concurrency.serializability import (
|
|
8
|
+
SerializationGraphAudit,
|
|
9
|
+
TransactionFootprint,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Conflict",
|
|
14
|
+
"LockManager",
|
|
15
|
+
"SerializationGraphAudit",
|
|
16
|
+
"TransactionFootprint",
|
|
17
|
+
"VersionEntry",
|
|
18
|
+
"compare_and_swap",
|
|
19
|
+
"detect",
|
|
20
|
+
"snapshot_values",
|
|
21
|
+
"visible_entry",
|
|
22
|
+
]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Compare-and-swap helper built on the transaction engine.
|
|
2
|
+
|
|
3
|
+
CAS is the smallest useful primitive for agents: "set this key to a new value only
|
|
4
|
+
if it is still at the version I observed". It is implemented as a one-operation
|
|
5
|
+
transaction so it inherits every backend's atomic validation; it is not a separate
|
|
6
|
+
code path that could silently diverge.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from statepact.core.exceptions import ConflictError
|
|
14
|
+
from statepact.core.interfaces import StorageBackend
|
|
15
|
+
from statepact.core.serde import normalize_value
|
|
16
|
+
from statepact.core.transaction import Transaction, new_transaction_id, utcnow
|
|
17
|
+
from statepact.core.version import UNVERSIONED, Version
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def compare_and_swap(
|
|
21
|
+
backend: StorageBackend,
|
|
22
|
+
resource: str,
|
|
23
|
+
key: str,
|
|
24
|
+
*,
|
|
25
|
+
expected_version: Version,
|
|
26
|
+
value: Any,
|
|
27
|
+
agent_id: str = "cas",
|
|
28
|
+
) -> bool:
|
|
29
|
+
"""Atomically write ``value`` iff ``key`` is at ``expected_version``.
|
|
30
|
+
|
|
31
|
+
``expected_version=0`` means "only if the key does not exist" (insert).
|
|
32
|
+
Returns ``True`` on success and ``False`` on a version conflict. Backend or
|
|
33
|
+
configuration errors are never swallowed.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
normalized = normalize_value(value)
|
|
37
|
+
transaction = Transaction(
|
|
38
|
+
id=new_transaction_id(),
|
|
39
|
+
agent_id=agent_id,
|
|
40
|
+
isolation="optimistic",
|
|
41
|
+
resources=(resource,),
|
|
42
|
+
started_at=utcnow(),
|
|
43
|
+
fence=None,
|
|
44
|
+
)
|
|
45
|
+
transaction.stage_write(resource, key, normalized, expected_version)
|
|
46
|
+
transaction.record_read(resource, key, expected_version)
|
|
47
|
+
transaction.mark_committing()
|
|
48
|
+
try:
|
|
49
|
+
await backend.commit(transaction.to_plan())
|
|
50
|
+
except ConflictError:
|
|
51
|
+
transaction.mark_aborted("compare-and-swap conflict")
|
|
52
|
+
return False
|
|
53
|
+
return True
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def compare_and_delete(
|
|
57
|
+
backend: StorageBackend,
|
|
58
|
+
resource: str,
|
|
59
|
+
key: str,
|
|
60
|
+
*,
|
|
61
|
+
expected_version: Version,
|
|
62
|
+
agent_id: str = "cas",
|
|
63
|
+
) -> bool:
|
|
64
|
+
"""Delete ``key`` iff it is still at ``expected_version``."""
|
|
65
|
+
|
|
66
|
+
transaction = Transaction(
|
|
67
|
+
id=new_transaction_id(),
|
|
68
|
+
agent_id=agent_id,
|
|
69
|
+
isolation="optimistic",
|
|
70
|
+
resources=(resource,),
|
|
71
|
+
started_at=utcnow(),
|
|
72
|
+
fence=None,
|
|
73
|
+
)
|
|
74
|
+
transaction.stage_write(resource, key, None, expected_version, op="delete")
|
|
75
|
+
transaction.record_read(resource, key, expected_version)
|
|
76
|
+
transaction.mark_committing()
|
|
77
|
+
try:
|
|
78
|
+
await backend.commit(transaction.to_plan())
|
|
79
|
+
except ConflictError:
|
|
80
|
+
transaction.mark_aborted("compare-and-delete conflict")
|
|
81
|
+
return False
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def is_absent(version: Version) -> bool:
|
|
86
|
+
return version == UNVERSIONED
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Conflict classification and detection.
|
|
2
|
+
|
|
3
|
+
The detector works on ``(resource, key) -> latest version`` maps supplied by the
|
|
4
|
+
backend *inside* its atomic commit section. Keeping it pure lets the same rules
|
|
5
|
+
be exercised by unit and property tests without a backend.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping, Sequence
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from statepact.core.exceptions import (
|
|
14
|
+
ConflictClass,
|
|
15
|
+
ConflictError,
|
|
16
|
+
ReadWriteConflict,
|
|
17
|
+
WriteWriteConflict,
|
|
18
|
+
)
|
|
19
|
+
from statepact.core.interfaces import CommitPlan, ReadRecord, WriteRecord
|
|
20
|
+
from statepact.core.version import Version
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class Conflict:
|
|
25
|
+
"""A detected version conflict, before it is turned into an exception."""
|
|
26
|
+
|
|
27
|
+
kind: ConflictClass
|
|
28
|
+
transaction_id: str
|
|
29
|
+
resource: str
|
|
30
|
+
key: str
|
|
31
|
+
expected_version: Version
|
|
32
|
+
actual_version: Version
|
|
33
|
+
description: str
|
|
34
|
+
|
|
35
|
+
def to_exception(self) -> ConflictError:
|
|
36
|
+
kwargs: dict[str, object] = {
|
|
37
|
+
"transaction_id": self.transaction_id,
|
|
38
|
+
"resource": self.resource,
|
|
39
|
+
"key": self.key,
|
|
40
|
+
"expected_version": self.expected_version,
|
|
41
|
+
"actual_version": self.actual_version,
|
|
42
|
+
}
|
|
43
|
+
if self.kind == "read_write":
|
|
44
|
+
return ReadWriteConflict(**kwargs)
|
|
45
|
+
return WriteWriteConflict(**kwargs)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def check_read(record: ReadRecord, latest_version: Version, transaction_id: str) -> Conflict | None:
|
|
49
|
+
if latest_version == record.version:
|
|
50
|
+
return None
|
|
51
|
+
return Conflict(
|
|
52
|
+
kind="read_write",
|
|
53
|
+
transaction_id=transaction_id,
|
|
54
|
+
resource=record.resource,
|
|
55
|
+
key=record.key,
|
|
56
|
+
expected_version=record.version,
|
|
57
|
+
actual_version=latest_version,
|
|
58
|
+
description=(
|
|
59
|
+
f"{record.resource}.{record.key} was read at version {record.version} "
|
|
60
|
+
f"but is now at {latest_version}"
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def check_write(
|
|
66
|
+
record: WriteRecord, latest_version: Version, transaction_id: str
|
|
67
|
+
) -> Conflict | None:
|
|
68
|
+
if latest_version == record.expected_version:
|
|
69
|
+
return None
|
|
70
|
+
return Conflict(
|
|
71
|
+
kind="write_write",
|
|
72
|
+
transaction_id=transaction_id,
|
|
73
|
+
resource=record.resource,
|
|
74
|
+
key=record.key,
|
|
75
|
+
expected_version=record.expected_version,
|
|
76
|
+
actual_version=latest_version,
|
|
77
|
+
description=(
|
|
78
|
+
f"{record.resource}.{record.key} was staged on version "
|
|
79
|
+
f"{record.expected_version} but is now at {latest_version}"
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def detect(
|
|
85
|
+
plan: CommitPlan,
|
|
86
|
+
latest: Mapping[tuple[str, str], Version],
|
|
87
|
+
*,
|
|
88
|
+
validate_reads: bool,
|
|
89
|
+
validate_writes: bool,
|
|
90
|
+
) -> Conflict | None:
|
|
91
|
+
"""Return the first conflict in a stable (read order, then write order) scan."""
|
|
92
|
+
|
|
93
|
+
if validate_reads:
|
|
94
|
+
for read in plan.reads:
|
|
95
|
+
actual = latest.get((read.resource, read.key), 0)
|
|
96
|
+
conflict = check_read(read, actual, plan.transaction_id)
|
|
97
|
+
if conflict is not None:
|
|
98
|
+
return conflict
|
|
99
|
+
if validate_writes:
|
|
100
|
+
for write in plan.writes:
|
|
101
|
+
actual = latest.get((write.resource, write.key), 0)
|
|
102
|
+
conflict = check_write(write, actual, plan.transaction_id)
|
|
103
|
+
if conflict is not None:
|
|
104
|
+
return conflict
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True, slots=True)
|
|
109
|
+
class ValidationOutcome:
|
|
110
|
+
ok: bool
|
|
111
|
+
conflict: Conflict | None = None
|
|
112
|
+
|
|
113
|
+
def raise_if_conflict(self) -> None:
|
|
114
|
+
if self.conflict is not None:
|
|
115
|
+
raise self.conflict.to_exception()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def involved_keys(plan: CommitPlan) -> Sequence[tuple[str, str]]:
|
|
119
|
+
keys = {(read.resource, read.key) for read in plan.reads}
|
|
120
|
+
keys.update((write.resource, write.key) for write in plan.writes)
|
|
121
|
+
return tuple(sorted(keys))
|