neveroversell 0.1.0__tar.gz
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.
- neveroversell-0.1.0/.gitignore +13 -0
- neveroversell-0.1.0/PKG-INFO +74 -0
- neveroversell-0.1.0/README.md +46 -0
- neveroversell-0.1.0/neveroversell/__init__.py +344 -0
- neveroversell-0.1.0/neveroversell/py.typed +0 -0
- neveroversell-0.1.0/neveroversell/sql/001_schema.sql +84 -0
- neveroversell-0.1.0/neveroversell/sql/002_functions.sql +418 -0
- neveroversell-0.1.0/neveroversell/sql/003_views.sql +24 -0
- neveroversell-0.1.0/pyproject.toml +45 -0
- neveroversell-0.1.0/tests/test_neveroversell.py +188 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: neveroversell
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Holds that cannot oversell. Postgres reservations with a TTL, a payment phase, idempotent two-path confirmation, a concurrent-safe sweeper and a drift check.
|
|
5
|
+
Project-URL: Homepage, https://github.com/pavangupta352/neveroversell
|
|
6
|
+
Project-URL: Repository, https://github.com/pavangupta352/neveroversell
|
|
7
|
+
Project-URL: Issues, https://github.com/pavangupta352/neveroversell/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/pavangupta352/neveroversell/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: Pavan Gupta <pavan.gupta.352@gmail.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
Keywords: booking,concurrency,hold,idempotent,inventory,oversell,payment,postgres,postgresql,reservation,seat,webhook
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Database
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: psycopg[binary,pool]>=3.1
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# neveroversell
|
|
30
|
+
|
|
31
|
+
Holds that cannot oversell. A Postgres library for selling a finite thing exactly once when payment is asynchronous: seats, tickets, stock, appointment slots, rental units, cohort places.
|
|
32
|
+
|
|
33
|
+
This is the Python client. It ships the same SQL as the TypeScript package, so both languages share one set of guarantees and one database schema. The full documentation, the design notes and the test results live in the [repository README](https://github.com/pavangupta352/neveroversell#readme).
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
pip install neveroversell
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Postgres 13 or newer. No extensions, no Redis, no queue.
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from datetime import timedelta
|
|
43
|
+
from neveroversell import Inventory
|
|
44
|
+
|
|
45
|
+
inv = Inventory(
|
|
46
|
+
"postgres://user:pass@host/db",
|
|
47
|
+
hold_ttl=timedelta(minutes=15), # a basket lives 15 minutes
|
|
48
|
+
payment_window=timedelta(minutes=30), # once payment starts, 30 minutes; must exceed the TTL
|
|
49
|
+
)
|
|
50
|
+
inv.migrate()
|
|
51
|
+
inv.upsert_resource("flight_AI202_2026-10-01", 180)
|
|
52
|
+
|
|
53
|
+
held = inv.hold("flight_AI202_2026-10-01", 2, account_id=user.id, idempotency_key=basket.id)
|
|
54
|
+
if held.status != "held":
|
|
55
|
+
... # "insufficient" (held.available says how many are left), "account_cap", "unknown_resource"
|
|
56
|
+
|
|
57
|
+
inv.begin_payment(held.hold.id)
|
|
58
|
+
|
|
59
|
+
# From the return URL and from the webhook, in any order, any number of times:
|
|
60
|
+
result = inv.confirm(held.hold.id, payment.id)
|
|
61
|
+
match result.status:
|
|
62
|
+
case "confirmed": fulfil(result.hold)
|
|
63
|
+
case "already_confirmed": pass
|
|
64
|
+
case "duplicate_payment": refund(result.payment_ref) # keep result.existing_payment_ref
|
|
65
|
+
case "payment_ref_in_use": investigate(result.other_hold_id)
|
|
66
|
+
case "expired" | "released": refund(result.payment_ref)
|
|
67
|
+
case "not_found": pass
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Run `inv.sweep()` every minute from a scheduler, or `select nos_sweep()` from `pg_cron`. `inv.check()` compares the counters with the rows whenever you want proof.
|
|
71
|
+
|
|
72
|
+
Every method returns a `Result` with a `status` string and the fields that status needs. Nothing throws for an expected outcome; exceptions are for programmer errors such as a non-positive quantity.
|
|
73
|
+
|
|
74
|
+
Pass your own `psycopg_pool.ConnectionPool` with `Inventory(pool=...)` to share connections with the rest of your application.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# neveroversell
|
|
2
|
+
|
|
3
|
+
Holds that cannot oversell. A Postgres library for selling a finite thing exactly once when payment is asynchronous: seats, tickets, stock, appointment slots, rental units, cohort places.
|
|
4
|
+
|
|
5
|
+
This is the Python client. It ships the same SQL as the TypeScript package, so both languages share one set of guarantees and one database schema. The full documentation, the design notes and the test results live in the [repository README](https://github.com/pavangupta352/neveroversell#readme).
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pip install neveroversell
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Postgres 13 or newer. No extensions, no Redis, no queue.
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from datetime import timedelta
|
|
15
|
+
from neveroversell import Inventory
|
|
16
|
+
|
|
17
|
+
inv = Inventory(
|
|
18
|
+
"postgres://user:pass@host/db",
|
|
19
|
+
hold_ttl=timedelta(minutes=15), # a basket lives 15 minutes
|
|
20
|
+
payment_window=timedelta(minutes=30), # once payment starts, 30 minutes; must exceed the TTL
|
|
21
|
+
)
|
|
22
|
+
inv.migrate()
|
|
23
|
+
inv.upsert_resource("flight_AI202_2026-10-01", 180)
|
|
24
|
+
|
|
25
|
+
held = inv.hold("flight_AI202_2026-10-01", 2, account_id=user.id, idempotency_key=basket.id)
|
|
26
|
+
if held.status != "held":
|
|
27
|
+
... # "insufficient" (held.available says how many are left), "account_cap", "unknown_resource"
|
|
28
|
+
|
|
29
|
+
inv.begin_payment(held.hold.id)
|
|
30
|
+
|
|
31
|
+
# From the return URL and from the webhook, in any order, any number of times:
|
|
32
|
+
result = inv.confirm(held.hold.id, payment.id)
|
|
33
|
+
match result.status:
|
|
34
|
+
case "confirmed": fulfil(result.hold)
|
|
35
|
+
case "already_confirmed": pass
|
|
36
|
+
case "duplicate_payment": refund(result.payment_ref) # keep result.existing_payment_ref
|
|
37
|
+
case "payment_ref_in_use": investigate(result.other_hold_id)
|
|
38
|
+
case "expired" | "released": refund(result.payment_ref)
|
|
39
|
+
case "not_found": pass
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Run `inv.sweep()` every minute from a scheduler, or `select nos_sweep()` from `pg_cron`. `inv.check()` compares the counters with the rows whenever you want proof.
|
|
43
|
+
|
|
44
|
+
Every method returns a `Result` with a `status` string and the fields that status needs. Nothing throws for an expected outcome; exceptions are for programmer errors such as a non-positive quantity.
|
|
45
|
+
|
|
46
|
+
Pass your own `psycopg_pool.ConnectionPool` with `Inventory(pool=...)` to share connections with the rest of your application.
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""neveroversell: holds that cannot oversell, for Postgres.
|
|
2
|
+
|
|
3
|
+
The guarantees live in the SQL functions shipped inside this package. This module applies them,
|
|
4
|
+
pushes your settings into the database, and turns each function's result row into a typed value.
|
|
5
|
+
Every method is one SQL call; nothing here opens a transaction across calls.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime, timedelta
|
|
12
|
+
from importlib import resources
|
|
13
|
+
from typing import Any, Literal, Optional
|
|
14
|
+
|
|
15
|
+
from psycopg import errors
|
|
16
|
+
from psycopg.rows import dict_row
|
|
17
|
+
from psycopg_pool import ConnectionPool
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"CheckRow",
|
|
21
|
+
"Hold",
|
|
22
|
+
"HoldEvent",
|
|
23
|
+
"HoldState",
|
|
24
|
+
"Inventory",
|
|
25
|
+
"RepairRow",
|
|
26
|
+
"ResourceStatus",
|
|
27
|
+
"Result",
|
|
28
|
+
"SweepRow",
|
|
29
|
+
"sql_files",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
__version__ = "0.1.0"
|
|
33
|
+
|
|
34
|
+
HoldState = Literal["held", "awaiting_payment", "confirmed", "released", "expired"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Hold:
|
|
39
|
+
id: str
|
|
40
|
+
resource_id: str
|
|
41
|
+
account_id: str
|
|
42
|
+
qty: int
|
|
43
|
+
state: HoldState
|
|
44
|
+
expires_at: datetime
|
|
45
|
+
payment_deadline: Optional[datetime]
|
|
46
|
+
payment_ref: Optional[str]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class Result:
|
|
51
|
+
"""The outcome of hold, begin_payment, confirm, release, extend and upsert_resource.
|
|
52
|
+
|
|
53
|
+
``status`` is the value to match on. The other fields are filled when they mean something:
|
|
54
|
+
``hold`` for any status that concerns an existing hold; ``available`` for insufficient,
|
|
55
|
+
account_cap, held and the resource statuses; ``payment_ref`` for the reference you should
|
|
56
|
+
refund on duplicate_payment, expired and released; ``existing_payment_ref`` on
|
|
57
|
+
duplicate_payment; ``other_hold_id`` on payment_ref_in_use.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
status: str
|
|
61
|
+
hold: Optional[Hold] = None
|
|
62
|
+
resource_id: Optional[str] = None
|
|
63
|
+
available: Optional[int] = None
|
|
64
|
+
payment_ref: Optional[str] = None
|
|
65
|
+
existing_payment_ref: Optional[str] = None
|
|
66
|
+
other_hold_id: Optional[str] = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class SweepRow:
|
|
71
|
+
resource_id: str
|
|
72
|
+
expired_holds: int
|
|
73
|
+
expired_qty: int
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class CheckRow:
|
|
78
|
+
resource_id: str
|
|
79
|
+
held: int
|
|
80
|
+
held_by_rows: int
|
|
81
|
+
sold: int
|
|
82
|
+
sold_by_rows: int
|
|
83
|
+
drift: bool
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class RepairRow:
|
|
88
|
+
resource_id: str
|
|
89
|
+
held_before: int
|
|
90
|
+
held_after: int
|
|
91
|
+
sold_before: int
|
|
92
|
+
sold_after: int
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class ResourceStatus:
|
|
97
|
+
resource_id: str
|
|
98
|
+
total: int
|
|
99
|
+
held: int
|
|
100
|
+
sold: int
|
|
101
|
+
available: int
|
|
102
|
+
held_plain: int
|
|
103
|
+
held_paying: int
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class HoldEvent:
|
|
108
|
+
id: int
|
|
109
|
+
hold_id: str
|
|
110
|
+
event: str
|
|
111
|
+
detail: dict[str, Any]
|
|
112
|
+
at: datetime
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def sql_files() -> list[tuple[str, str]]:
|
|
116
|
+
"""The migration files bundled with the package, as (name, contents), in apply order."""
|
|
117
|
+
folder = resources.files(__package__).joinpath("sql")
|
|
118
|
+
out: list[tuple[str, str]] = []
|
|
119
|
+
for entry in sorted(folder.iterdir(), key=lambda e: e.name):
|
|
120
|
+
if entry.name.endswith(".sql"):
|
|
121
|
+
out.append((entry.name, entry.read_text(encoding="utf-8")))
|
|
122
|
+
return out
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _hold(row: dict[str, Any]) -> Optional[Hold]:
|
|
126
|
+
if row.get("hold_id") is None:
|
|
127
|
+
return None
|
|
128
|
+
return Hold(
|
|
129
|
+
id=str(row["hold_id"]),
|
|
130
|
+
resource_id=row["resource_id"],
|
|
131
|
+
account_id=row["account_id"],
|
|
132
|
+
qty=row["qty"],
|
|
133
|
+
state=row["state"],
|
|
134
|
+
expires_at=row["expires_at"],
|
|
135
|
+
payment_deadline=row.get("payment_deadline"),
|
|
136
|
+
payment_ref=row.get("payment_ref"),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _result(row: dict[str, Any]) -> Result:
|
|
141
|
+
# The SQL already puts the right reference in payment_ref: the hold's own on confirmed and
|
|
142
|
+
# already_confirmed, the one to refund on duplicate_payment, expired and released.
|
|
143
|
+
return Result(
|
|
144
|
+
status=row["status"],
|
|
145
|
+
hold=_hold(row),
|
|
146
|
+
resource_id=row.get("resource_id"),
|
|
147
|
+
available=row.get("available"),
|
|
148
|
+
payment_ref=row.get("payment_ref"),
|
|
149
|
+
existing_payment_ref=row.get("existing_payment_ref"),
|
|
150
|
+
other_hold_id=str(row["other_hold_id"]) if row.get("other_hold_id") else None,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class Inventory:
|
|
155
|
+
"""Holds, payment phase, confirmation, release, extension, sweeping and checking.
|
|
156
|
+
|
|
157
|
+
Pass either a connection string, from which a small pool is created, or your own
|
|
158
|
+
``psycopg_pool.ConnectionPool``.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
def __init__(
|
|
162
|
+
self,
|
|
163
|
+
conninfo: Optional[str] = None,
|
|
164
|
+
*,
|
|
165
|
+
pool: Optional[ConnectionPool] = None,
|
|
166
|
+
hold_ttl: timedelta = timedelta(minutes=15),
|
|
167
|
+
payment_window: timedelta = timedelta(minutes=30),
|
|
168
|
+
account_cap: Optional[int] = None,
|
|
169
|
+
cap_scope: Literal["resource", "account"] = "resource",
|
|
170
|
+
max_extensions: int = 1,
|
|
171
|
+
extension: timedelta = timedelta(minutes=5),
|
|
172
|
+
max_size: int = 8,
|
|
173
|
+
) -> None:
|
|
174
|
+
if hold_ttl <= timedelta(0):
|
|
175
|
+
raise ValueError("hold_ttl must be positive")
|
|
176
|
+
if payment_window <= hold_ttl:
|
|
177
|
+
raise ValueError(
|
|
178
|
+
f"payment_window ({payment_window}) must exceed hold_ttl ({hold_ttl}); otherwise the sweeper can kill a live checkout"
|
|
179
|
+
)
|
|
180
|
+
if account_cap is not None and account_cap <= 0:
|
|
181
|
+
raise ValueError("account_cap must be positive or None")
|
|
182
|
+
if max_extensions < 0:
|
|
183
|
+
raise ValueError("max_extensions must be zero or positive")
|
|
184
|
+
if extension <= timedelta(0):
|
|
185
|
+
raise ValueError("extension must be positive")
|
|
186
|
+
if cap_scope not in ("resource", "account"):
|
|
187
|
+
raise ValueError("cap_scope must be 'resource' or 'account'")
|
|
188
|
+
if pool is None:
|
|
189
|
+
if conninfo is None:
|
|
190
|
+
raise ValueError("pass a connection string or a pool")
|
|
191
|
+
pool = ConnectionPool(conninfo, min_size=1, max_size=max_size, kwargs={"row_factory": dict_row}, open=True)
|
|
192
|
+
self._pool = pool
|
|
193
|
+
self.hold_ttl = hold_ttl
|
|
194
|
+
self.payment_window = payment_window
|
|
195
|
+
self.account_cap = account_cap
|
|
196
|
+
self.cap_scope = cap_scope
|
|
197
|
+
self.max_extensions = max_extensions
|
|
198
|
+
self.extension = extension
|
|
199
|
+
|
|
200
|
+
# ---- infrastructure -------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
def close(self) -> None:
|
|
203
|
+
self._pool.close()
|
|
204
|
+
|
|
205
|
+
def _one(self, query: str, params: tuple[Any, ...]) -> dict[str, Any]:
|
|
206
|
+
with self._pool.connection() as conn:
|
|
207
|
+
row = conn.execute(query, params).fetchone() # type: ignore[union-attr]
|
|
208
|
+
if row is None:
|
|
209
|
+
raise RuntimeError(f"neveroversell: {query} returned no row")
|
|
210
|
+
return dict(row)
|
|
211
|
+
|
|
212
|
+
def _all(self, query: str, params: tuple[Any, ...] = ()) -> list[dict[str, Any]]:
|
|
213
|
+
with self._pool.connection() as conn:
|
|
214
|
+
rows = conn.execute(query, params).fetchall() # type: ignore[union-attr]
|
|
215
|
+
return [dict(r) for r in rows]
|
|
216
|
+
|
|
217
|
+
def migrate(self) -> list[str]:
|
|
218
|
+
"""Apply the bundled migrations that have not been applied yet, then push the settings."""
|
|
219
|
+
applied: list[str] = []
|
|
220
|
+
with self._pool.connection() as conn:
|
|
221
|
+
conn.execute("select pg_advisory_lock(hashtext('nos_migrations'))")
|
|
222
|
+
try:
|
|
223
|
+
conn.execute(
|
|
224
|
+
"create table if not exists nos_migrations (name text primary key, applied_at timestamptz not null default clock_timestamp())"
|
|
225
|
+
)
|
|
226
|
+
done = {r["name"] for r in conn.execute("select name from nos_migrations").fetchall()} # type: ignore[index]
|
|
227
|
+
conn.commit()
|
|
228
|
+
for name, body in sql_files():
|
|
229
|
+
if name in done:
|
|
230
|
+
continue
|
|
231
|
+
try:
|
|
232
|
+
conn.execute(body)
|
|
233
|
+
conn.execute("insert into nos_migrations (name) values (%s)", (name,))
|
|
234
|
+
conn.commit()
|
|
235
|
+
applied.append(name)
|
|
236
|
+
except Exception as err:
|
|
237
|
+
conn.rollback()
|
|
238
|
+
raise RuntimeError(f"neveroversell migration {name} failed: {err}") from err
|
|
239
|
+
finally:
|
|
240
|
+
conn.execute("select pg_advisory_unlock(hashtext('nos_migrations'))")
|
|
241
|
+
conn.commit()
|
|
242
|
+
self.apply_settings()
|
|
243
|
+
return applied
|
|
244
|
+
|
|
245
|
+
def apply_settings(self) -> None:
|
|
246
|
+
"""Write the configured settings to nos_settings. The database enforces payment_window > hold_ttl."""
|
|
247
|
+
try:
|
|
248
|
+
with self._pool.connection() as conn:
|
|
249
|
+
conn.execute(
|
|
250
|
+
"""
|
|
251
|
+
update nos_settings
|
|
252
|
+
set hold_ttl = %s, payment_window = %s, account_cap = %s,
|
|
253
|
+
cap_scope = %s, max_extensions = %s, extension = %s
|
|
254
|
+
where id
|
|
255
|
+
""",
|
|
256
|
+
(self.hold_ttl, self.payment_window, self.account_cap, self.cap_scope, self.max_extensions, self.extension),
|
|
257
|
+
)
|
|
258
|
+
except errors.CheckViolation as err:
|
|
259
|
+
raise ValueError(f"neveroversell: the database rejected these settings ({err})") from err
|
|
260
|
+
|
|
261
|
+
# ---- the primitive ------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
def upsert_resource(self, resource_id: str, total: int) -> Result:
|
|
264
|
+
return _result(self._one("select * from nos_upsert_resource(%s, %s)", (resource_id, total)))
|
|
265
|
+
|
|
266
|
+
def hold(
|
|
267
|
+
self,
|
|
268
|
+
resource_id: str,
|
|
269
|
+
qty: int,
|
|
270
|
+
account_id: str,
|
|
271
|
+
*,
|
|
272
|
+
idempotency_key: Optional[str] = None,
|
|
273
|
+
ttl: Optional[timedelta] = None,
|
|
274
|
+
) -> Result:
|
|
275
|
+
"""Statuses: held, replayed, insufficient, account_cap, unknown_resource."""
|
|
276
|
+
return _result(self._one("select * from nos_hold(%s, %s, %s, %s, %s)", (resource_id, qty, account_id, idempotency_key, ttl)))
|
|
277
|
+
|
|
278
|
+
def begin_payment(self, hold_id: str, *, window: Optional[timedelta] = None) -> Result:
|
|
279
|
+
"""Statuses: awaiting_payment, replayed, expired, confirmed, released, not_found."""
|
|
280
|
+
return _result(self._one("select * from nos_begin_payment(%s, %s)", (hold_id, window)))
|
|
281
|
+
|
|
282
|
+
def confirm(self, hold_id: str, payment_ref: str) -> Result:
|
|
283
|
+
"""Statuses: confirmed, already_confirmed, duplicate_payment, payment_ref_in_use, expired, released, not_found."""
|
|
284
|
+
return _result(self._one("select * from nos_confirm(%s, %s)", (hold_id, payment_ref)))
|
|
285
|
+
|
|
286
|
+
def release(self, hold_id: str, *, reason: Optional[str] = None) -> Result:
|
|
287
|
+
"""Statuses: released, already_released, confirmed, expired, not_found."""
|
|
288
|
+
return _result(self._one("select * from nos_release(%s, %s)", (hold_id, reason)))
|
|
289
|
+
|
|
290
|
+
def extend(self, hold_id: str, *, by: Optional[timedelta] = None) -> Result:
|
|
291
|
+
"""Statuses: extended, not_extendable, not_found."""
|
|
292
|
+
return _result(self._one("select * from nos_extend(%s, %s)", (hold_id, by)))
|
|
293
|
+
|
|
294
|
+
def sweep(self, limit: int = 1000) -> list[SweepRow]:
|
|
295
|
+
return [SweepRow(r["resource_id"], r["expired_holds"], r["expired_qty"]) for r in self._all("select * from nos_sweep(%s)", (limit,))]
|
|
296
|
+
|
|
297
|
+
def check(self) -> list[CheckRow]:
|
|
298
|
+
return [
|
|
299
|
+
CheckRow(r["resource_id"], r["held"], int(r["held_by_rows"]), r["sold"], int(r["sold_by_rows"]), r["drift"])
|
|
300
|
+
for r in self._all("select * from nos_check()")
|
|
301
|
+
]
|
|
302
|
+
|
|
303
|
+
def repair(self, resource_id: str) -> Optional[RepairRow]:
|
|
304
|
+
rows = self._all("select * from nos_repair(%s)", (resource_id,))
|
|
305
|
+
if not rows:
|
|
306
|
+
return None
|
|
307
|
+
r = rows[0]
|
|
308
|
+
return RepairRow(r["resource_id"], r["held_before"], r["held_after"], r["sold_before"], r["sold_after"])
|
|
309
|
+
|
|
310
|
+
def status(self, resource_id: str) -> Optional[ResourceStatus]:
|
|
311
|
+
rows = self._all("select * from nos_resource_status where resource_id = %s", (resource_id,))
|
|
312
|
+
if not rows:
|
|
313
|
+
return None
|
|
314
|
+
r = rows[0]
|
|
315
|
+
return ResourceStatus(r["resource_id"], r["total"], r["held"], r["sold"], r["available"], r["held_plain"], r["held_paying"])
|
|
316
|
+
|
|
317
|
+
def holds(
|
|
318
|
+
self,
|
|
319
|
+
*,
|
|
320
|
+
resource_id: Optional[str] = None,
|
|
321
|
+
state: Optional[HoldState] = None,
|
|
322
|
+
older_than: Optional[timedelta] = None,
|
|
323
|
+
limit: int = 200,
|
|
324
|
+
) -> list[Hold]:
|
|
325
|
+
rows = self._all(
|
|
326
|
+
"""
|
|
327
|
+
select id as hold_id, resource_id, account_id, qty, state, expires_at, payment_deadline, payment_ref
|
|
328
|
+
from nos_holds
|
|
329
|
+
where (%(resource)s::text is null or resource_id = %(resource)s)
|
|
330
|
+
and (%(state)s::nos_hold_state is null or state = %(state)s)
|
|
331
|
+
and (%(older)s::interval is null or created_at <= clock_timestamp() - %(older)s::interval)
|
|
332
|
+
order by created_at asc
|
|
333
|
+
limit %(limit)s
|
|
334
|
+
""",
|
|
335
|
+
{"resource": resource_id, "state": state, "older": older_than, "limit": limit}, # type: ignore[arg-type]
|
|
336
|
+
)
|
|
337
|
+
return [h for h in (_hold(r) for r in rows) if h is not None]
|
|
338
|
+
|
|
339
|
+
def events(self, hold_id: str) -> list[HoldEvent]:
|
|
340
|
+
return [
|
|
341
|
+
HoldEvent(int(r["id"]), str(r["hold_id"]), r["event"], r["detail"], r["at"])
|
|
342
|
+
for r in self._all("select id, hold_id, event, detail, at from nos_hold_events where hold_id = %s order by id", (hold_id,))
|
|
343
|
+
]
|
|
344
|
+
|
|
File without changes
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
-- neveroversell: schema
|
|
2
|
+
-- Postgres 13 or newer. Every object is prefixed nos_ so it can live inside an existing database.
|
|
3
|
+
|
|
4
|
+
create table if not exists nos_settings (
|
|
5
|
+
id boolean primary key default true check (id),
|
|
6
|
+
hold_ttl interval not null default interval '15 minutes' check (hold_ttl > interval '0'),
|
|
7
|
+
payment_window interval not null default interval '30 minutes',
|
|
8
|
+
account_cap integer check (account_cap is null or account_cap > 0),
|
|
9
|
+
cap_scope text not null default 'resource' check (cap_scope in ('resource', 'account')),
|
|
10
|
+
max_extensions integer not null default 1 check (max_extensions >= 0),
|
|
11
|
+
extension interval not null default interval '5 minutes' check (extension > interval '0'),
|
|
12
|
+
-- A live checkout must never be killed by the TTL: the payment window has to outlast the hold.
|
|
13
|
+
constraint nos_settings_window_gt_ttl check (payment_window > hold_ttl)
|
|
14
|
+
);
|
|
15
|
+
insert into nos_settings default values on conflict (id) do nothing;
|
|
16
|
+
|
|
17
|
+
create table if not exists nos_resources (
|
|
18
|
+
id text primary key,
|
|
19
|
+
total integer not null check (total >= 0),
|
|
20
|
+
held integer not null default 0 check (held >= 0),
|
|
21
|
+
sold integer not null default 0 check (sold >= 0),
|
|
22
|
+
created_at timestamptz not null default clock_timestamp(),
|
|
23
|
+
updated_at timestamptz not null default clock_timestamp(),
|
|
24
|
+
-- The last line of defence: even a wrong code path cannot commit an oversell.
|
|
25
|
+
constraint nos_resources_capacity check (held + sold <= total)
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
do $$ begin
|
|
29
|
+
create type nos_hold_state as enum ('held', 'awaiting_payment', 'confirmed', 'released', 'expired');
|
|
30
|
+
exception when duplicate_object then null; end $$;
|
|
31
|
+
|
|
32
|
+
create table if not exists nos_holds (
|
|
33
|
+
id uuid primary key default gen_random_uuid(),
|
|
34
|
+
resource_id text not null references nos_resources (id),
|
|
35
|
+
account_id text not null,
|
|
36
|
+
qty integer not null check (qty > 0),
|
|
37
|
+
state nos_hold_state not null default 'held',
|
|
38
|
+
idempotency_key text,
|
|
39
|
+
payment_ref text,
|
|
40
|
+
expires_at timestamptz not null,
|
|
41
|
+
payment_started_at timestamptz,
|
|
42
|
+
payment_deadline timestamptz,
|
|
43
|
+
extensions integer not null default 0,
|
|
44
|
+
created_at timestamptz not null default clock_timestamp(),
|
|
45
|
+
updated_at timestamptz not null default clock_timestamp()
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
-- One hold per (resource, account, idempotency key): a client retry cannot double-hold.
|
|
49
|
+
create unique index if not exists nos_holds_idem_uq
|
|
50
|
+
on nos_holds (resource_id, account_id, idempotency_key) where idempotency_key is not null;
|
|
51
|
+
-- One payment buys one hold, enforced by the database.
|
|
52
|
+
create unique index if not exists nos_holds_payment_ref_uq
|
|
53
|
+
on nos_holds (payment_ref) where payment_ref is not null;
|
|
54
|
+
create index if not exists nos_holds_active_ix on nos_holds (resource_id) where state in ('held', 'awaiting_payment');
|
|
55
|
+
create index if not exists nos_holds_account_ix on nos_holds (account_id) where state in ('held', 'awaiting_payment');
|
|
56
|
+
create index if not exists nos_holds_expiry_ix on nos_holds (expires_at) where state = 'held';
|
|
57
|
+
create index if not exists nos_holds_deadline_ix on nos_holds (payment_deadline) where state = 'awaiting_payment';
|
|
58
|
+
|
|
59
|
+
create table if not exists nos_hold_events (
|
|
60
|
+
id bigserial primary key,
|
|
61
|
+
hold_id uuid not null references nos_holds (id),
|
|
62
|
+
event text not null,
|
|
63
|
+
detail jsonb not null default '{}'::jsonb,
|
|
64
|
+
at timestamptz not null default clock_timestamp()
|
|
65
|
+
);
|
|
66
|
+
create index if not exists nos_hold_events_hold_ix on nos_hold_events (hold_id, id);
|
|
67
|
+
|
|
68
|
+
-- Every function returns this shape. status carries the outcome; the rest is context for the caller.
|
|
69
|
+
do $$ begin
|
|
70
|
+
create type nos_result as (
|
|
71
|
+
status text,
|
|
72
|
+
hold_id uuid,
|
|
73
|
+
resource_id text,
|
|
74
|
+
account_id text,
|
|
75
|
+
qty integer,
|
|
76
|
+
state nos_hold_state,
|
|
77
|
+
expires_at timestamptz,
|
|
78
|
+
payment_deadline timestamptz,
|
|
79
|
+
payment_ref text,
|
|
80
|
+
existing_payment_ref text,
|
|
81
|
+
other_hold_id uuid,
|
|
82
|
+
available integer
|
|
83
|
+
);
|
|
84
|
+
exception when duplicate_object then null; end $$;
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
-- neveroversell: functions
|
|
2
|
+
--
|
|
3
|
+
-- Rules that every function follows:
|
|
4
|
+
-- 1. Default READ COMMITTED isolation. No SERIALIZABLE, no client retries.
|
|
5
|
+
-- 2. Lock the resource row first with FOR NO KEY UPDATE. Functions that start from a hold id read the
|
|
6
|
+
-- hold's resource_id without a lock, lock the resource, then re-read the hold under that lock.
|
|
7
|
+
-- 3. An account-wide cap takes an advisory lock on the account BEFORE the resource lock.
|
|
8
|
+
-- 4. Expected outcomes are statuses in nos_result. Exceptions are for programmer errors only.
|
|
9
|
+
-- 5. All time decisions use clock_timestamp() captured once at the top of the call.
|
|
10
|
+
|
|
11
|
+
-- Build a nos_result from a hold row plus optional context.
|
|
12
|
+
create or replace function nos_pack(
|
|
13
|
+
p_status text,
|
|
14
|
+
p_hold nos_holds,
|
|
15
|
+
p_resource text default null,
|
|
16
|
+
p_available integer default null,
|
|
17
|
+
p_payment_ref text default null,
|
|
18
|
+
p_existing_ref text default null,
|
|
19
|
+
p_other uuid default null
|
|
20
|
+
) returns nos_result language sql immutable as $$
|
|
21
|
+
select row(
|
|
22
|
+
p_status,
|
|
23
|
+
(p_hold).id,
|
|
24
|
+
coalesce((p_hold).resource_id, p_resource),
|
|
25
|
+
(p_hold).account_id,
|
|
26
|
+
(p_hold).qty,
|
|
27
|
+
(p_hold).state,
|
|
28
|
+
(p_hold).expires_at,
|
|
29
|
+
(p_hold).payment_deadline,
|
|
30
|
+
coalesce(p_payment_ref, (p_hold).payment_ref),
|
|
31
|
+
p_existing_ref,
|
|
32
|
+
p_other,
|
|
33
|
+
p_available
|
|
34
|
+
)::nos_result;
|
|
35
|
+
$$;
|
|
36
|
+
|
|
37
|
+
-- Create a resource or change its capacity. Lowering total below held + sold is refused.
|
|
38
|
+
create or replace function nos_upsert_resource(p_id text, p_total integer)
|
|
39
|
+
returns nos_result language plpgsql as $$
|
|
40
|
+
declare
|
|
41
|
+
r nos_resources%rowtype;
|
|
42
|
+
v_now timestamptz := clock_timestamp();
|
|
43
|
+
v_status text;
|
|
44
|
+
begin
|
|
45
|
+
if p_id is null or p_id = '' then
|
|
46
|
+
raise exception 'resource id is required' using errcode = '22023';
|
|
47
|
+
end if;
|
|
48
|
+
if p_total is null or p_total < 0 then
|
|
49
|
+
raise exception 'total must be zero or positive' using errcode = '22023';
|
|
50
|
+
end if;
|
|
51
|
+
v_status := case when exists (select 1 from nos_resources where id = p_id) then 'updated' else 'created' end;
|
|
52
|
+
begin
|
|
53
|
+
insert into nos_resources (id, total) values (p_id, p_total)
|
|
54
|
+
on conflict (id) do update set total = excluded.total, updated_at = v_now
|
|
55
|
+
returning * into r;
|
|
56
|
+
exception when check_violation then
|
|
57
|
+
select * into r from nos_resources where id = p_id;
|
|
58
|
+
return nos_pack('capacity_below_committed', null::nos_holds, p_id, r.total - r.held - r.sold);
|
|
59
|
+
end;
|
|
60
|
+
return nos_pack(v_status, null::nos_holds, p_id, r.total - r.held - r.sold);
|
|
61
|
+
end $$;
|
|
62
|
+
|
|
63
|
+
-- Take a hold. Statuses: held | replayed | insufficient | account_cap | unknown_resource
|
|
64
|
+
create or replace function nos_hold(
|
|
65
|
+
p_resource text,
|
|
66
|
+
p_qty integer,
|
|
67
|
+
p_account text,
|
|
68
|
+
p_idem text default null,
|
|
69
|
+
p_ttl interval default null
|
|
70
|
+
) returns nos_result language plpgsql as $$
|
|
71
|
+
declare
|
|
72
|
+
s nos_settings%rowtype;
|
|
73
|
+
r nos_resources%rowtype;
|
|
74
|
+
h nos_holds%rowtype;
|
|
75
|
+
v_now timestamptz := clock_timestamp();
|
|
76
|
+
v_ttl interval;
|
|
77
|
+
v_exp integer;
|
|
78
|
+
v_avail integer;
|
|
79
|
+
v_used integer;
|
|
80
|
+
begin
|
|
81
|
+
if p_qty is null or p_qty <= 0 then
|
|
82
|
+
raise exception 'qty must be positive' using errcode = '22023';
|
|
83
|
+
end if;
|
|
84
|
+
if p_account is null or p_account = '' then
|
|
85
|
+
raise exception 'account id is required' using errcode = '22023';
|
|
86
|
+
end if;
|
|
87
|
+
select * into s from nos_settings;
|
|
88
|
+
v_ttl := coalesce(p_ttl, s.hold_ttl);
|
|
89
|
+
if v_ttl <= interval '0' then
|
|
90
|
+
raise exception 'ttl must be positive' using errcode = '22023';
|
|
91
|
+
end if;
|
|
92
|
+
|
|
93
|
+
-- 1. Idempotent replay, before any lock.
|
|
94
|
+
if p_idem is not null then
|
|
95
|
+
select * into h from nos_holds
|
|
96
|
+
where resource_id = p_resource and account_id = p_account and idempotency_key = p_idem;
|
|
97
|
+
if found then
|
|
98
|
+
return nos_pack('replayed', h);
|
|
99
|
+
end if;
|
|
100
|
+
end if;
|
|
101
|
+
|
|
102
|
+
-- 2. An account-wide cap needs an account lock, always before the resource lock.
|
|
103
|
+
if s.account_cap is not null and s.cap_scope = 'account' then
|
|
104
|
+
perform pg_advisory_xact_lock(hashtext('nos:account:' || p_account));
|
|
105
|
+
end if;
|
|
106
|
+
|
|
107
|
+
-- 3. The resource lock. Everything below happens under it.
|
|
108
|
+
select * into r from nos_resources where id = p_resource for no key update;
|
|
109
|
+
if not found then
|
|
110
|
+
return nos_pack('unknown_resource', null::nos_holds, p_resource, null);
|
|
111
|
+
end if;
|
|
112
|
+
|
|
113
|
+
-- 4. Expire this resource's plain holds that are past their TTL. Payment-phase holds are left alone.
|
|
114
|
+
with x as (
|
|
115
|
+
update nos_holds set state = 'expired', updated_at = v_now
|
|
116
|
+
where resource_id = p_resource and state = 'held' and expires_at <= v_now
|
|
117
|
+
returning id, qty
|
|
118
|
+
), ev as (
|
|
119
|
+
insert into nos_hold_events (hold_id, event, detail)
|
|
120
|
+
select id, 'expired', jsonb_build_object('by', 'hold', 'reason', 'ttl') from x
|
|
121
|
+
)
|
|
122
|
+
select coalesce(sum(qty), 0)::integer into v_exp from x;
|
|
123
|
+
if v_exp > 0 then
|
|
124
|
+
update nos_resources set held = held - v_exp, updated_at = v_now
|
|
125
|
+
where id = p_resource returning * into r;
|
|
126
|
+
end if;
|
|
127
|
+
|
|
128
|
+
-- 5. Availability from the counters, never from counting rows.
|
|
129
|
+
v_avail := r.total - r.held - r.sold;
|
|
130
|
+
if p_qty > v_avail then
|
|
131
|
+
return nos_pack('insufficient', null::nos_holds, p_resource, v_avail);
|
|
132
|
+
end if;
|
|
133
|
+
|
|
134
|
+
-- 6. Per-account cap.
|
|
135
|
+
if s.account_cap is not null then
|
|
136
|
+
select coalesce(sum(qty), 0)::integer into v_used from nos_holds
|
|
137
|
+
where account_id = p_account and state in ('held', 'awaiting_payment')
|
|
138
|
+
and (s.cap_scope = 'account' or resource_id = p_resource);
|
|
139
|
+
if v_used + p_qty > s.account_cap then
|
|
140
|
+
return nos_pack('account_cap', null::nos_holds, p_resource, v_avail);
|
|
141
|
+
end if;
|
|
142
|
+
end if;
|
|
143
|
+
|
|
144
|
+
-- 7. Record the hold, then move the counter. Both under the lock.
|
|
145
|
+
begin
|
|
146
|
+
insert into nos_holds (resource_id, account_id, qty, state, idempotency_key, expires_at)
|
|
147
|
+
values (p_resource, p_account, p_qty, 'held', p_idem, v_now + v_ttl)
|
|
148
|
+
returning * into h;
|
|
149
|
+
exception when unique_violation then
|
|
150
|
+
-- A concurrent call with the same idempotency key committed first. Return its hold.
|
|
151
|
+
select * into h from nos_holds
|
|
152
|
+
where resource_id = p_resource and account_id = p_account and idempotency_key = p_idem;
|
|
153
|
+
return nos_pack('replayed', h);
|
|
154
|
+
end;
|
|
155
|
+
|
|
156
|
+
update nos_resources set held = held + p_qty, updated_at = v_now where id = p_resource;
|
|
157
|
+
insert into nos_hold_events (hold_id, event, detail)
|
|
158
|
+
values (h.id, 'held', jsonb_build_object('qty', p_qty, 'ttl', v_ttl::text));
|
|
159
|
+
|
|
160
|
+
return nos_pack('held', h, null, v_avail - p_qty);
|
|
161
|
+
end $$;
|
|
162
|
+
|
|
163
|
+
-- Enter the payment phase. Statuses: awaiting_payment | replayed | expired | confirmed | released | not_found
|
|
164
|
+
create or replace function nos_begin_payment(p_hold uuid, p_window interval default null)
|
|
165
|
+
returns nos_result language plpgsql as $$
|
|
166
|
+
declare
|
|
167
|
+
s nos_settings%rowtype;
|
|
168
|
+
h nos_holds%rowtype;
|
|
169
|
+
v_res text;
|
|
170
|
+
v_now timestamptz := clock_timestamp();
|
|
171
|
+
begin
|
|
172
|
+
select * into s from nos_settings;
|
|
173
|
+
select resource_id into v_res from nos_holds where id = p_hold;
|
|
174
|
+
if not found then
|
|
175
|
+
return nos_pack('not_found', null::nos_holds);
|
|
176
|
+
end if;
|
|
177
|
+
perform 1 from nos_resources where id = v_res for no key update;
|
|
178
|
+
select * into h from nos_holds where id = p_hold;
|
|
179
|
+
|
|
180
|
+
if h.state = 'held' then
|
|
181
|
+
if h.expires_at <= v_now then
|
|
182
|
+
update nos_holds set state = 'expired', updated_at = v_now where id = h.id returning * into h;
|
|
183
|
+
update nos_resources set held = held - h.qty, updated_at = v_now where id = v_res;
|
|
184
|
+
insert into nos_hold_events (hold_id, event, detail)
|
|
185
|
+
values (h.id, 'expired', jsonb_build_object('by', 'begin_payment', 'reason', 'ttl'));
|
|
186
|
+
return nos_pack('expired', h);
|
|
187
|
+
end if;
|
|
188
|
+
update nos_holds
|
|
189
|
+
set state = 'awaiting_payment',
|
|
190
|
+
payment_started_at = v_now,
|
|
191
|
+
payment_deadline = v_now + coalesce(p_window, s.payment_window),
|
|
192
|
+
updated_at = v_now
|
|
193
|
+
where id = h.id returning * into h;
|
|
194
|
+
insert into nos_hold_events (hold_id, event, detail)
|
|
195
|
+
values (h.id, 'awaiting_payment', jsonb_build_object('deadline', h.payment_deadline));
|
|
196
|
+
return nos_pack('awaiting_payment', h);
|
|
197
|
+
elsif h.state = 'awaiting_payment' then
|
|
198
|
+
return nos_pack('replayed', h);
|
|
199
|
+
else
|
|
200
|
+
return nos_pack(h.state::text, h);
|
|
201
|
+
end if;
|
|
202
|
+
end $$;
|
|
203
|
+
|
|
204
|
+
-- Confirm a hold with a payment reference. Idempotent across both confirmation paths.
|
|
205
|
+
-- Statuses: confirmed | already_confirmed | duplicate_payment | payment_ref_in_use | expired | released | not_found
|
|
206
|
+
create or replace function nos_confirm(p_hold uuid, p_payment_ref text)
|
|
207
|
+
returns nos_result language plpgsql as $$
|
|
208
|
+
declare
|
|
209
|
+
h nos_holds%rowtype;
|
|
210
|
+
v_res text;
|
|
211
|
+
v_other uuid;
|
|
212
|
+
v_now timestamptz := clock_timestamp();
|
|
213
|
+
begin
|
|
214
|
+
if p_payment_ref is null or p_payment_ref = '' then
|
|
215
|
+
raise exception 'payment_ref is required' using errcode = '22023';
|
|
216
|
+
end if;
|
|
217
|
+
select resource_id into v_res from nos_holds where id = p_hold;
|
|
218
|
+
if not found then
|
|
219
|
+
return nos_pack('not_found', null::nos_holds, null, null, p_payment_ref);
|
|
220
|
+
end if;
|
|
221
|
+
perform 1 from nos_resources where id = v_res for no key update;
|
|
222
|
+
select * into h from nos_holds where id = p_hold;
|
|
223
|
+
|
|
224
|
+
if h.state in ('held', 'awaiting_payment') then
|
|
225
|
+
-- A plain hold past its TTL is expired here rather than confirmed; a payment-phase hold is
|
|
226
|
+
-- honoured until the sweeper expires it, because the customer has paid and the units are still held.
|
|
227
|
+
if h.state = 'held' and h.expires_at <= v_now then
|
|
228
|
+
update nos_holds set state = 'expired', updated_at = v_now where id = h.id returning * into h;
|
|
229
|
+
update nos_resources set held = held - h.qty, updated_at = v_now where id = v_res;
|
|
230
|
+
insert into nos_hold_events (hold_id, event, detail)
|
|
231
|
+
values (h.id, 'expired', jsonb_build_object('by', 'confirm', 'reason', 'ttl', 'payment_ref', p_payment_ref));
|
|
232
|
+
return nos_pack('expired', h, null, null, p_payment_ref);
|
|
233
|
+
end if;
|
|
234
|
+
|
|
235
|
+
-- Has this payment already bought a different hold?
|
|
236
|
+
select id into v_other from nos_holds where payment_ref = p_payment_ref and id <> h.id;
|
|
237
|
+
if found then
|
|
238
|
+
insert into nos_hold_events (hold_id, event, detail)
|
|
239
|
+
values (h.id, 'payment_ref_in_use', jsonb_build_object('payment_ref', p_payment_ref, 'other_hold_id', v_other));
|
|
240
|
+
return nos_pack('payment_ref_in_use', h, null, null, p_payment_ref, null, v_other);
|
|
241
|
+
end if;
|
|
242
|
+
|
|
243
|
+
begin
|
|
244
|
+
update nos_holds set state = 'confirmed', payment_ref = p_payment_ref, updated_at = v_now
|
|
245
|
+
where id = h.id returning * into h;
|
|
246
|
+
exception when unique_violation then
|
|
247
|
+
-- Two different holds raced to confirm with the same payment reference. The unique index decided.
|
|
248
|
+
select id into v_other from nos_holds where payment_ref = p_payment_ref and id <> h.id;
|
|
249
|
+
insert into nos_hold_events (hold_id, event, detail)
|
|
250
|
+
values (h.id, 'payment_ref_in_use', jsonb_build_object('payment_ref', p_payment_ref, 'other_hold_id', v_other));
|
|
251
|
+
return nos_pack('payment_ref_in_use', h, null, null, p_payment_ref, null, v_other);
|
|
252
|
+
end;
|
|
253
|
+
update nos_resources set held = held - h.qty, sold = sold + h.qty, updated_at = v_now where id = v_res;
|
|
254
|
+
insert into nos_hold_events (hold_id, event, detail)
|
|
255
|
+
values (h.id, 'confirmed', jsonb_build_object('payment_ref', p_payment_ref));
|
|
256
|
+
return nos_pack('confirmed', h);
|
|
257
|
+
|
|
258
|
+
elsif h.state = 'confirmed' then
|
|
259
|
+
if h.payment_ref = p_payment_ref then
|
|
260
|
+
-- The second confirmation path for the same payment: a no-op that returns the same answer.
|
|
261
|
+
return nos_pack('already_confirmed', h);
|
|
262
|
+
end if;
|
|
263
|
+
-- A different payment for an already-confirmed hold: the customer paid twice. Caller refunds p_payment_ref.
|
|
264
|
+
insert into nos_hold_events (hold_id, event, detail)
|
|
265
|
+
values (h.id, 'duplicate_payment', jsonb_build_object('payment_ref', p_payment_ref, 'existing_payment_ref', h.payment_ref));
|
|
266
|
+
return nos_pack('duplicate_payment', h, null, null, p_payment_ref, h.payment_ref);
|
|
267
|
+
|
|
268
|
+
else
|
|
269
|
+
-- released or expired: a late confirmation never revives a hold. Caller refunds p_payment_ref.
|
|
270
|
+
insert into nos_hold_events (hold_id, event, detail)
|
|
271
|
+
values (h.id, 'late_confirm', jsonb_build_object('state', h.state, 'payment_ref', p_payment_ref));
|
|
272
|
+
return nos_pack(h.state::text, h, null, null, p_payment_ref);
|
|
273
|
+
end if;
|
|
274
|
+
end $$;
|
|
275
|
+
|
|
276
|
+
-- Release a hold explicitly. Statuses: released | already_released | confirmed | expired | not_found
|
|
277
|
+
create or replace function nos_release(p_hold uuid, p_reason text default null)
|
|
278
|
+
returns nos_result language plpgsql as $$
|
|
279
|
+
declare
|
|
280
|
+
h nos_holds%rowtype;
|
|
281
|
+
v_res text;
|
|
282
|
+
v_now timestamptz := clock_timestamp();
|
|
283
|
+
begin
|
|
284
|
+
select resource_id into v_res from nos_holds where id = p_hold;
|
|
285
|
+
if not found then
|
|
286
|
+
return nos_pack('not_found', null::nos_holds);
|
|
287
|
+
end if;
|
|
288
|
+
perform 1 from nos_resources where id = v_res for no key update;
|
|
289
|
+
select * into h from nos_holds where id = p_hold;
|
|
290
|
+
|
|
291
|
+
if h.state in ('held', 'awaiting_payment') then
|
|
292
|
+
update nos_holds set state = 'released', updated_at = v_now where id = h.id returning * into h;
|
|
293
|
+
update nos_resources set held = held - h.qty, updated_at = v_now where id = v_res;
|
|
294
|
+
insert into nos_hold_events (hold_id, event, detail)
|
|
295
|
+
values (h.id, 'released', jsonb_build_object('reason', p_reason));
|
|
296
|
+
return nos_pack('released', h);
|
|
297
|
+
elsif h.state = 'released' then
|
|
298
|
+
return nos_pack('already_released', h);
|
|
299
|
+
else
|
|
300
|
+
return nos_pack(h.state::text, h);
|
|
301
|
+
end if;
|
|
302
|
+
end $$;
|
|
303
|
+
|
|
304
|
+
-- Extend a plain hold's TTL, a bounded number of times. Statuses: extended | not_extendable | not_found
|
|
305
|
+
create or replace function nos_extend(p_hold uuid, p_by interval default null)
|
|
306
|
+
returns nos_result language plpgsql as $$
|
|
307
|
+
declare
|
|
308
|
+
s nos_settings%rowtype;
|
|
309
|
+
h nos_holds%rowtype;
|
|
310
|
+
v_res text;
|
|
311
|
+
v_now timestamptz := clock_timestamp();
|
|
312
|
+
begin
|
|
313
|
+
select * into s from nos_settings;
|
|
314
|
+
select resource_id into v_res from nos_holds where id = p_hold;
|
|
315
|
+
if not found then
|
|
316
|
+
return nos_pack('not_found', null::nos_holds);
|
|
317
|
+
end if;
|
|
318
|
+
perform 1 from nos_resources where id = v_res for no key update;
|
|
319
|
+
select * into h from nos_holds where id = p_hold;
|
|
320
|
+
|
|
321
|
+
if h.state = 'held' and h.expires_at > v_now and h.extensions < s.max_extensions then
|
|
322
|
+
update nos_holds
|
|
323
|
+
set expires_at = greatest(expires_at, v_now) + coalesce(p_by, s.extension),
|
|
324
|
+
extensions = extensions + 1,
|
|
325
|
+
updated_at = v_now
|
|
326
|
+
where id = h.id returning * into h;
|
|
327
|
+
insert into nos_hold_events (hold_id, event, detail)
|
|
328
|
+
values (h.id, 'extended', jsonb_build_object('expires_at', h.expires_at, 'extensions', h.extensions));
|
|
329
|
+
return nos_pack('extended', h);
|
|
330
|
+
end if;
|
|
331
|
+
insert into nos_hold_events (hold_id, event, detail)
|
|
332
|
+
values (h.id, 'extend_refused', jsonb_build_object(
|
|
333
|
+
'state', h.state,
|
|
334
|
+
'reason', case
|
|
335
|
+
when h.state <> 'held' then h.state::text
|
|
336
|
+
when h.expires_at <= v_now then 'expired'
|
|
337
|
+
else 'max_extensions' end));
|
|
338
|
+
return nos_pack('not_extendable', h);
|
|
339
|
+
end $$;
|
|
340
|
+
|
|
341
|
+
-- Expire plain holds past their TTL and payment-phase holds past their deadline.
|
|
342
|
+
-- Safe to run from several workers at once: per-resource row lock plus conditional updates.
|
|
343
|
+
create or replace function nos_sweep(p_limit integer default 1000)
|
|
344
|
+
returns table (resource_id text, expired_holds integer, expired_qty integer) language plpgsql as $$
|
|
345
|
+
declare
|
|
346
|
+
v_res text;
|
|
347
|
+
v_now timestamptz := clock_timestamp();
|
|
348
|
+
v_n integer;
|
|
349
|
+
v_q integer;
|
|
350
|
+
begin
|
|
351
|
+
for v_res in
|
|
352
|
+
select distinct h.resource_id from nos_holds h
|
|
353
|
+
where (h.state = 'held' and h.expires_at <= v_now)
|
|
354
|
+
or (h.state = 'awaiting_payment' and h.payment_deadline <= v_now)
|
|
355
|
+
order by h.resource_id
|
|
356
|
+
limit p_limit
|
|
357
|
+
loop
|
|
358
|
+
perform 1 from nos_resources where id = v_res for no key update;
|
|
359
|
+
with x as (
|
|
360
|
+
update nos_holds set state = 'expired', updated_at = v_now
|
|
361
|
+
where nos_holds.resource_id = v_res
|
|
362
|
+
and ((state = 'held' and expires_at <= v_now)
|
|
363
|
+
or (state = 'awaiting_payment' and payment_deadline <= v_now))
|
|
364
|
+
returning id, qty, (payment_deadline is not null) as was_paying
|
|
365
|
+
), ev as (
|
|
366
|
+
insert into nos_hold_events (hold_id, event, detail)
|
|
367
|
+
select id, 'expired', jsonb_build_object('by', 'sweep', 'reason', case when was_paying then 'payment_window' else 'ttl' end)
|
|
368
|
+
from x
|
|
369
|
+
)
|
|
370
|
+
select count(*)::integer, coalesce(sum(qty), 0)::integer into v_n, v_q from x;
|
|
371
|
+
if v_q > 0 then
|
|
372
|
+
update nos_resources set held = held - v_q, updated_at = v_now where id = v_res;
|
|
373
|
+
end if;
|
|
374
|
+
if v_n > 0 then
|
|
375
|
+
resource_id := v_res; expired_holds := v_n; expired_qty := v_q;
|
|
376
|
+
return next;
|
|
377
|
+
end if;
|
|
378
|
+
end loop;
|
|
379
|
+
end $$;
|
|
380
|
+
|
|
381
|
+
-- Compare the counters with the rows. drift = true means something is wrong and nos_repair may be used.
|
|
382
|
+
create or replace function nos_check()
|
|
383
|
+
returns table (resource_id text, held integer, held_by_rows bigint, sold integer, sold_by_rows bigint, drift boolean)
|
|
384
|
+
language sql stable as $$
|
|
385
|
+
select r.id,
|
|
386
|
+
r.held,
|
|
387
|
+
coalesce(sum(h.qty) filter (where h.state in ('held', 'awaiting_payment')), 0),
|
|
388
|
+
r.sold,
|
|
389
|
+
coalesce(sum(h.qty) filter (where h.state = 'confirmed'), 0),
|
|
390
|
+
r.held <> coalesce(sum(h.qty) filter (where h.state in ('held', 'awaiting_payment')), 0)
|
|
391
|
+
or r.sold <> coalesce(sum(h.qty) filter (where h.state = 'confirmed'), 0)
|
|
392
|
+
from nos_resources r
|
|
393
|
+
left join nos_holds h on h.resource_id = r.id
|
|
394
|
+
group by r.id, r.held, r.sold
|
|
395
|
+
order by r.id;
|
|
396
|
+
$$;
|
|
397
|
+
|
|
398
|
+
-- Recompute one resource's counters from its rows, under the resource lock. Explicit opt-in only.
|
|
399
|
+
create or replace function nos_repair(p_resource text)
|
|
400
|
+
returns table (resource_id text, held_before integer, held_after integer, sold_before integer, sold_after integer)
|
|
401
|
+
language plpgsql as $$
|
|
402
|
+
declare
|
|
403
|
+
r nos_resources%rowtype;
|
|
404
|
+
v_held integer;
|
|
405
|
+
v_sold integer;
|
|
406
|
+
begin
|
|
407
|
+
select * into r from nos_resources where id = p_resource for no key update;
|
|
408
|
+
if not found then
|
|
409
|
+
return;
|
|
410
|
+
end if;
|
|
411
|
+
select coalesce(sum(qty) filter (where state in ('held', 'awaiting_payment')), 0)::integer,
|
|
412
|
+
coalesce(sum(qty) filter (where state = 'confirmed'), 0)::integer
|
|
413
|
+
into v_held, v_sold
|
|
414
|
+
from nos_holds where nos_holds.resource_id = p_resource;
|
|
415
|
+
update nos_resources set held = v_held, sold = v_sold, updated_at = clock_timestamp() where id = p_resource;
|
|
416
|
+
resource_id := p_resource; held_before := r.held; held_after := v_held; sold_before := r.sold; sold_after := v_sold;
|
|
417
|
+
return next;
|
|
418
|
+
end $$;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
-- neveroversell: observability views
|
|
2
|
+
|
|
3
|
+
create or replace view nos_holds_by_state as
|
|
4
|
+
select resource_id,
|
|
5
|
+
state,
|
|
6
|
+
count(*)::integer as holds,
|
|
7
|
+
sum(qty)::integer as qty,
|
|
8
|
+
min(created_at) as oldest,
|
|
9
|
+
max(created_at) as newest
|
|
10
|
+
from nos_holds
|
|
11
|
+
group by resource_id, state;
|
|
12
|
+
|
|
13
|
+
create or replace view nos_resource_status as
|
|
14
|
+
select r.id as resource_id,
|
|
15
|
+
r.total,
|
|
16
|
+
r.held,
|
|
17
|
+
r.sold,
|
|
18
|
+
r.total - r.held - r.sold as available,
|
|
19
|
+
coalesce(sum(h.qty) filter (where h.state = 'held'), 0)::integer as held_plain,
|
|
20
|
+
coalesce(sum(h.qty) filter (where h.state = 'awaiting_payment'), 0)::integer as held_paying,
|
|
21
|
+
r.updated_at
|
|
22
|
+
from nos_resources r
|
|
23
|
+
left join nos_holds h on h.resource_id = r.id and h.state in ('held', 'awaiting_payment')
|
|
24
|
+
group by r.id;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "neveroversell"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Holds that cannot oversell. Postgres reservations with a TTL, a payment phase, idempotent two-path confirmation, a concurrent-safe sweeper and a drift check."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "Pavan Gupta", email = "pavan.gupta.352@gmail.com" }]
|
|
13
|
+
keywords = ["postgres", "postgresql", "inventory", "reservation", "hold", "booking", "seat", "oversell", "idempotent", "payment", "webhook", "concurrency"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Database",
|
|
24
|
+
"Topic :: Software Development :: Libraries",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
dependencies = ["psycopg[binary,pool]>=3.1"]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
dev = ["pytest>=8.0"]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/pavangupta352/neveroversell"
|
|
34
|
+
Repository = "https://github.com/pavangupta352/neveroversell"
|
|
35
|
+
Issues = "https://github.com/pavangupta352/neveroversell/issues"
|
|
36
|
+
Changelog = "https://github.com/pavangupta352/neveroversell/blob/main/CHANGELOG.md"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["neveroversell"]
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.sdist]
|
|
42
|
+
include = ["neveroversell", "tests", "README.md"]
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""The Python client against a real Postgres. Same guarantees as the TypeScript suite, same SQL."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
9
|
+
from datetime import timedelta
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import psycopg
|
|
13
|
+
import pytest
|
|
14
|
+
from psycopg.rows import dict_row
|
|
15
|
+
from psycopg_pool import ConnectionPool
|
|
16
|
+
|
|
17
|
+
from neveroversell import Inventory, SweepRow, sql_files
|
|
18
|
+
|
|
19
|
+
DATABASE_URL = os.environ.get("DATABASE_URL", "postgres://nos:nos@127.0.0.1:54329/nos")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture(scope="module")
|
|
23
|
+
def pool():
|
|
24
|
+
p = ConnectionPool(DATABASE_URL, min_size=2, max_size=40, kwargs={"row_factory": dict_row}, open=True)
|
|
25
|
+
yield p
|
|
26
|
+
p.close()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture(scope="module")
|
|
30
|
+
def inv(pool):
|
|
31
|
+
i = Inventory(pool=pool)
|
|
32
|
+
i.migrate()
|
|
33
|
+
return i
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.fixture(autouse=True)
|
|
37
|
+
def clean(pool, inv):
|
|
38
|
+
with pool.connection() as conn:
|
|
39
|
+
conn.execute("truncate nos_hold_events, nos_holds, nos_resources restart identity cascade")
|
|
40
|
+
conn.execute(
|
|
41
|
+
"update nos_settings set hold_ttl = default, payment_window = default, account_cap = default, cap_scope = default, max_extensions = default, extension = default"
|
|
42
|
+
)
|
|
43
|
+
inv.apply_settings()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def no_drift(inv: Inventory) -> None:
|
|
47
|
+
assert [c for c in inv.check() if c.drift] == []
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_bundled_sql_matches_the_repository_sql():
|
|
51
|
+
root = Path(__file__).resolve().parents[2] / "sql"
|
|
52
|
+
if not root.exists():
|
|
53
|
+
pytest.skip("repository sql/ not present (installed package)")
|
|
54
|
+
bundled = dict(sql_files())
|
|
55
|
+
repo = {p.name: p.read_text(encoding="utf-8") for p in sorted(root.glob("*.sql"))}
|
|
56
|
+
assert bundled == repo
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_migrations_are_idempotent(inv):
|
|
60
|
+
assert inv.migrate() == []
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_500_concurrent_holds_on_10_units(inv):
|
|
64
|
+
inv.upsert_resource("gig", 10)
|
|
65
|
+
with ThreadPoolExecutor(max_workers=40) as ex:
|
|
66
|
+
results = list(ex.map(lambda i: inv.hold("gig", 1, f"buyer-{i}"), range(500)))
|
|
67
|
+
assert Counter(r.status for r in results) == {"held": 10, "insufficient": 490}
|
|
68
|
+
s = inv.status("gig")
|
|
69
|
+
assert (s.held, s.sold, s.available) == (10, 0, 0)
|
|
70
|
+
no_drift(inv)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_confirm_is_idempotent_across_both_paths(inv):
|
|
74
|
+
inv.upsert_resource("r", 5)
|
|
75
|
+
h = inv.hold("r", 2, "a")
|
|
76
|
+
assert h.status == "held"
|
|
77
|
+
with ThreadPoolExecutor(max_workers=12) as ex:
|
|
78
|
+
results = list(ex.map(lambda _: inv.confirm(h.hold.id, "pay_1"), range(12)))
|
|
79
|
+
assert Counter(r.status for r in results) == {"confirmed": 1, "already_confirmed": 11}
|
|
80
|
+
s = inv.status("r")
|
|
81
|
+
assert (s.held, s.sold, s.available) == (0, 2, 3)
|
|
82
|
+
no_drift(inv)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_duplicate_payment_is_reported_with_both_references(inv):
|
|
86
|
+
inv.upsert_resource("r", 5)
|
|
87
|
+
h = inv.hold("r", 1, "a")
|
|
88
|
+
assert inv.confirm(h.hold.id, "pay_A").status == "confirmed"
|
|
89
|
+
dup = inv.confirm(h.hold.id, "pay_B")
|
|
90
|
+
assert (dup.status, dup.payment_ref, dup.existing_payment_ref) == ("duplicate_payment", "pay_B", "pay_A")
|
|
91
|
+
assert [e.event for e in inv.events(h.hold.id)] == ["held", "confirmed", "duplicate_payment"]
|
|
92
|
+
no_drift(inv)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_one_payment_cannot_buy_two_holds(inv, pool):
|
|
96
|
+
inv.upsert_resource("r1", 5)
|
|
97
|
+
inv.upsert_resource("r2", 5)
|
|
98
|
+
h1 = inv.hold("r1", 1, "a")
|
|
99
|
+
h2 = inv.hold("r2", 1, "a")
|
|
100
|
+
with ThreadPoolExecutor(max_workers=2) as ex:
|
|
101
|
+
results = list(ex.map(lambda hid: inv.confirm(hid, "pay_shared"), [h1.hold.id, h2.hold.id]))
|
|
102
|
+
assert Counter(r.status for r in results) == {"confirmed": 1, "payment_ref_in_use": 1}
|
|
103
|
+
used = next(r for r in results if r.status == "payment_ref_in_use")
|
|
104
|
+
winner = next(r for r in results if r.status == "confirmed")
|
|
105
|
+
assert used.other_hold_id == winner.hold.id
|
|
106
|
+
with pool.connection() as conn:
|
|
107
|
+
n = conn.execute("select count(*) as n from nos_holds where payment_ref = 'pay_shared'").fetchone()["n"]
|
|
108
|
+
assert n == 1
|
|
109
|
+
no_drift(inv)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_late_confirmation_never_revives_a_hold(inv):
|
|
113
|
+
inv.upsert_resource("r", 1)
|
|
114
|
+
h = inv.hold("r", 1, "a", ttl=timedelta(milliseconds=100))
|
|
115
|
+
time.sleep(0.2)
|
|
116
|
+
assert inv.sweep() == [SweepRow("r", 1, 1)]
|
|
117
|
+
late = inv.confirm(h.hold.id, "pay_late")
|
|
118
|
+
assert (late.status, late.payment_ref, late.hold.state) == ("expired", "pay_late", "expired")
|
|
119
|
+
assert inv.hold("r", 1, "someone-else").status == "held"
|
|
120
|
+
no_drift(inv)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_payment_phase_outlives_the_ttl(inv):
|
|
124
|
+
inv.upsert_resource("r", 1)
|
|
125
|
+
h = inv.hold("r", 1, "a", ttl=timedelta(milliseconds=100))
|
|
126
|
+
assert inv.begin_payment(h.hold.id, window=timedelta(milliseconds=600)).status == "awaiting_payment"
|
|
127
|
+
time.sleep(0.25)
|
|
128
|
+
assert inv.sweep() == []
|
|
129
|
+
assert inv.hold("r", 1, "b").status == "insufficient"
|
|
130
|
+
assert inv.confirm(h.hold.id, "pay_slow_bank").status == "confirmed"
|
|
131
|
+
assert inv.status("r").sold == 1
|
|
132
|
+
no_drift(inv)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_idempotency_key_replays(inv):
|
|
136
|
+
inv.upsert_resource("r", 50)
|
|
137
|
+
with ThreadPoolExecutor(max_workers=20) as ex:
|
|
138
|
+
results = list(ex.map(lambda _: inv.hold("r", 1, "a", idempotency_key="same"), range(20)))
|
|
139
|
+
assert len({r.hold.id for r in results}) == 1
|
|
140
|
+
assert Counter(r.status for r in results)["held"] == 1
|
|
141
|
+
assert inv.status("r").held == 1
|
|
142
|
+
no_drift(inv)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_release_and_extend(inv):
|
|
146
|
+
inv.upsert_resource("r", 2)
|
|
147
|
+
h = inv.hold("r", 1, "a", ttl=timedelta(seconds=5))
|
|
148
|
+
assert inv.extend(h.hold.id, by=timedelta(minutes=1)).status == "extended"
|
|
149
|
+
assert inv.extend(h.hold.id, by=timedelta(minutes=1)).status == "not_extendable"
|
|
150
|
+
assert inv.release(h.hold.id, reason="changed mind").status == "released"
|
|
151
|
+
assert inv.release(h.hold.id).status == "already_released"
|
|
152
|
+
assert inv.confirm(h.hold.id, "pay_late").status == "released"
|
|
153
|
+
assert inv.status("r").available == 2
|
|
154
|
+
no_drift(inv)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_configuration_is_validated_twice(inv, pool):
|
|
158
|
+
with pytest.raises(ValueError, match="must exceed hold_ttl"):
|
|
159
|
+
Inventory(pool=pool, hold_ttl=timedelta(minutes=20), payment_window=timedelta(minutes=10))
|
|
160
|
+
with pool.connection() as conn, pytest.raises(psycopg.errors.CheckViolation):
|
|
161
|
+
conn.execute("update nos_settings set hold_ttl = interval '2 minutes', payment_window = interval '1 minute'")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_the_database_is_the_last_line_of_defence(inv, pool):
|
|
165
|
+
inv.upsert_resource("r", 3)
|
|
166
|
+
inv.hold("r", 2, "a")
|
|
167
|
+
with pool.connection() as conn, pytest.raises(psycopg.errors.CheckViolation):
|
|
168
|
+
conn.execute("update nos_resources set sold = total where id = 'r'")
|
|
169
|
+
assert inv.upsert_resource("r", 1).status == "capacity_below_committed"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_account_cap_across_resources(pool):
|
|
173
|
+
capped = Inventory(pool=pool, account_cap=3, cap_scope="account")
|
|
174
|
+
capped.apply_settings()
|
|
175
|
+
capped.upsert_resource("r1", 100)
|
|
176
|
+
capped.upsert_resource("r2", 100)
|
|
177
|
+
with ThreadPoolExecutor(max_workers=20) as ex:
|
|
178
|
+
results = list(ex.map(lambda i: capped.hold("r1" if i % 2 else "r2", 1, "greedy"), range(20)))
|
|
179
|
+
assert Counter(r.status for r in results) == {"held": 3, "account_cap": 17}
|
|
180
|
+
assert capped.status("r1").held + capped.status("r2").held == 3
|
|
181
|
+
no_drift(capped)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_programmer_errors_raise(inv):
|
|
185
|
+
inv.upsert_resource("r", 1)
|
|
186
|
+
with pytest.raises(psycopg.errors.InvalidParameterValue):
|
|
187
|
+
inv.hold("r", 0, "a")
|
|
188
|
+
assert inv.hold("nope", 1, "a").status == "unknown_resource"
|