deltav 0.0.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.
deltav/__init__.py ADDED
@@ -0,0 +1,53 @@
1
+ from .core import (
2
+ Breach,
3
+ Budget,
4
+ BudgetStore,
5
+ Decision,
6
+ Reservation,
7
+ Spend,
8
+ SpendRequest,
9
+ Usage,
10
+ UsageMap,
11
+ canonicalize,
12
+ check,
13
+ check_stack,
14
+ empty_usage,
15
+ release,
16
+ release_stack,
17
+ reserve,
18
+ reserve_stack,
19
+ settle,
20
+ settle_stack,
21
+ )
22
+ from .store_convex import ConvexBudgetStore, create_budget_operations
23
+ from .store_local import InMemoryBudgetStore
24
+ from .store_postgres import DriverBudgetStore, PostgresBudgetStore
25
+ from .store_remote import RemoteBudgetStore
26
+
27
+ __all__ = [
28
+ "Breach",
29
+ "Budget",
30
+ "BudgetStore",
31
+ "ConvexBudgetStore",
32
+ "Decision",
33
+ "DriverBudgetStore",
34
+ "InMemoryBudgetStore",
35
+ "PostgresBudgetStore",
36
+ "RemoteBudgetStore",
37
+ "Reservation",
38
+ "Spend",
39
+ "SpendRequest",
40
+ "Usage",
41
+ "UsageMap",
42
+ "canonicalize",
43
+ "check",
44
+ "check_stack",
45
+ "create_budget_operations",
46
+ "empty_usage",
47
+ "release",
48
+ "release_stack",
49
+ "reserve",
50
+ "reserve_stack",
51
+ "settle",
52
+ "settle_stack",
53
+ ]
deltav/core.py ADDED
@@ -0,0 +1,178 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass, field
5
+ from typing import Protocol
6
+
7
+ Spend = dict[str, float]
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Budget:
12
+ id: str
13
+ layer: str
14
+ limits: dict[str, float]
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Usage:
19
+ cumulative: Spend = field(default_factory=dict)
20
+ concurrent: Spend = field(default_factory=dict)
21
+ peak: Spend = field(default_factory=dict)
22
+ reserved: Spend = field(default_factory=dict)
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class SpendRequest:
27
+ estimate: Spend
28
+ run_id: str | None = None
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Reservation:
33
+ id: str
34
+ estimate: Spend
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Breach:
39
+ budget_id: str
40
+ layer: str
41
+ dimension: str
42
+ limit: float
43
+ would_be: float
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class Decision:
48
+ ok: bool
49
+ reservation: Reservation | None = None
50
+ breach: Breach | None = None
51
+
52
+
53
+ UsageMap = dict[str, Usage]
54
+ _next_reservation = 1
55
+
56
+
57
+ class BudgetStore(Protocol):
58
+ async def load(self, stack_id: str) -> tuple[list[Budget], UsageMap]: ...
59
+ async def try_reserve(self, stack_id: str, req: SpendRequest) -> Decision: ...
60
+ async def settle(self, stack_id: str, reservation: Reservation, actual: Spend) -> None: ...
61
+ async def release(self, stack_id: str, reservation: Reservation) -> None: ...
62
+ async def snapshot(self, stack_id: str) -> UsageMap: ...
63
+
64
+
65
+ def empty_usage() -> Usage:
66
+ return Usage()
67
+
68
+
69
+ def check(budget: Budget, usage: Usage, req: SpendRequest) -> Decision:
70
+ for dimension, amount in req.estimate.items():
71
+ limit = budget.limits.get(dimension)
72
+ if limit is None:
73
+ continue
74
+ if amount < 0:
75
+ return Decision(
76
+ ok=False,
77
+ breach=Breach(budget.id, budget.layer, dimension, 0, amount),
78
+ )
79
+ would_be = usage.cumulative.get(dimension, 0) + usage.reserved.get(dimension, 0) + amount
80
+ if would_be > limit:
81
+ return Decision(
82
+ ok=False,
83
+ breach=Breach(budget.id, budget.layer, dimension, limit, would_be),
84
+ )
85
+ return Decision(ok=True, reservation=_reservation(req.estimate))
86
+
87
+
88
+ def check_stack(stack: list[Budget], usages: UsageMap, req: SpendRequest) -> Decision:
89
+ reservation: Reservation | None = None
90
+ for budget in stack:
91
+ decision = check(budget, usages.get(budget.id, empty_usage()), req)
92
+ if not decision.ok:
93
+ return decision
94
+ reservation = decision.reservation
95
+ return Decision(ok=True, reservation=reservation or _reservation(req.estimate))
96
+
97
+
98
+ def reserve(usage: Usage, req: SpendRequest) -> Usage:
99
+ return Usage(
100
+ cumulative=dict(usage.cumulative),
101
+ concurrent=dict(usage.concurrent),
102
+ peak=dict(usage.peak),
103
+ reserved=_add_delta(usage.reserved, req.estimate),
104
+ )
105
+
106
+
107
+ def settle(usage: Usage, reservation: Reservation, actual: Spend) -> Usage:
108
+ cumulative = dict(usage.cumulative)
109
+ for dimension, amount in actual.items():
110
+ cumulative[dimension] = cumulative.get(dimension, 0) + amount
111
+ return Usage(
112
+ cumulative=cumulative,
113
+ concurrent=dict(usage.concurrent),
114
+ peak=dict(usage.peak),
115
+ reserved=_add_delta(usage.reserved, {k: -v for k, v in reservation.estimate.items()}),
116
+ )
117
+
118
+
119
+ def release(usage: Usage, reservation: Reservation) -> Usage:
120
+ return Usage(
121
+ cumulative=dict(usage.cumulative),
122
+ concurrent=dict(usage.concurrent),
123
+ peak=dict(usage.peak),
124
+ reserved=_add_delta(usage.reserved, {k: -v for k, v in reservation.estimate.items()}),
125
+ )
126
+
127
+
128
+ def reserve_stack(stack: list[Budget], usages: UsageMap, req: SpendRequest) -> UsageMap:
129
+ next_usages = dict(usages)
130
+ for budget in stack:
131
+ next_usages[budget.id] = reserve(next_usages.get(budget.id, empty_usage()), req)
132
+ return next_usages
133
+
134
+
135
+ def settle_stack(
136
+ stack: list[Budget], usages: UsageMap, reservation: Reservation, actual: Spend
137
+ ) -> UsageMap:
138
+ next_usages = dict(usages)
139
+ for budget in stack:
140
+ next_usages[budget.id] = settle(
141
+ next_usages.get(budget.id, empty_usage()), reservation, actual
142
+ )
143
+ return next_usages
144
+
145
+
146
+ def release_stack(stack: list[Budget], usages: UsageMap, reservation: Reservation) -> UsageMap:
147
+ next_usages = dict(usages)
148
+ for budget in stack:
149
+ next_usages[budget.id] = release(next_usages.get(budget.id, empty_usage()), reservation)
150
+ return next_usages
151
+
152
+
153
+ def canonicalize(value: object) -> str:
154
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), default=_json_default)
155
+
156
+
157
+ def _reservation(estimate: Spend) -> Reservation:
158
+ global _next_reservation
159
+ reservation = Reservation(id=f"reservation_{_next_reservation}", estimate=dict(estimate))
160
+ _next_reservation += 1
161
+ return reservation
162
+
163
+
164
+ def _add_delta(values: Spend, delta: Spend) -> Spend:
165
+ out = dict(values)
166
+ for dimension, amount in delta.items():
167
+ value = out.get(dimension, 0) + amount
168
+ if value == 0:
169
+ out.pop(dimension, None)
170
+ else:
171
+ out[dimension] = value
172
+ return out
173
+
174
+
175
+ def _json_default(value: object) -> object:
176
+ if hasattr(value, "__dict__"):
177
+ return value.__dict__
178
+ raise TypeError(f"Cannot serialize {type(value)!r}")
deltav/store_convex.py ADDED
@@ -0,0 +1,276 @@
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+ from typing import Any
5
+
6
+ from dockbay import (
7
+ ConvexOperationContext,
8
+ ConvexOperationDriver,
9
+ ConvexStoreOperation,
10
+ JsonValue,
11
+ )
12
+
13
+ from .core import (
14
+ Budget,
15
+ Decision,
16
+ Reservation,
17
+ Spend,
18
+ SpendRequest,
19
+ Usage,
20
+ UsageMap,
21
+ check_stack,
22
+ release_stack,
23
+ reserve_stack,
24
+ settle_stack,
25
+ )
26
+
27
+ LOAD = "budget.load"
28
+ TRY_RESERVE = "budget.tryReserve"
29
+ SETTLE = "budget.settle"
30
+ RELEASE = "budget.release"
31
+ SNAPSHOT = "budget.snapshot"
32
+
33
+ InitialState = dict[str, tuple[list[Budget], UsageMap]]
34
+ _StackState = dict[str, Any]
35
+
36
+
37
+ class ConvexBudgetStore:
38
+ def __init__(
39
+ self,
40
+ driver: ConvexOperationDriver,
41
+ *,
42
+ operations: dict[str, str] | None = None,
43
+ ) -> None:
44
+ self._driver = driver
45
+ self._operations = {
46
+ "load": LOAD,
47
+ "try_reserve": TRY_RESERVE,
48
+ "settle": SETTLE,
49
+ "release": RELEASE,
50
+ "snapshot": SNAPSHOT,
51
+ **(operations or {}),
52
+ }
53
+
54
+ async def load(self, stack_id: str) -> tuple[list[Budget], UsageMap]:
55
+ result = await self._driver.call(self._operations["load"], {"stackId": stack_id})
56
+ assert isinstance(result, dict)
57
+ return _row_to_stack(result), _row_to_usage_map(result["usages"])
58
+
59
+ async def try_reserve(self, stack_id: str, req: SpendRequest) -> Decision:
60
+ result = await self._driver.call(
61
+ self._operations["try_reserve"],
62
+ {"stackId": stack_id, "req": _spend_request_to_row(req)},
63
+ )
64
+ assert isinstance(result, dict)
65
+ return _row_to_decision(result)
66
+
67
+ async def settle(self, stack_id: str, reservation: Reservation, actual: Spend) -> None:
68
+ await self._driver.call(
69
+ self._operations["settle"],
70
+ {
71
+ "stackId": stack_id,
72
+ "reservation": _reservation_to_row(reservation),
73
+ "actual": actual,
74
+ },
75
+ )
76
+
77
+ async def release(self, stack_id: str, reservation: Reservation) -> None:
78
+ await self._driver.call(
79
+ self._operations["release"],
80
+ {"stackId": stack_id, "reservation": _reservation_to_row(reservation)},
81
+ )
82
+
83
+ async def snapshot(self, stack_id: str) -> UsageMap:
84
+ result = await self._driver.call(self._operations["snapshot"], {"stackId": stack_id})
85
+ return _row_to_usage_map(result)
86
+
87
+
88
+ def create_budget_operations(initial: InitialState) -> list[ConvexStoreOperation]:
89
+ state: dict[str, _StackState] = {
90
+ stack_id: {
91
+ "stack": [_budget_to_row(budget) for budget in stack],
92
+ "usages": _usage_map_to_row(usages),
93
+ "openReservations": [],
94
+ }
95
+ for stack_id, (stack, usages) in initial.items()
96
+ }
97
+
98
+ async def load(_ctx: ConvexOperationContext, input_value: JsonValue) -> JsonValue:
99
+ stack_id = _stack_id_from(input_value)
100
+ current = _require_stack(state, stack_id)
101
+ return {
102
+ "stack": deepcopy(current["stack"]),
103
+ "usages": deepcopy(current["usages"]),
104
+ }
105
+
106
+ async def try_reserve(_ctx: ConvexOperationContext, input_value: JsonValue) -> JsonValue:
107
+ assert isinstance(input_value, dict)
108
+ stack_id = _stack_id_from(input_value)
109
+ current = _require_stack(state, stack_id)
110
+ stack = _row_to_stack(current)
111
+ usages = _row_to_usage_map(current["usages"])
112
+ decision = check_stack(stack, usages, _row_to_spend_request(input_value["req"]))
113
+ if not decision.ok or decision.reservation is None:
114
+ return _decision_to_row(decision)
115
+ state[stack_id] = {
116
+ **current,
117
+ "usages": _usage_map_to_row(
118
+ reserve_stack(stack, usages, _row_to_spend_request(input_value["req"]))
119
+ ),
120
+ "openReservations": [*current["openReservations"], decision.reservation.id],
121
+ }
122
+ return _decision_to_row(decision)
123
+
124
+ async def settle(_ctx: ConvexOperationContext, input_value: JsonValue) -> JsonValue:
125
+ assert isinstance(input_value, dict)
126
+ stack_id = _stack_id_from(input_value)
127
+ current = _require_stack(state, stack_id)
128
+ reservation = _row_to_reservation(input_value["reservation"])
129
+ if reservation.id not in current["openReservations"]:
130
+ return None
131
+ stack = _row_to_stack(current)
132
+ usages = _row_to_usage_map(current["usages"])
133
+ state[stack_id] = {
134
+ **current,
135
+ "usages": _usage_map_to_row(
136
+ settle_stack(stack, usages, reservation, input_value["actual"])
137
+ ),
138
+ "openReservations": [
139
+ item for item in current["openReservations"] if item != reservation.id
140
+ ],
141
+ }
142
+ return None
143
+
144
+ async def release(_ctx: ConvexOperationContext, input_value: JsonValue) -> JsonValue:
145
+ assert isinstance(input_value, dict)
146
+ stack_id = _stack_id_from(input_value)
147
+ current = _require_stack(state, stack_id)
148
+ reservation = _row_to_reservation(input_value["reservation"])
149
+ if reservation.id not in current["openReservations"]:
150
+ return None
151
+ stack = _row_to_stack(current)
152
+ usages = _row_to_usage_map(current["usages"])
153
+ state[stack_id] = {
154
+ **current,
155
+ "usages": _usage_map_to_row(release_stack(stack, usages, reservation)),
156
+ "openReservations": [
157
+ item for item in current["openReservations"] if item != reservation.id
158
+ ],
159
+ }
160
+ return None
161
+
162
+ async def snapshot(_ctx: ConvexOperationContext, input_value: JsonValue) -> JsonValue:
163
+ stack_id = _stack_id_from(input_value)
164
+ return deepcopy(_require_stack(state, stack_id)["usages"])
165
+
166
+ return [
167
+ ConvexStoreOperation(name=LOAD, kind="query", run=load),
168
+ ConvexStoreOperation(name=TRY_RESERVE, kind="mutation", run=try_reserve),
169
+ ConvexStoreOperation(name=SETTLE, kind="mutation", run=settle),
170
+ ConvexStoreOperation(name=RELEASE, kind="mutation", run=release),
171
+ ConvexStoreOperation(name=SNAPSHOT, kind="query", run=snapshot),
172
+ ]
173
+
174
+
175
+ def _require_stack(state: dict[str, _StackState], stack_id: str) -> _StackState:
176
+ try:
177
+ return state[stack_id]
178
+ except KeyError as exc:
179
+ raise KeyError(f"Unknown budget stack: {stack_id}") from exc
180
+
181
+
182
+ def _stack_id_from(input_value: JsonValue) -> str:
183
+ assert isinstance(input_value, dict)
184
+ stack_id = input_value.get("stackId")
185
+ if not isinstance(stack_id, str):
186
+ raise ValueError("Convex budget operation requires stackId")
187
+ return stack_id
188
+
189
+
190
+ def _budget_to_row(budget: Budget) -> dict[str, Any]:
191
+ return {"id": budget.id, "layer": budget.layer, "limits": dict(budget.limits)}
192
+
193
+
194
+ def _row_to_budget(row: Any) -> Budget:
195
+ return Budget(id=row["id"], layer=row["layer"], limits=dict(row["limits"]))
196
+
197
+
198
+ def _usage_to_row(usage: Usage) -> dict[str, Any]:
199
+ return {
200
+ "cumulative": dict(usage.cumulative),
201
+ "concurrent": dict(usage.concurrent),
202
+ "peak": dict(usage.peak),
203
+ "reserved": dict(usage.reserved),
204
+ }
205
+
206
+
207
+ def _row_to_usage(row: Any) -> Usage:
208
+ return Usage(
209
+ cumulative=dict(row["cumulative"]),
210
+ concurrent=dict(row["concurrent"]),
211
+ peak=dict(row["peak"]),
212
+ reserved=dict(row["reserved"]),
213
+ )
214
+
215
+
216
+ def _usage_map_to_row(usages: UsageMap) -> dict[str, Any]:
217
+ return {budget_id: _usage_to_row(usage) for budget_id, usage in usages.items()}
218
+
219
+
220
+ def _row_to_usage_map(row: Any) -> UsageMap:
221
+ return {budget_id: _row_to_usage(usage) for budget_id, usage in deepcopy(row).items()}
222
+
223
+
224
+ def _row_to_stack(row: Any) -> list[Budget]:
225
+ return [_row_to_budget(budget) for budget in deepcopy(row["stack"])]
226
+
227
+
228
+ def _spend_request_to_row(req: SpendRequest) -> dict[str, Any]:
229
+ return {"estimate": dict(req.estimate), "run_id": req.run_id}
230
+
231
+
232
+ def _row_to_spend_request(row: Any) -> SpendRequest:
233
+ return SpendRequest(estimate=dict(row["estimate"]), run_id=row.get("run_id"))
234
+
235
+
236
+ def _reservation_to_row(reservation: Reservation) -> dict[str, Any]:
237
+ return {"id": reservation.id, "estimate": dict(reservation.estimate)}
238
+
239
+
240
+ def _row_to_reservation(row: Any) -> Reservation:
241
+ return Reservation(id=row["id"], estimate=dict(row["estimate"]))
242
+
243
+
244
+ def _decision_to_row(decision: Decision) -> dict[str, Any]:
245
+ if decision.ok:
246
+ assert decision.reservation is not None
247
+ return {"ok": True, "reservation": _reservation_to_row(decision.reservation)}
248
+ assert decision.breach is not None
249
+ return {
250
+ "ok": False,
251
+ "breach": {
252
+ "budget_id": decision.breach.budget_id,
253
+ "layer": decision.breach.layer,
254
+ "dimension": decision.breach.dimension,
255
+ "limit": decision.breach.limit,
256
+ "would_be": decision.breach.would_be,
257
+ },
258
+ }
259
+
260
+
261
+ def _row_to_decision(row: Any) -> Decision:
262
+ if row["ok"]:
263
+ return Decision(ok=True, reservation=_row_to_reservation(row["reservation"]))
264
+ breach = row["breach"]
265
+ from .core import Breach
266
+
267
+ return Decision(
268
+ ok=False,
269
+ breach=Breach(
270
+ budget_id=breach["budget_id"],
271
+ layer=breach["layer"],
272
+ dimension=breach["dimension"],
273
+ limit=breach["limit"],
274
+ would_be=breach["would_be"],
275
+ ),
276
+ )
deltav/store_local.py ADDED
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from copy import deepcopy
5
+
6
+ from .core import (
7
+ Budget,
8
+ Decision,
9
+ Reservation,
10
+ Spend,
11
+ SpendRequest,
12
+ UsageMap,
13
+ check_stack,
14
+ release_stack,
15
+ reserve_stack,
16
+ settle_stack,
17
+ )
18
+
19
+
20
+ class InMemoryBudgetStore:
21
+ def __init__(self, initial: dict[str, tuple[list[Budget], UsageMap]] | None = None) -> None:
22
+ self._stacks: dict[str, tuple[list[Budget], UsageMap]] = initial or {}
23
+ self._open: dict[str, set[str]] = {stack_id: set() for stack_id in self._stacks}
24
+ self._lock = asyncio.Lock()
25
+
26
+ async def load(self, stack_id: str) -> tuple[list[Budget], UsageMap]:
27
+ async with self._lock:
28
+ stack, usages = self._require(stack_id)
29
+ return list(stack), deepcopy(usages)
30
+
31
+ async def try_reserve(self, stack_id: str, req: SpendRequest) -> Decision:
32
+ async with self._lock:
33
+ stack, usages = self._require(stack_id)
34
+ decision = check_stack(stack, usages, req)
35
+ if decision.ok and decision.reservation is not None:
36
+ self._stacks[stack_id] = (stack, reserve_stack(stack, usages, req))
37
+ self._open.setdefault(stack_id, set()).add(decision.reservation.id)
38
+ return decision
39
+
40
+ async def settle(self, stack_id: str, reservation: Reservation, actual: Spend) -> None:
41
+ async with self._lock:
42
+ if reservation.id not in self._open.setdefault(stack_id, set()):
43
+ return
44
+ stack, usages = self._require(stack_id)
45
+ self._stacks[stack_id] = (stack, settle_stack(stack, usages, reservation, actual))
46
+ self._open[stack_id].remove(reservation.id)
47
+
48
+ async def release(self, stack_id: str, reservation: Reservation) -> None:
49
+ async with self._lock:
50
+ if reservation.id not in self._open.setdefault(stack_id, set()):
51
+ return
52
+ stack, usages = self._require(stack_id)
53
+ self._stacks[stack_id] = (stack, release_stack(stack, usages, reservation))
54
+ self._open[stack_id].remove(reservation.id)
55
+
56
+ async def snapshot(self, stack_id: str) -> UsageMap:
57
+ async with self._lock:
58
+ return deepcopy(self._require(stack_id)[1])
59
+
60
+ def _require(self, stack_id: str) -> tuple[list[Budget], UsageMap]:
61
+ if stack_id not in self._stacks:
62
+ raise KeyError(f"Unknown budget stack: {stack_id}")
63
+ return self._stacks[stack_id]
@@ -0,0 +1,205 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Awaitable, Callable
4
+ from copy import deepcopy
5
+ from typing import Any
6
+
7
+ from dockbay import (
8
+ PostgresStoreDriver,
9
+ PostgresStoreDriverOptions,
10
+ Row,
11
+ StoreDriver,
12
+ Transaction,
13
+ create_postgres_driver,
14
+ )
15
+ from psycopg_pool import AsyncConnectionPool
16
+
17
+ from .core import (
18
+ Budget,
19
+ Decision,
20
+ Reservation,
21
+ Spend,
22
+ SpendRequest,
23
+ Usage,
24
+ UsageMap,
25
+ check_stack,
26
+ release_stack,
27
+ reserve_stack,
28
+ settle_stack,
29
+ )
30
+
31
+ TABLE = "budget_stacks"
32
+ MAX_CAS_ATTEMPTS = 25
33
+
34
+ InitialState = dict[str, tuple[list[Budget], UsageMap]]
35
+ _Update = Callable[[Row], tuple[Any, Row]]
36
+
37
+
38
+ class DriverBudgetStore:
39
+ def __init__(self, driver: StoreDriver, initial: InitialState | None = None) -> None:
40
+ self._driver = driver
41
+ self._initial = initial or {}
42
+
43
+ async def load(self, stack_id: str) -> tuple[list[Budget], UsageMap]:
44
+ async def work(txn: Transaction) -> tuple[list[Budget], UsageMap]:
45
+ state = await self._load_state(txn, stack_id)
46
+ return _row_to_stack(state), _row_to_usage_map(state["usages"])
47
+
48
+ return await self._driver.transaction(work)
49
+
50
+ async def try_reserve(self, stack_id: str, req: SpendRequest) -> Decision:
51
+ def update(state: Row) -> tuple[Decision, Row]:
52
+ stack = _row_to_stack(state)
53
+ usages = _row_to_usage_map(state["usages"])
54
+ decision = check_stack(stack, usages, req)
55
+ if not decision.ok or decision.reservation is None:
56
+ return decision, state
57
+ next_state = {
58
+ **state,
59
+ "usages": _usage_map_to_row(reserve_stack(stack, usages, req)),
60
+ "openReservations": [*state["openReservations"], decision.reservation.id],
61
+ }
62
+ return decision, next_state
63
+
64
+ return await self._cas(stack_id, update)
65
+
66
+ async def settle(self, stack_id: str, reservation: Reservation, actual: Spend) -> None:
67
+ def update(state: Row) -> tuple[None, Row]:
68
+ if reservation.id not in state["openReservations"]:
69
+ return None, state
70
+ stack = _row_to_stack(state)
71
+ usages = _row_to_usage_map(state["usages"])
72
+ next_state = {
73
+ **state,
74
+ "usages": _usage_map_to_row(settle_stack(stack, usages, reservation, actual)),
75
+ "openReservations": [
76
+ item for item in state["openReservations"] if item != reservation.id
77
+ ],
78
+ }
79
+ return None, next_state
80
+
81
+ await self._cas(stack_id, update)
82
+
83
+ async def release(self, stack_id: str, reservation: Reservation) -> None:
84
+ def update(state: Row) -> tuple[None, Row]:
85
+ if reservation.id not in state["openReservations"]:
86
+ return None, state
87
+ stack = _row_to_stack(state)
88
+ usages = _row_to_usage_map(state["usages"])
89
+ next_state = {
90
+ **state,
91
+ "usages": _usage_map_to_row(release_stack(stack, usages, reservation)),
92
+ "openReservations": [
93
+ item for item in state["openReservations"] if item != reservation.id
94
+ ],
95
+ }
96
+ return None, next_state
97
+
98
+ await self._cas(stack_id, update)
99
+
100
+ async def snapshot(self, stack_id: str) -> UsageMap:
101
+ async def work(txn: Transaction) -> UsageMap:
102
+ state = await self._load_state(txn, stack_id)
103
+ return _row_to_usage_map(state["usages"])
104
+
105
+ return await self._driver.transaction(work)
106
+
107
+ async def close(self) -> None:
108
+ await self._driver.close()
109
+
110
+ async def _cas(self, stack_id: str, update: _Update) -> Any:
111
+ for _ in range(MAX_CAS_ATTEMPTS):
112
+ outcome = await self._driver.transaction(
113
+ _attempt_update(stack_id, self._load_state, update)
114
+ )
115
+ if outcome["applied"]:
116
+ return outcome["result"]
117
+ raise RuntimeError(f"Budget stack update conflicted too often: {stack_id}")
118
+
119
+ async def _load_state(self, txn: Transaction, stack_id: str) -> Row:
120
+ existing = await txn.get(TABLE, _stack_key(stack_id))
121
+ if existing is not None:
122
+ return existing
123
+ seed = self._initial.get(stack_id)
124
+ if seed is None:
125
+ raise KeyError(f"Unknown budget stack: {stack_id}")
126
+ stack, usages = seed
127
+ state = {
128
+ "stack": [_budget_to_row(budget) for budget in stack],
129
+ "usages": _usage_map_to_row(usages),
130
+ "openReservations": [],
131
+ }
132
+ await txn.compare_and_apply(TABLE, _stack_key(stack_id), None, state)
133
+ return state
134
+
135
+
136
+ class PostgresBudgetStore(DriverBudgetStore):
137
+ def __init__(
138
+ self,
139
+ pool: AsyncConnectionPool,
140
+ *,
141
+ table: str = "deltav_budget_store",
142
+ initial: InitialState | None = None,
143
+ ) -> None:
144
+ self.postgres_driver: PostgresStoreDriver = create_postgres_driver(
145
+ pool, PostgresStoreDriverOptions(table=table)
146
+ )
147
+ super().__init__(self.postgres_driver, initial)
148
+
149
+
150
+ def _attempt_update(
151
+ stack_id: str,
152
+ load_state: Callable[[Transaction, str], Awaitable[Row]],
153
+ update: _Update,
154
+ ) -> Callable[[Transaction], Awaitable[Row]]:
155
+ async def work(txn: Transaction) -> Row:
156
+ state = await load_state(txn, stack_id)
157
+ result, next_state = update(state)
158
+ if state == next_state:
159
+ return {"applied": True, "result": result}
160
+ applied = await txn.compare_and_apply(TABLE, _stack_key(stack_id), state, next_state)
161
+ return {"applied": applied, "result": result}
162
+
163
+ return work
164
+
165
+
166
+ def _stack_key(stack_id: str) -> Row:
167
+ return {"stackId": stack_id}
168
+
169
+
170
+ def _budget_to_row(budget: Budget) -> Row:
171
+ return {"id": budget.id, "layer": budget.layer, "limits": dict(budget.limits)}
172
+
173
+
174
+ def _row_to_budget(row: Row) -> Budget:
175
+ return Budget(id=row["id"], layer=row["layer"], limits=dict(row["limits"]))
176
+
177
+
178
+ def _usage_to_row(usage: Usage) -> Row:
179
+ return {
180
+ "cumulative": dict(usage.cumulative),
181
+ "concurrent": dict(usage.concurrent),
182
+ "peak": dict(usage.peak),
183
+ "reserved": dict(usage.reserved),
184
+ }
185
+
186
+
187
+ def _row_to_usage(row: Row) -> Usage:
188
+ return Usage(
189
+ cumulative=dict(row["cumulative"]),
190
+ concurrent=dict(row["concurrent"]),
191
+ peak=dict(row["peak"]),
192
+ reserved=dict(row["reserved"]),
193
+ )
194
+
195
+
196
+ def _usage_map_to_row(usages: UsageMap) -> Row:
197
+ return {budget_id: _usage_to_row(usage) for budget_id, usage in usages.items()}
198
+
199
+
200
+ def _row_to_usage_map(row: Any) -> UsageMap:
201
+ return {budget_id: _row_to_usage(usage) for budget_id, usage in deepcopy(row).items()}
202
+
203
+
204
+ def _row_to_stack(row: Row) -> list[Budget]:
205
+ return [_row_to_budget(budget) for budget in deepcopy(row["stack"])]
deltav/store_remote.py ADDED
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Awaitable, Callable
4
+ from dataclasses import dataclass
5
+ from typing import cast
6
+
7
+ from .core import Breach, Budget, Decision, Reservation, Spend, SpendRequest, Usage, UsageMap
8
+
9
+ HttpRequest = Callable[[str, str, object | None], Awaitable[object]]
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class RemoteBudgetStore:
14
+ request: HttpRequest
15
+ default_stack_id: str
16
+
17
+ async def load(self, stack_id: str) -> tuple[list[Budget], UsageMap]:
18
+ body = await self.request("POST", "/budget/load", {"stackId": stack_id})
19
+ if not isinstance(body, dict):
20
+ raise TypeError("Remote budget load returned a non-object response")
21
+ body_map = cast(dict[str, object], body)
22
+ stack_value = body_map.get("stack", [])
23
+ usages_value = body_map.get("usages", {})
24
+ return (
25
+ [_budget(value) for value in _list(stack_value)],
26
+ _usage_map(usages_value),
27
+ )
28
+
29
+ async def try_reserve(self, stack_id: str, req: SpendRequest) -> Decision:
30
+ body = await self.request(
31
+ "POST", "/budget/try-reserve", {"stackId": stack_id, "request": req}
32
+ )
33
+ return _decision(body)
34
+
35
+ async def settle(self, stack_id: str, reservation: Reservation, actual: Spend) -> None:
36
+ await self.request(
37
+ "POST",
38
+ "/budget/settle",
39
+ {"stackId": stack_id, "reservation": reservation, "actual": actual},
40
+ )
41
+
42
+ async def release(self, stack_id: str, reservation: Reservation) -> None:
43
+ await self.request(
44
+ "POST", "/budget/release", {"stackId": stack_id, "reservation": reservation}
45
+ )
46
+
47
+ async def snapshot(self, stack_id: str) -> UsageMap:
48
+ body = await self.request("POST", "/budget/snapshot", {"stackId": stack_id})
49
+ return _usage_map(body)
50
+
51
+
52
+ def _budget(value: object) -> Budget:
53
+ if isinstance(value, Budget):
54
+ return value
55
+ if not isinstance(value, dict):
56
+ raise TypeError("Budget value must be an object")
57
+ value_map = cast(dict[str, object], value)
58
+ limits = value_map["limits"]
59
+ if not isinstance(limits, dict):
60
+ raise TypeError("Budget limits must be an object")
61
+ return Budget(
62
+ id=str(value_map["id"]),
63
+ layer=str(value_map["layer"]),
64
+ limits={str(key): _number(amount) for key, amount in limits.items()},
65
+ )
66
+
67
+
68
+ def _usage(value: object) -> Usage:
69
+ if isinstance(value, Usage):
70
+ return value
71
+ if not isinstance(value, dict):
72
+ raise TypeError("Usage value must be an object")
73
+ value_map = cast(dict[str, object], value)
74
+ return Usage(
75
+ cumulative=_spend(value_map.get("cumulative", {})),
76
+ concurrent=_spend(value_map.get("concurrent", {})),
77
+ peak=_spend(value_map.get("peak", {})),
78
+ reserved=_spend(value_map.get("reserved", {})),
79
+ )
80
+
81
+
82
+ def _usage_map(value: object) -> UsageMap:
83
+ if not isinstance(value, dict):
84
+ raise TypeError("Usage map must be an object")
85
+ value_map = cast(dict[str, object], value)
86
+ return {str(key): _usage(item) for key, item in value_map.items()}
87
+
88
+
89
+ def _decision(value: object) -> Decision:
90
+ if isinstance(value, Decision):
91
+ return value
92
+ if not isinstance(value, dict):
93
+ raise TypeError("Decision value must be an object")
94
+ value_map = cast(dict[str, object], value)
95
+ if value_map.get("ok") is True:
96
+ return Decision(ok=True, reservation=_reservation(value_map.get("reservation")))
97
+ return Decision(ok=False, breach=_breach(value_map.get("breach")))
98
+
99
+
100
+ def _reservation(value: object) -> Reservation:
101
+ if isinstance(value, Reservation):
102
+ return value
103
+ if not isinstance(value, dict):
104
+ raise TypeError("Reservation value must be an object")
105
+ value_map = cast(dict[str, object], value)
106
+ return Reservation(id=str(value_map["id"]), estimate=_spend(value_map["estimate"]))
107
+
108
+
109
+ def _breach(value: object) -> Breach:
110
+ if isinstance(value, Breach):
111
+ return value
112
+ if not isinstance(value, dict):
113
+ raise TypeError("Breach value must be an object")
114
+ value_map = cast(dict[str, object], value)
115
+ return Breach(
116
+ budget_id=str(value_map["budget_id"]),
117
+ layer=str(value_map["layer"]),
118
+ dimension=str(value_map["dimension"]),
119
+ limit=_number(value_map["limit"]),
120
+ would_be=_number(value_map["would_be"]),
121
+ )
122
+
123
+
124
+ def _spend(value: object) -> Spend:
125
+ if not isinstance(value, dict):
126
+ raise TypeError("Spend value must be an object")
127
+ value_map = cast(dict[str, object], value)
128
+ return {str(key): _number(amount) for key, amount in value_map.items()}
129
+
130
+
131
+ def _list(value: object) -> list[object]:
132
+ if not isinstance(value, list):
133
+ raise TypeError("Value must be a list")
134
+ return cast(list[object], value)
135
+
136
+
137
+ def _number(value: object) -> float:
138
+ if not isinstance(value, int | float):
139
+ raise TypeError("Value must be numeric")
140
+ return float(value)
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.4
2
+ Name: deltav
3
+ Version: 0.0.0
4
+ Summary: DeltaV reservation-based budget enforcement for agent and tool runs.
5
+ Project-URL: Homepage, https://github.com/cachetronaut/deltav
6
+ Project-URL: Repository, https://github.com/cachetronaut/deltav
7
+ Project-URL: Issues, https://github.com/cachetronaut/deltav/issues
8
+ License: MIT
9
+ Requires-Python: >=3.11
10
+ Requires-Dist: dockbay>=0.0.0
11
+ Requires-Dist: psycopg-pool>=3.2
12
+ Requires-Dist: psycopg[binary]>=3.2
13
+ Description-Content-Type: text/markdown
14
+
15
+ # deltav
16
+
17
+ Python implementation of DeltaV.
18
+
19
+ For product-level context, shared contracts, and cross-language repository information, see the public repository: https://github.com/cachetronaut/deltav.
20
+
21
+ ## Install
22
+
23
+ ```sh
24
+ pip install deltav
25
+ ```
26
+
27
+ ## Import
28
+
29
+ ```python
30
+ import deltav
31
+ ```
32
+
33
+ ## Development
34
+
35
+ Run from `py/`:
36
+
37
+ ```sh
38
+ uv sync --dev
39
+ uv run --with ruff ruff check .
40
+ uv run --with ruff ruff format --check .
41
+ uv run --with ty ty check
42
+ uv run --with pytest --with pytest-asyncio python -m pytest
43
+ ```
44
+
45
+ ## License
46
+
47
+ MIT
@@ -0,0 +1,9 @@
1
+ deltav/__init__.py,sha256=zDOKPV-mfK4thYAH3VAtDfzru6vHjyMgttris0WVZTY,1028
2
+ deltav/core.py,sha256=Akj_t7hGV3Jgg4hHLxXJtK2Q_W-9DjLjc5qQpu_nXBM,5286
3
+ deltav/store_convex.py,sha256=ep4yA2kfa0jWT7evBqeg_Ju5nQ0XUmUj1s5eYvJao2s,9395
4
+ deltav/store_local.py,sha256=zqMbW2X0D1mTTzC9-uDDy6QZTUioAXwr3uRa-S3sLtU,2469
5
+ deltav/store_postgres.py,sha256=pBwhqK1SW6pWXJhIj8xCFWy8u8dE_i9UfmL03xfB-3c,6890
6
+ deltav/store_remote.py,sha256=9Vma1BHq7gAhPyB7sUe0R4lQKCmuRsD6oq7azVqa69Q,4957
7
+ deltav-0.0.0.dist-info/METADATA,sha256=24dQI1FGszDbRu_Tl2cOGbuSpwKbaTtajEvXIY1pLBo,1013
8
+ deltav-0.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
9
+ deltav-0.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any