harness-sdk-python 0.1.0__tar.gz → 0.2.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.
- {harness_sdk_python-0.1.0 → harness_sdk_python-0.2.0}/PKG-INFO +3 -1
- {harness_sdk_python-0.1.0 → harness_sdk_python-0.2.0}/pyproject.toml +4 -1
- harness_sdk_python-0.2.0/src/harness_sdk/fenced_postgres.py +220 -0
- harness_sdk_python-0.2.0/tests/test_fenced_postgres.py +255 -0
- {harness_sdk_python-0.1.0 → harness_sdk_python-0.2.0}/.gitignore +0 -0
- {harness_sdk_python-0.1.0 → harness_sdk_python-0.2.0}/README.md +0 -0
- {harness_sdk_python-0.1.0 → harness_sdk_python-0.2.0}/src/harness_sdk/__init__.py +0 -0
- {harness_sdk_python-0.1.0 → harness_sdk_python-0.2.0}/src/harness_sdk/run_manager.py +0 -0
- {harness_sdk_python-0.1.0 → harness_sdk_python-0.2.0}/tests/run_helpers.py +0 -0
- {harness_sdk_python-0.1.0 → harness_sdk_python-0.2.0}/tests/test_caller.py +0 -0
- {harness_sdk_python-0.1.0 → harness_sdk_python-0.2.0}/tests/test_edit_dispatched.py +0 -0
- {harness_sdk_python-0.1.0 → harness_sdk_python-0.2.0}/tests/test_edit_reload.py +0 -0
- {harness_sdk_python-0.1.0 → harness_sdk_python-0.2.0}/tests/test_enqueue.py +0 -0
- {harness_sdk_python-0.1.0 → harness_sdk_python-0.2.0}/tests/test_input_required.py +0 -0
- {harness_sdk_python-0.1.0 → harness_sdk_python-0.2.0}/tests/test_outcomes.py +0 -0
- {harness_sdk_python-0.1.0 → harness_sdk_python-0.2.0}/tests/test_placement.py +0 -0
- {harness_sdk_python-0.1.0 → harness_sdk_python-0.2.0}/tests/test_rewind_during_run.py +0 -0
- {harness_sdk_python-0.1.0 → harness_sdk_python-0.2.0}/tests/test_steer.py +0 -0
- {harness_sdk_python-0.1.0 → harness_sdk_python-0.2.0}/tests/test_stop_continue.py +0 -0
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: harness-sdk-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: RunManager: the harness-sdk runs subsystem for Python Statewire hosts
|
|
5
5
|
Project-URL: Repository, https://github.com/assistant-ui/harness-sdk
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Requires-Python: <4.0,>=3.11
|
|
8
8
|
Requires-Dist: statewire<0.3,>=0.2.0
|
|
9
|
+
Provides-Extra: postgres
|
|
10
|
+
Requires-Dist: langgraph-checkpoint-postgres>=2.0.0; extra == 'postgres'
|
|
9
11
|
Description-Content-Type: text/markdown
|
|
10
12
|
|
|
11
13
|
# harness-sdk-python
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "harness-sdk-python"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
description = "RunManager: the harness-sdk runs subsystem for Python Statewire hosts"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
7
7
|
requires-python = ">=3.11,<4.0"
|
|
8
8
|
dependencies = ["statewire>=0.2.0,<0.3"]
|
|
9
9
|
|
|
10
|
+
[project.optional-dependencies]
|
|
11
|
+
postgres = ["langgraph-checkpoint-postgres>=2.0.0"]
|
|
12
|
+
|
|
10
13
|
[project.urls]
|
|
11
14
|
Repository = "https://github.com/assistant-ui/harness-sdk"
|
|
12
15
|
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from collections.abc import AsyncIterator, Callable, Sequence
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
from langchain_core.runnables import RunnableConfig
|
|
8
|
+
from langgraph.checkpoint.base import SerializerProtocol
|
|
9
|
+
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver, _ainternal
|
|
10
|
+
from psycopg import AsyncPipeline, errors
|
|
11
|
+
from psycopg.cursor_async import AsyncCursor
|
|
12
|
+
from psycopg.rows import DictRow, dict_row
|
|
13
|
+
from psycopg.sql import SQL, Identifier, Literal
|
|
14
|
+
except ImportError as exc:
|
|
15
|
+
raise ImportError(
|
|
16
|
+
"harness_sdk.fenced_postgres requires the 'postgres' extra: "
|
|
17
|
+
"pip install 'harness-sdk-python[postgres]'"
|
|
18
|
+
) from exc
|
|
19
|
+
|
|
20
|
+
DEFAULT_FENCE_TABLE = "checkpoint_fence"
|
|
21
|
+
FENCE_LOST_SQLSTATE = "FL001"
|
|
22
|
+
|
|
23
|
+
_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FenceLost(RuntimeError):
|
|
27
|
+
def __init__(self, thread_id: str, epoch: int) -> None:
|
|
28
|
+
super().__init__(
|
|
29
|
+
f"fence lost for thread {thread_id!r}: epoch {epoch} is no longer current"
|
|
30
|
+
)
|
|
31
|
+
self.thread_id = thread_id
|
|
32
|
+
self.epoch = epoch
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _require_thread_id(thread_id: str) -> str:
|
|
36
|
+
if not isinstance(thread_id, str) or not thread_id:
|
|
37
|
+
raise ValueError(f"thread_id must be a non-empty string, got {thread_id!r}")
|
|
38
|
+
return thread_id
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _require_fence_table(fence_table: str) -> str:
|
|
42
|
+
if not isinstance(fence_table, str) or not _IDENTIFIER_RE.match(fence_table):
|
|
43
|
+
raise ValueError(
|
|
44
|
+
f"fence_table must be an unquoted SQL identifier, got {fence_table!r}"
|
|
45
|
+
)
|
|
46
|
+
return fence_table
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _assert_fn(fence_table: str) -> str:
|
|
50
|
+
return f"{fence_table}_assert"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _create_sql(fence_table: str) -> SQL:
|
|
54
|
+
return SQL(
|
|
55
|
+
"CREATE TABLE IF NOT EXISTS {} (thread_id TEXT PRIMARY KEY, epoch BIGINT NOT NULL)"
|
|
56
|
+
).format(Identifier(fence_table))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# FOR UPDATE holds the fence row against concurrent epoch bumps until the
|
|
60
|
+
# surrounding write transaction ends; the RAISE aborts the pipeline/transaction
|
|
61
|
+
def _create_fn_sql(fence_table: str) -> SQL:
|
|
62
|
+
return SQL(
|
|
63
|
+
"CREATE OR REPLACE FUNCTION {fn}(p_thread_id TEXT, p_epoch BIGINT) "
|
|
64
|
+
"RETURNS void LANGUAGE plpgsql AS $$\n"
|
|
65
|
+
"BEGIN\n"
|
|
66
|
+
" PERFORM 1 FROM {t} WHERE thread_id = p_thread_id AND epoch = p_epoch FOR UPDATE;\n"
|
|
67
|
+
" IF NOT FOUND THEN\n"
|
|
68
|
+
" RAISE EXCEPTION 'fence lost for thread %: epoch % is not current', "
|
|
69
|
+
"p_thread_id, p_epoch USING ERRCODE = {code};\n"
|
|
70
|
+
" END IF;\n"
|
|
71
|
+
"END;\n"
|
|
72
|
+
"$$"
|
|
73
|
+
).format(
|
|
74
|
+
fn=Identifier(_assert_fn(fence_table)),
|
|
75
|
+
t=Identifier(fence_table),
|
|
76
|
+
code=Literal(FENCE_LOST_SQLSTATE),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _bump_sql(fence_table: str) -> SQL:
|
|
81
|
+
return SQL(
|
|
82
|
+
"INSERT INTO {t} (thread_id, epoch) VALUES (%s, 1) "
|
|
83
|
+
"ON CONFLICT (thread_id) DO UPDATE SET epoch = {t}.epoch + 1 "
|
|
84
|
+
"RETURNING epoch"
|
|
85
|
+
).format(t=Identifier(fence_table))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _assert_sql(fence_table: str) -> SQL:
|
|
89
|
+
return SQL("SELECT {}(%s, %s)").format(Identifier(_assert_fn(fence_table)))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _is_fence_lost(exc: errors.Error) -> bool:
|
|
93
|
+
return exc.sqlstate == FENCE_LOST_SQLSTATE
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _missing_fence_error(missing: str) -> RuntimeError:
|
|
97
|
+
return RuntimeError(
|
|
98
|
+
f"{missing} does not exist; "
|
|
99
|
+
"pass create_fence_table=True or create it before acquiring"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class AsyncFencedPostgresSaver(AsyncPostgresSaver):
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
conn: _ainternal.Conn,
|
|
107
|
+
thread_id: str,
|
|
108
|
+
epoch: int,
|
|
109
|
+
*,
|
|
110
|
+
pipe: AsyncPipeline | None = None,
|
|
111
|
+
serde: SerializerProtocol | None = None,
|
|
112
|
+
fence_table: str = DEFAULT_FENCE_TABLE,
|
|
113
|
+
on_fence_lost: Callable[[], None] | None = None,
|
|
114
|
+
) -> None:
|
|
115
|
+
super().__init__(conn, pipe=pipe, serde=serde)
|
|
116
|
+
self.thread_id = _require_thread_id(thread_id)
|
|
117
|
+
self.epoch = epoch
|
|
118
|
+
self.fence_table = _require_fence_table(fence_table)
|
|
119
|
+
self.on_fence_lost = on_fence_lost
|
|
120
|
+
self._fence_lost_notified = False
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
async def acquire(
|
|
124
|
+
cls,
|
|
125
|
+
conn: _ainternal.Conn,
|
|
126
|
+
thread_id: str,
|
|
127
|
+
*,
|
|
128
|
+
create_fence_table: bool,
|
|
129
|
+
pipe: AsyncPipeline | None = None,
|
|
130
|
+
serde: SerializerProtocol | None = None,
|
|
131
|
+
fence_table: str = DEFAULT_FENCE_TABLE,
|
|
132
|
+
on_fence_lost: Callable[[], None] | None = None,
|
|
133
|
+
) -> "AsyncFencedPostgresSaver":
|
|
134
|
+
_require_thread_id(thread_id)
|
|
135
|
+
_require_fence_table(fence_table)
|
|
136
|
+
async with (
|
|
137
|
+
_ainternal.get_connection(conn) as c,
|
|
138
|
+
c.cursor(binary=True, row_factory=dict_row) as cur,
|
|
139
|
+
):
|
|
140
|
+
if create_fence_table:
|
|
141
|
+
await cur.execute(_create_sql(fence_table))
|
|
142
|
+
await cur.execute(_create_fn_sql(fence_table))
|
|
143
|
+
else:
|
|
144
|
+
await cur.execute(
|
|
145
|
+
"SELECT to_regclass(%s) AS tbl, to_regprocedure(%s) AS fn",
|
|
146
|
+
(fence_table, f"{_assert_fn(fence_table)}(text, bigint)"),
|
|
147
|
+
)
|
|
148
|
+
row = await cur.fetchone()
|
|
149
|
+
if row["tbl"] is None:
|
|
150
|
+
raise _missing_fence_error(f"fence table {fence_table!r}")
|
|
151
|
+
if row["fn"] is None:
|
|
152
|
+
raise _missing_fence_error(
|
|
153
|
+
f"fence function {_assert_fn(fence_table)!r}"
|
|
154
|
+
)
|
|
155
|
+
await cur.execute(_bump_sql(fence_table), (thread_id,))
|
|
156
|
+
epoch = (await cur.fetchone())["epoch"]
|
|
157
|
+
if pipe is not None:
|
|
158
|
+
await pipe.sync()
|
|
159
|
+
return cls(
|
|
160
|
+
conn,
|
|
161
|
+
thread_id,
|
|
162
|
+
epoch,
|
|
163
|
+
pipe=pipe,
|
|
164
|
+
serde=serde,
|
|
165
|
+
fence_table=fence_table,
|
|
166
|
+
on_fence_lost=on_fence_lost,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def _fence_lost(self) -> FenceLost:
|
|
170
|
+
if self.on_fence_lost is not None and not self._fence_lost_notified:
|
|
171
|
+
self._fence_lost_notified = True
|
|
172
|
+
self.on_fence_lost()
|
|
173
|
+
return FenceLost(self.thread_id, self.epoch)
|
|
174
|
+
|
|
175
|
+
def _bound_to(self, thread_id: Any) -> None:
|
|
176
|
+
if str(thread_id) != self.thread_id:
|
|
177
|
+
raise ValueError(
|
|
178
|
+
f"saver is fenced to thread {self.thread_id!r}, got {thread_id!r}"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
@asynccontextmanager
|
|
182
|
+
async def _cursor(
|
|
183
|
+
self, *, pipeline: bool = False
|
|
184
|
+
) -> AsyncIterator[AsyncCursor[DictRow]]:
|
|
185
|
+
if not pipeline:
|
|
186
|
+
async with super()._cursor(pipeline=False) as cur:
|
|
187
|
+
yield cur
|
|
188
|
+
return
|
|
189
|
+
try:
|
|
190
|
+
async with super()._cursor(pipeline=True) as cur:
|
|
191
|
+
await cur.execute(_assert_sql(self.fence_table), (self.thread_id, self.epoch))
|
|
192
|
+
yield cur
|
|
193
|
+
except errors.Error as exc:
|
|
194
|
+
if not _is_fence_lost(exc):
|
|
195
|
+
raise
|
|
196
|
+
raise self._fence_lost() from exc
|
|
197
|
+
|
|
198
|
+
async def aput(
|
|
199
|
+
self,
|
|
200
|
+
config: RunnableConfig,
|
|
201
|
+
checkpoint: Any,
|
|
202
|
+
metadata: Any,
|
|
203
|
+
new_versions: Any,
|
|
204
|
+
) -> RunnableConfig:
|
|
205
|
+
self._bound_to(config["configurable"]["thread_id"])
|
|
206
|
+
return await super().aput(config, checkpoint, metadata, new_versions)
|
|
207
|
+
|
|
208
|
+
async def aput_writes(
|
|
209
|
+
self,
|
|
210
|
+
config: RunnableConfig,
|
|
211
|
+
writes: Sequence[tuple[str, Any]],
|
|
212
|
+
task_id: str,
|
|
213
|
+
task_path: str = "",
|
|
214
|
+
) -> None:
|
|
215
|
+
self._bound_to(config["configurable"]["thread_id"])
|
|
216
|
+
await super().aput_writes(config, writes, task_id, task_path)
|
|
217
|
+
|
|
218
|
+
async def adelete_thread(self, thread_id: str) -> None:
|
|
219
|
+
self._bound_to(thread_id)
|
|
220
|
+
await super().adelete_thread(thread_id)
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
import socket
|
|
4
|
+
import subprocess
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
|
|
8
|
+
import psycopg
|
|
9
|
+
import pytest
|
|
10
|
+
from langgraph.checkpoint.base import empty_checkpoint
|
|
11
|
+
from psycopg.rows import dict_row
|
|
12
|
+
|
|
13
|
+
from harness_sdk.fenced_postgres import AsyncFencedPostgresSaver, FenceLost
|
|
14
|
+
|
|
15
|
+
THREAD = "thread-1"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _free_port():
|
|
19
|
+
with socket.socket() as s:
|
|
20
|
+
s.bind(("127.0.0.1", 0))
|
|
21
|
+
return s.getsockname()[1]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _wait_ready(dsn, timeout=120.0):
|
|
25
|
+
deadline = time.monotonic() + timeout
|
|
26
|
+
while time.monotonic() < deadline:
|
|
27
|
+
try:
|
|
28
|
+
psycopg.connect(dsn, connect_timeout=2).close()
|
|
29
|
+
return
|
|
30
|
+
except psycopg.OperationalError:
|
|
31
|
+
time.sleep(0.5)
|
|
32
|
+
raise RuntimeError(f"postgres at {dsn} did not become ready within {timeout}s")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.fixture(scope="session")
|
|
36
|
+
def postgres_dsn():
|
|
37
|
+
if dsn := os.environ.get("HARNESS_TEST_POSTGRES_DSN"):
|
|
38
|
+
yield dsn
|
|
39
|
+
return
|
|
40
|
+
docker = shutil.which("docker")
|
|
41
|
+
if docker is None or subprocess.run(
|
|
42
|
+
[docker, "info"], capture_output=True
|
|
43
|
+
).returncode:
|
|
44
|
+
pytest.skip(
|
|
45
|
+
"postgres unavailable: set HARNESS_TEST_POSTGRES_DSN or start docker"
|
|
46
|
+
)
|
|
47
|
+
port = _free_port()
|
|
48
|
+
container = subprocess.run(
|
|
49
|
+
[
|
|
50
|
+
docker, "run", "--rm", "-d",
|
|
51
|
+
"-e", "POSTGRES_PASSWORD=harness",
|
|
52
|
+
"-p", f"127.0.0.1:{port}:5432",
|
|
53
|
+
"postgres:16",
|
|
54
|
+
],
|
|
55
|
+
capture_output=True, text=True, check=True,
|
|
56
|
+
).stdout.strip()
|
|
57
|
+
dsn = f"postgresql://postgres:harness@127.0.0.1:{port}/postgres"
|
|
58
|
+
try:
|
|
59
|
+
_wait_ready(dsn)
|
|
60
|
+
yield dsn
|
|
61
|
+
finally:
|
|
62
|
+
subprocess.run([docker, "rm", "-f", container], capture_output=True)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@pytest.fixture
|
|
66
|
+
def dsn(postgres_dsn):
|
|
67
|
+
dbname = f"fence_test_{uuid.uuid4().hex[:12]}"
|
|
68
|
+
with psycopg.connect(postgres_dsn, autocommit=True) as admin:
|
|
69
|
+
admin.execute(f'CREATE DATABASE "{dbname}"')
|
|
70
|
+
yield postgres_dsn.rsplit("/", 1)[0] + f"/{dbname}"
|
|
71
|
+
with psycopg.connect(postgres_dsn, autocommit=True) as admin:
|
|
72
|
+
admin.execute(f'DROP DATABASE "{dbname}" WITH (FORCE)')
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@pytest.fixture
|
|
76
|
+
async def aconnect(dsn):
|
|
77
|
+
conns = []
|
|
78
|
+
|
|
79
|
+
async def _connect():
|
|
80
|
+
conn = await psycopg.AsyncConnection.connect(
|
|
81
|
+
dsn, autocommit=True, prepare_threshold=0, row_factory=dict_row
|
|
82
|
+
)
|
|
83
|
+
conns.append(conn)
|
|
84
|
+
return conn
|
|
85
|
+
|
|
86
|
+
yield _connect
|
|
87
|
+
for conn in conns:
|
|
88
|
+
await conn.close()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def make_config(thread_id=THREAD, checkpoint_id=None):
|
|
92
|
+
return {
|
|
93
|
+
"configurable": {
|
|
94
|
+
"thread_id": thread_id,
|
|
95
|
+
"checkpoint_ns": "",
|
|
96
|
+
"checkpoint_id": checkpoint_id or str(uuid.uuid4()),
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def thread_config(thread_id=THREAD):
|
|
102
|
+
return {"configurable": {"thread_id": thread_id}}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def aput_checkpoint(saver, thread_id=THREAD):
|
|
106
|
+
checkpoint = empty_checkpoint()
|
|
107
|
+
checkpoint["id"] = str(uuid.uuid4())
|
|
108
|
+
return await saver.aput(
|
|
109
|
+
make_config(thread_id), checkpoint, {"source": "loop", "step": 1}, {}
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def aacquire(aconnect, thread_id=THREAD, **kwargs):
|
|
114
|
+
saver = await AsyncFencedPostgresSaver.acquire(
|
|
115
|
+
await aconnect(), thread_id, create_fence_table=True, **kwargs
|
|
116
|
+
)
|
|
117
|
+
await saver.setup()
|
|
118
|
+
return saver
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class TestFencing:
|
|
122
|
+
async def test_acquire_bumps_epoch_monotonically(self, aconnect):
|
|
123
|
+
first = await aacquire(aconnect)
|
|
124
|
+
second = await aacquire(aconnect)
|
|
125
|
+
assert first.epoch == 1
|
|
126
|
+
assert second.epoch == 2
|
|
127
|
+
|
|
128
|
+
async def test_same_instance_writes_keep_working(self, aconnect):
|
|
129
|
+
saver = await aacquire(aconnect)
|
|
130
|
+
for _ in range(10):
|
|
131
|
+
config = await aput_checkpoint(saver)
|
|
132
|
+
await saver.aput_writes(config, [("channel", "value")], "task-1")
|
|
133
|
+
assert len([c async for c in saver.alist(thread_config())]) == 10
|
|
134
|
+
await saver.adelete_thread(THREAD)
|
|
135
|
+
assert [c async for c in saver.alist(thread_config())] == []
|
|
136
|
+
|
|
137
|
+
async def test_new_instance_fences_old_writes(self, aconnect):
|
|
138
|
+
old = await aacquire(aconnect)
|
|
139
|
+
config = await aput_checkpoint(old)
|
|
140
|
+
new = await aacquire(aconnect)
|
|
141
|
+
with pytest.raises(FenceLost):
|
|
142
|
+
await aput_checkpoint(old)
|
|
143
|
+
with pytest.raises(FenceLost):
|
|
144
|
+
await old.aput_writes(config, [("channel", "value")], "task-1")
|
|
145
|
+
with pytest.raises(FenceLost):
|
|
146
|
+
await old.adelete_thread(THREAD)
|
|
147
|
+
await aput_checkpoint(new)
|
|
148
|
+
|
|
149
|
+
async def test_fenced_write_leaves_no_trace(self, aconnect):
|
|
150
|
+
old = await aacquire(aconnect)
|
|
151
|
+
await aput_checkpoint(old)
|
|
152
|
+
await aacquire(aconnect)
|
|
153
|
+
with pytest.raises(FenceLost):
|
|
154
|
+
await aput_checkpoint(old)
|
|
155
|
+
assert len([c async for c in old.alist(thread_config())]) == 1
|
|
156
|
+
|
|
157
|
+
async def test_reacquire_kills_old_handle_of_same_worker(self, aconnect):
|
|
158
|
+
conn = await aconnect()
|
|
159
|
+
old = await AsyncFencedPostgresSaver.acquire(
|
|
160
|
+
conn, THREAD, create_fence_table=True
|
|
161
|
+
)
|
|
162
|
+
await old.setup()
|
|
163
|
+
reacquired = await AsyncFencedPostgresSaver.acquire(
|
|
164
|
+
conn, THREAD, create_fence_table=True
|
|
165
|
+
)
|
|
166
|
+
assert reacquired.epoch == old.epoch + 1
|
|
167
|
+
with pytest.raises(FenceLost):
|
|
168
|
+
await aput_checkpoint(old)
|
|
169
|
+
await aput_checkpoint(reacquired)
|
|
170
|
+
|
|
171
|
+
async def test_fences_are_per_thread(self, aconnect):
|
|
172
|
+
other = await aacquire(aconnect, "thread-2")
|
|
173
|
+
mine = await aacquire(aconnect)
|
|
174
|
+
await aacquire(aconnect, "thread-2")
|
|
175
|
+
await aput_checkpoint(mine)
|
|
176
|
+
with pytest.raises(FenceLost):
|
|
177
|
+
await aput_checkpoint(other, "thread-2")
|
|
178
|
+
|
|
179
|
+
async def test_rejects_writes_for_other_threads(self, aconnect):
|
|
180
|
+
saver = await aacquire(aconnect)
|
|
181
|
+
with pytest.raises(ValueError):
|
|
182
|
+
await aput_checkpoint(saver, "thread-2")
|
|
183
|
+
with pytest.raises(ValueError):
|
|
184
|
+
await saver.aput_writes(make_config("thread-2"), [("c", "v")], "task-1")
|
|
185
|
+
with pytest.raises(ValueError):
|
|
186
|
+
await saver.adelete_thread("thread-2")
|
|
187
|
+
|
|
188
|
+
async def test_acquire_rejects_invalid_thread_id(self, aconnect):
|
|
189
|
+
with pytest.raises(ValueError):
|
|
190
|
+
await AsyncFencedPostgresSaver.acquire(
|
|
191
|
+
await aconnect(), "", create_fence_table=True
|
|
192
|
+
)
|
|
193
|
+
with pytest.raises(ValueError):
|
|
194
|
+
await AsyncFencedPostgresSaver.acquire(
|
|
195
|
+
await aconnect(), None, create_fence_table=True
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class TestFenceOptions:
|
|
200
|
+
async def test_custom_fence_table(self, aconnect):
|
|
201
|
+
old = await aacquire(aconnect, fence_table="my_fence")
|
|
202
|
+
new = await aacquire(aconnect, fence_table="my_fence")
|
|
203
|
+
assert new.epoch == old.epoch + 1
|
|
204
|
+
with pytest.raises(FenceLost):
|
|
205
|
+
await aput_checkpoint(old)
|
|
206
|
+
await aput_checkpoint(new)
|
|
207
|
+
|
|
208
|
+
async def test_acquire_without_create_requires_existing_table(self, aconnect):
|
|
209
|
+
with pytest.raises(RuntimeError, match="does not exist"):
|
|
210
|
+
await AsyncFencedPostgresSaver.acquire(
|
|
211
|
+
await aconnect(), THREAD, create_fence_table=False
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
async def test_acquire_without_create_requires_existing_function(self, aconnect):
|
|
215
|
+
await aacquire(aconnect)
|
|
216
|
+
conn = await aconnect()
|
|
217
|
+
await conn.execute("DROP FUNCTION checkpoint_fence_assert(text, bigint)")
|
|
218
|
+
with pytest.raises(RuntimeError, match="fence function"):
|
|
219
|
+
await AsyncFencedPostgresSaver.acquire(
|
|
220
|
+
conn, THREAD, create_fence_table=False
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
async def test_acquire_without_create_uses_existing_table(self, aconnect):
|
|
224
|
+
await aacquire(aconnect)
|
|
225
|
+
saver = await AsyncFencedPostgresSaver.acquire(
|
|
226
|
+
await aconnect(), THREAD, create_fence_table=False
|
|
227
|
+
)
|
|
228
|
+
assert saver.epoch == 2
|
|
229
|
+
|
|
230
|
+
async def test_acquire_rejects_invalid_fence_table(self, aconnect):
|
|
231
|
+
with pytest.raises(ValueError):
|
|
232
|
+
await AsyncFencedPostgresSaver.acquire(
|
|
233
|
+
await aconnect(), THREAD, create_fence_table=True, fence_table="bad table;"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class TestOnFenceLost:
|
|
238
|
+
async def test_callback_fires_once_across_write_attempts(self, aconnect):
|
|
239
|
+
calls = []
|
|
240
|
+
old = await aacquire(aconnect, on_fence_lost=lambda: calls.append("lost"))
|
|
241
|
+
await aacquire(aconnect)
|
|
242
|
+
for _ in range(3):
|
|
243
|
+
with pytest.raises(FenceLost):
|
|
244
|
+
await aput_checkpoint(old)
|
|
245
|
+
with pytest.raises(FenceLost):
|
|
246
|
+
await old.adelete_thread(THREAD)
|
|
247
|
+
assert calls == ["lost"]
|
|
248
|
+
|
|
249
|
+
async def test_no_callback_still_raises(self, aconnect):
|
|
250
|
+
old = await aacquire(aconnect)
|
|
251
|
+
await aacquire(aconnect)
|
|
252
|
+
with pytest.raises(FenceLost) as excinfo:
|
|
253
|
+
await aput_checkpoint(old)
|
|
254
|
+
assert excinfo.value.thread_id == THREAD
|
|
255
|
+
assert excinfo.value.epoch == 1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|