prefect 3.7.3.dev7__py3-none-any.whl → 3.7.4.dev2__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.
- prefect/_build_info.py +3 -3
- prefect/cli/deploy/_commands.py +1 -1
- prefect/cli/work_pool.py +9 -3
- prefect/client/orchestration/_automations/client.py +38 -4
- prefect/events/filters.py +22 -0
- prefect/runner/_workspace_starter.py +76 -4
- prefect/runner/runner.py +1 -1
- prefect/settings/AGENTS.md +2 -0
- prefect/settings/models/server/worker_channel.py +4 -4
- prefect/testing/standard_test_suites/__init__.py +1 -0
- prefect/testing/standard_test_suites/worker_cleanup_queue.py +622 -0
- prefect/utilities/pydantic.py +2 -1
- {prefect-3.7.3.dev7.dist-info → prefect-3.7.4.dev2.dist-info}/METADATA +1 -1
- {prefect-3.7.3.dev7.dist-info → prefect-3.7.4.dev2.dist-info}/RECORD +17 -16
- {prefect-3.7.3.dev7.dist-info → prefect-3.7.4.dev2.dist-info}/WHEEL +1 -1
- {prefect-3.7.3.dev7.dist-info → prefect-3.7.4.dev2.dist-info}/entry_points.txt +0 -0
- {prefect-3.7.3.dev7.dist-info → prefect-3.7.4.dev2.dist-info}/licenses/LICENSE +0 -0
prefect/_build_info.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Generated by versioningit
|
|
2
|
-
__version__ = "3.7.
|
|
3
|
-
__build_date__ = "2026-06-
|
|
4
|
-
__git_commit__ = "
|
|
2
|
+
__version__ = "3.7.4.dev2"
|
|
3
|
+
__build_date__ = "2026-06-03 09:46:04.741454+00:00"
|
|
4
|
+
__git_commit__ = "24ebdd47271b0f77b3704903bfadb3374e4a487f"
|
|
5
5
|
__dirty__ = False
|
prefect/cli/deploy/_commands.py
CHANGED
|
@@ -64,7 +64,7 @@ async def init(
|
|
|
64
64
|
recipe_paths = prefect.__module_path__ / "deployments" / "recipes"
|
|
65
65
|
|
|
66
66
|
for field in fields:
|
|
67
|
-
key, value = field.split("=")
|
|
67
|
+
key, value = field.split("=", 1)
|
|
68
68
|
inputs[key] = value
|
|
69
69
|
|
|
70
70
|
from prefect.cli._prompts import prompt_select_from_table
|
prefect/cli/work_pool.py
CHANGED
|
@@ -1379,7 +1379,9 @@ async def storage_configure_s3(
|
|
|
1379
1379
|
" --aws-credentials-block-name to use default credentials."
|
|
1380
1380
|
)
|
|
1381
1381
|
|
|
1382
|
-
result_storage_block_document_name =
|
|
1382
|
+
result_storage_block_document_name = (
|
|
1383
|
+
f"default-{work_pool_name.lower()}-result-storage"
|
|
1384
|
+
)
|
|
1383
1385
|
# Always set `credentials` explicitly (a $ref for a named block, or
|
|
1384
1386
|
# an empty dict for ambient auth). Setting it on every run clears any
|
|
1385
1387
|
# stale credential reference from a prior --aws-credentials-block-name
|
|
@@ -1518,7 +1520,9 @@ async def storage_configure_gcs(
|
|
|
1518
1520
|
" --gcp-credentials-block-name to use default credentials."
|
|
1519
1521
|
)
|
|
1520
1522
|
|
|
1521
|
-
result_storage_block_document_name =
|
|
1523
|
+
result_storage_block_document_name = (
|
|
1524
|
+
f"default-{work_pool_name.lower()}-result-storage"
|
|
1525
|
+
)
|
|
1522
1526
|
# Always set `gcp_credentials` explicitly (a $ref for a named block,
|
|
1523
1527
|
# or an empty dict for ambient auth). Setting it on every run clears
|
|
1524
1528
|
# any stale credential reference from a prior
|
|
@@ -1652,7 +1656,9 @@ async def storage_configure_azure_blob_storage(
|
|
|
1652
1656
|
" `prefect block create azure-blob-storage-credentials`."
|
|
1653
1657
|
)
|
|
1654
1658
|
|
|
1655
|
-
result_storage_block_document_name =
|
|
1659
|
+
result_storage_block_document_name = (
|
|
1660
|
+
f"default-{work_pool_name.lower()}-result-storage"
|
|
1661
|
+
)
|
|
1656
1662
|
block_data = {
|
|
1657
1663
|
"container_name": container,
|
|
1658
1664
|
"credentials": {
|
|
@@ -10,6 +10,8 @@ from prefect.exceptions import ObjectNotFound
|
|
|
10
10
|
if TYPE_CHECKING:
|
|
11
11
|
from uuid import UUID
|
|
12
12
|
|
|
13
|
+
from prefect.client.schemas.sorting import AutomationSort
|
|
14
|
+
from prefect.events.filters import AutomationFilter
|
|
13
15
|
from prefect.events.schemas.automations import Automation, AutomationCore
|
|
14
16
|
|
|
15
17
|
|
|
@@ -37,8 +39,24 @@ class AutomationClient(BaseClient):
|
|
|
37
39
|
)
|
|
38
40
|
response.raise_for_status()
|
|
39
41
|
|
|
40
|
-
def read_automations(
|
|
41
|
-
|
|
42
|
+
def read_automations(
|
|
43
|
+
self,
|
|
44
|
+
*,
|
|
45
|
+
automations: "AutomationFilter | None" = None,
|
|
46
|
+
sort: "AutomationSort | None" = None,
|
|
47
|
+
limit: int | None = None,
|
|
48
|
+
offset: int = 0,
|
|
49
|
+
) -> list["Automation"]:
|
|
50
|
+
body: dict[str, object] = {
|
|
51
|
+
"automations": (
|
|
52
|
+
automations.model_dump(mode="json") if automations else None
|
|
53
|
+
),
|
|
54
|
+
"sort": sort,
|
|
55
|
+
"limit": limit,
|
|
56
|
+
"offset": offset,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
response = self.request("POST", "/automations/filter", json=body)
|
|
42
60
|
response.raise_for_status()
|
|
43
61
|
from prefect.events.schemas.automations import Automation
|
|
44
62
|
|
|
@@ -196,8 +214,24 @@ class AutomationAsyncClient(BaseAsyncClient):
|
|
|
196
214
|
)
|
|
197
215
|
response.raise_for_status()
|
|
198
216
|
|
|
199
|
-
async def read_automations(
|
|
200
|
-
|
|
217
|
+
async def read_automations(
|
|
218
|
+
self,
|
|
219
|
+
*,
|
|
220
|
+
automations: "AutomationFilter | None" = None,
|
|
221
|
+
sort: "AutomationSort | None" = None,
|
|
222
|
+
limit: int | None = None,
|
|
223
|
+
offset: int = 0,
|
|
224
|
+
) -> list["Automation"]:
|
|
225
|
+
body: dict[str, object] = {
|
|
226
|
+
"automations": (
|
|
227
|
+
automations.model_dump(mode="json") if automations else None
|
|
228
|
+
),
|
|
229
|
+
"sort": sort,
|
|
230
|
+
"limit": limit,
|
|
231
|
+
"offset": offset,
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
response = await self.request("POST", "/automations/filter", json=body)
|
|
201
235
|
response.raise_for_status()
|
|
202
236
|
from prefect.events.schemas.automations import Automation
|
|
203
237
|
|
prefect/events/filters.py
CHANGED
|
@@ -32,6 +32,25 @@ class AutomationFilterName(PrefectBaseModel):
|
|
|
32
32
|
)
|
|
33
33
|
|
|
34
34
|
|
|
35
|
+
class AutomationFilterTags(PrefectBaseModel):
|
|
36
|
+
"""Filter by `Automation.tags`."""
|
|
37
|
+
|
|
38
|
+
all_: Optional[list[str]] = Field(
|
|
39
|
+
default=None,
|
|
40
|
+
examples=[["tag-1", "tag-2"]],
|
|
41
|
+
description="A list of tags. Automations will be returned only if their tags are a superset of the list",
|
|
42
|
+
)
|
|
43
|
+
any_: Optional[list[str]] = Field(
|
|
44
|
+
default=None,
|
|
45
|
+
examples=[["tag-1", "tag-2"]],
|
|
46
|
+
description="A list of tags. Automations will be returned if their tags contain any of the tags in the list",
|
|
47
|
+
)
|
|
48
|
+
is_null_: Optional[bool] = Field(
|
|
49
|
+
default=None,
|
|
50
|
+
description="If true, only include automations without tags",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
35
54
|
class AutomationFilter(PrefectBaseModel):
|
|
36
55
|
name: Optional[AutomationFilterName] = Field(
|
|
37
56
|
default=None, description="Filter criteria for `Automation.name`"
|
|
@@ -39,6 +58,9 @@ class AutomationFilter(PrefectBaseModel):
|
|
|
39
58
|
created: Optional[AutomationFilterCreated] = Field(
|
|
40
59
|
default=None, description="Filter criteria for `Automation.created`"
|
|
41
60
|
)
|
|
61
|
+
tags: Optional[AutomationFilterTags] = Field(
|
|
62
|
+
default=None, description="Filter criteria for `Automation.tags`"
|
|
63
|
+
)
|
|
42
64
|
|
|
43
65
|
|
|
44
66
|
class EventDataFilter(PrefectBaseModel, extra="forbid"): # type: ignore[call-arg]
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import functools
|
|
3
4
|
import os
|
|
4
5
|
import shutil
|
|
6
|
+
import site
|
|
5
7
|
import subprocess
|
|
6
8
|
import sys
|
|
9
|
+
import sysconfig
|
|
7
10
|
from contextlib import contextmanager
|
|
8
11
|
from pathlib import Path
|
|
9
12
|
from typing import TYPE_CHECKING, Any, Iterable, Iterator
|
|
@@ -109,14 +112,83 @@ def _workspace_sys_path(workspace: PreparedWorkspace) -> list[str]:
|
|
|
109
112
|
return entries
|
|
110
113
|
|
|
111
114
|
|
|
115
|
+
@functools.lru_cache(maxsize=1)
|
|
116
|
+
def _stdlib_prefixes() -> tuple[str, ...]:
|
|
117
|
+
"""Resolved stdlib directory prefixes whose children should not land on PYTHONPATH."""
|
|
118
|
+
roots: set[str] = set()
|
|
119
|
+
paths = sysconfig.get_paths()
|
|
120
|
+
for key in ("stdlib", "platstdlib"):
|
|
121
|
+
val = paths.get(key)
|
|
122
|
+
if val:
|
|
123
|
+
roots.add(str(Path(val).resolve()))
|
|
124
|
+
return tuple(sorted(roots))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@functools.lru_cache(maxsize=1)
|
|
128
|
+
def _site_packages_dirs() -> tuple[str, ...]:
|
|
129
|
+
"""Resolved site-packages directories that should always be kept."""
|
|
130
|
+
dirs: set[str] = set()
|
|
131
|
+
paths = sysconfig.get_paths()
|
|
132
|
+
for key in ("purelib", "platlib"):
|
|
133
|
+
val = paths.get(key)
|
|
134
|
+
if val:
|
|
135
|
+
dirs.add(str(Path(val).resolve()))
|
|
136
|
+
try:
|
|
137
|
+
for sp in site.getsitepackages():
|
|
138
|
+
dirs.add(str(Path(sp).resolve()))
|
|
139
|
+
except AttributeError:
|
|
140
|
+
pass
|
|
141
|
+
try:
|
|
142
|
+
usp = site.getusersitepackages()
|
|
143
|
+
if isinstance(usp, str):
|
|
144
|
+
dirs.add(str(Path(usp).resolve()))
|
|
145
|
+
except AttributeError:
|
|
146
|
+
pass
|
|
147
|
+
return tuple(sorted(dirs))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _is_stdlib_path(entry: str) -> bool:
|
|
151
|
+
"""True when *entry* is a stdlib, lib-dynload, or stdlib zip path.
|
|
152
|
+
|
|
153
|
+
Site-packages directories that live under the stdlib tree are kept.
|
|
154
|
+
Only interpreter stdlib zip archives next to stdlib directories are filtered;
|
|
155
|
+
user archives like `/app/python_deps.zip` are preserved.
|
|
156
|
+
"""
|
|
157
|
+
if not entry:
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
resolved_path = Path(entry).resolve()
|
|
161
|
+
resolved = str(resolved_path)
|
|
162
|
+
|
|
163
|
+
for sp in _site_packages_dirs():
|
|
164
|
+
if resolved == sp or resolved.startswith(sp + os.sep):
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
for root in _stdlib_prefixes():
|
|
168
|
+
if resolved == root or resolved.startswith(root + os.sep):
|
|
169
|
+
return True
|
|
170
|
+
|
|
171
|
+
interpreter_zip_name = f"python{sys.version_info.major}{sys.version_info.minor}.zip"
|
|
172
|
+
if resolved_path.name == interpreter_zip_name:
|
|
173
|
+
resolved_parent = str(resolved_path.parent)
|
|
174
|
+
stdlib_parents = {str(Path(r).parent) for r in _stdlib_prefixes()}
|
|
175
|
+
if resolved_parent in stdlib_parents:
|
|
176
|
+
return True
|
|
177
|
+
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
|
|
112
181
|
def workspace_environment(workspace: PreparedWorkspace) -> dict[str, str]:
|
|
113
182
|
environment = dict(workspace.environment)
|
|
114
|
-
pythonpath_entries =
|
|
183
|
+
pythonpath_entries: list[str] = []
|
|
184
|
+
candidate_entries = _workspace_sys_path(workspace)
|
|
115
185
|
existing_pythonpath = environment.get("PYTHONPATH")
|
|
116
186
|
if existing_pythonpath:
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
187
|
+
candidate_entries.extend(existing_pythonpath.split(os.pathsep))
|
|
188
|
+
|
|
189
|
+
for entry in candidate_entries:
|
|
190
|
+
if entry and entry not in pythonpath_entries and not _is_stdlib_path(entry):
|
|
191
|
+
pythonpath_entries.append(entry)
|
|
120
192
|
|
|
121
193
|
environment["PYTHONPATH"] = os.pathsep.join(pythonpath_entries)
|
|
122
194
|
return environment
|
prefect/runner/runner.py
CHANGED
|
@@ -1701,7 +1701,7 @@ class Runner:
|
|
|
1701
1701
|
):
|
|
1702
1702
|
return self._deployment_registry.get_flow(flow_run.deployment_id)
|
|
1703
1703
|
return await load_flow_from_flow_run(
|
|
1704
|
-
|
|
1704
|
+
flow_run, storage_base_path=str(self._tmp_dir)
|
|
1705
1705
|
)
|
|
1706
1706
|
|
|
1707
1707
|
self._hook_runner = HookRunner(resolve_flow=_resolve_flow_for_hooks)
|
prefect/settings/AGENTS.md
CHANGED
|
@@ -118,3 +118,5 @@ from prefect.settings.context import get_current_settings
|
|
|
118
118
|
settings = get_current_settings()
|
|
119
119
|
value = settings.server.services.my_service.my_setting
|
|
120
120
|
```
|
|
121
|
+
|
|
122
|
+
`temporary_settings()` accepts both legacy `Setting` objects and dotted-path string keys (e.g., `"server.api.host"`) or uppercase env var names (e.g., `"PREFECT_SERVER_API_HOST"`). Prefer string keys in new tests to avoid importing legacy `Setting` objects.
|
|
@@ -20,8 +20,8 @@ class ServerWorkerChannelSettings(PrefectBaseSettings):
|
|
|
20
20
|
description=(
|
|
21
21
|
"The module to use for storing worker cleanup delivery messages. "
|
|
22
22
|
"The default in-memory backend stores messages, leases, wakeups, "
|
|
23
|
-
"and dead-letter entries only in the current server process; use
|
|
24
|
-
"
|
|
23
|
+
"and dead-letter entries only in the current server process; use "
|
|
24
|
+
"an external storage module for high availability or restart-safe "
|
|
25
25
|
"cleanup delivery."
|
|
26
26
|
),
|
|
27
27
|
)
|
|
@@ -47,8 +47,8 @@ class ServerWorkerChannelSettings(PrefectBaseSettings):
|
|
|
47
47
|
description=(
|
|
48
48
|
"How long completed cleanup idempotency records are retained after "
|
|
49
49
|
"acknowledgement. None keeps them for the lifetime of the current "
|
|
50
|
-
"server process. The in-memory backend does not survive restart; "
|
|
51
|
-
"
|
|
50
|
+
"server process. The in-memory backend does not survive restart; use "
|
|
51
|
+
"external storage for high availability or restart-safe cleanup "
|
|
52
52
|
"idempotency."
|
|
53
53
|
),
|
|
54
54
|
)
|
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Callable, Iterator
|
|
5
|
+
from contextlib import AbstractContextManager, contextmanager
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from typing import Any, Literal, Protocol
|
|
8
|
+
from uuid import UUID, uuid4
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from prefect.client.schemas.worker_channel import (
|
|
13
|
+
CANCELLING_TIMEOUT_TEARDOWN,
|
|
14
|
+
PENDING_CLAIM_TEARDOWN,
|
|
15
|
+
)
|
|
16
|
+
from prefect.server.worker_communication.cleanup_queue import WorkerCleanupQueue
|
|
17
|
+
from prefect.settings import (
|
|
18
|
+
PREFECT_SERVER_WORKER_CHANNEL_CLEANUP_COMPLETED_IDEMPOTENCY_RETENTION_SECONDS,
|
|
19
|
+
PREFECT_SERVER_WORKER_CHANNEL_CLEANUP_LEASE_SECONDS,
|
|
20
|
+
PREFECT_SERVER_WORKER_CHANNEL_CLEANUP_MAX_DELIVERY_ATTEMPTS,
|
|
21
|
+
temporary_settings,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
CleanupPolicySettings = Callable[..., AbstractContextManager[None]]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class QueueTestClock(Protocol):
|
|
28
|
+
current: datetime
|
|
29
|
+
|
|
30
|
+
def advance(self, duration: timedelta) -> None: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _target() -> dict[str, str]:
|
|
34
|
+
return {"flow_run_id": str(uuid4())}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def _enqueue_message(
|
|
38
|
+
queue: WorkerCleanupQueue,
|
|
39
|
+
*,
|
|
40
|
+
work_pool_id: UUID | None = None,
|
|
41
|
+
message_id: UUID | None = None,
|
|
42
|
+
idempotency_key: str = "cleanup-key",
|
|
43
|
+
work_queue_id: UUID | None = None,
|
|
44
|
+
) -> UUID:
|
|
45
|
+
message_id = message_id or uuid4()
|
|
46
|
+
await queue.enqueue(
|
|
47
|
+
message_id=message_id,
|
|
48
|
+
idempotency_key=idempotency_key,
|
|
49
|
+
work_pool_id=work_pool_id or uuid4(),
|
|
50
|
+
kind=CANCELLING_TIMEOUT_TEARDOWN,
|
|
51
|
+
target=_target(),
|
|
52
|
+
work_queue_id=work_queue_id,
|
|
53
|
+
)
|
|
54
|
+
return message_id
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class WorkerCleanupQueueStandardTestSuite:
|
|
58
|
+
@pytest.fixture
|
|
59
|
+
def cleanup_policy_settings(self) -> CleanupPolicySettings:
|
|
60
|
+
@contextmanager
|
|
61
|
+
def settings(
|
|
62
|
+
*,
|
|
63
|
+
lease_seconds: float | None = None,
|
|
64
|
+
max_delivery_attempts: int | None = None,
|
|
65
|
+
completed_idempotency_retention_seconds: float | None = None,
|
|
66
|
+
) -> Iterator[None]:
|
|
67
|
+
values: dict[Any, Any] = {}
|
|
68
|
+
if lease_seconds is not None:
|
|
69
|
+
values[PREFECT_SERVER_WORKER_CHANNEL_CLEANUP_LEASE_SECONDS] = (
|
|
70
|
+
lease_seconds
|
|
71
|
+
)
|
|
72
|
+
if max_delivery_attempts is not None:
|
|
73
|
+
values[PREFECT_SERVER_WORKER_CHANNEL_CLEANUP_MAX_DELIVERY_ATTEMPTS] = (
|
|
74
|
+
max_delivery_attempts
|
|
75
|
+
)
|
|
76
|
+
if completed_idempotency_retention_seconds is not None:
|
|
77
|
+
values[
|
|
78
|
+
PREFECT_SERVER_WORKER_CHANNEL_CLEANUP_COMPLETED_IDEMPOTENCY_RETENTION_SECONDS
|
|
79
|
+
] = completed_idempotency_retention_seconds
|
|
80
|
+
|
|
81
|
+
with temporary_settings(values):
|
|
82
|
+
yield
|
|
83
|
+
|
|
84
|
+
return settings
|
|
85
|
+
|
|
86
|
+
async def test_enqueue_is_idempotent_for_stable_cleanup_keys(
|
|
87
|
+
self,
|
|
88
|
+
queue: WorkerCleanupQueue,
|
|
89
|
+
) -> None:
|
|
90
|
+
work_pool_id = uuid4()
|
|
91
|
+
first_message_id = uuid4()
|
|
92
|
+
second_message_id = uuid4()
|
|
93
|
+
|
|
94
|
+
first = await queue.enqueue(
|
|
95
|
+
message_id=first_message_id,
|
|
96
|
+
idempotency_key="flow-run-cleanup",
|
|
97
|
+
work_pool_id=work_pool_id,
|
|
98
|
+
kind=CANCELLING_TIMEOUT_TEARDOWN,
|
|
99
|
+
target=_target(),
|
|
100
|
+
)
|
|
101
|
+
second = await queue.enqueue(
|
|
102
|
+
message_id=second_message_id,
|
|
103
|
+
idempotency_key="flow-run-cleanup",
|
|
104
|
+
work_pool_id=work_pool_id,
|
|
105
|
+
kind=CANCELLING_TIMEOUT_TEARDOWN,
|
|
106
|
+
target=_target(),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
assert second.message_id == first.message_id
|
|
110
|
+
assert second.message_id != second_message_id
|
|
111
|
+
|
|
112
|
+
async def test_enqueue_deep_copies_payload_state(
|
|
113
|
+
self,
|
|
114
|
+
queue: WorkerCleanupQueue,
|
|
115
|
+
) -> None:
|
|
116
|
+
work_pool_id = uuid4()
|
|
117
|
+
message_id = uuid4()
|
|
118
|
+
target = {"flow_run": {"id": str(uuid4())}}
|
|
119
|
+
data = {"resources": [{"id": str(uuid4())}]}
|
|
120
|
+
original_target = {"flow_run": dict(target["flow_run"])}
|
|
121
|
+
original_data = {"resources": [dict(data["resources"][0])]}
|
|
122
|
+
|
|
123
|
+
await queue.enqueue(
|
|
124
|
+
message_id=message_id,
|
|
125
|
+
idempotency_key="flow-run-cleanup",
|
|
126
|
+
work_pool_id=work_pool_id,
|
|
127
|
+
kind=CANCELLING_TIMEOUT_TEARDOWN,
|
|
128
|
+
target=target,
|
|
129
|
+
data=data,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
target["flow_run"]["id"] = str(uuid4())
|
|
133
|
+
data["resources"][0]["id"] = str(uuid4())
|
|
134
|
+
|
|
135
|
+
message = await queue.read_message(
|
|
136
|
+
work_pool_id=work_pool_id, message_id=message_id
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
assert message is not None
|
|
140
|
+
assert message.target == original_target
|
|
141
|
+
assert message.data == original_data
|
|
142
|
+
|
|
143
|
+
async def test_reserve_commits_delivery_count_and_single_active_reservation(
|
|
144
|
+
self,
|
|
145
|
+
queue: WorkerCleanupQueue,
|
|
146
|
+
clock: QueueTestClock,
|
|
147
|
+
) -> None:
|
|
148
|
+
work_pool_id = uuid4()
|
|
149
|
+
message_id = await _enqueue_message(queue, work_pool_id=work_pool_id)
|
|
150
|
+
|
|
151
|
+
reservation = await queue.reserve(work_pool_id=work_pool_id)
|
|
152
|
+
|
|
153
|
+
assert reservation is not None
|
|
154
|
+
assert reservation.message_id == message_id
|
|
155
|
+
assert reservation.delivery_count == 1
|
|
156
|
+
assert reservation.lease_expires_at == clock.current + timedelta(seconds=30)
|
|
157
|
+
assert len(reservation.reservation_token) > 32
|
|
158
|
+
|
|
159
|
+
assert await queue.reserve(work_pool_id=work_pool_id) is None
|
|
160
|
+
|
|
161
|
+
async def test_reservation_operations_require_current_token(
|
|
162
|
+
self,
|
|
163
|
+
queue: WorkerCleanupQueue,
|
|
164
|
+
) -> None:
|
|
165
|
+
work_pool_id = uuid4()
|
|
166
|
+
message_id = await _enqueue_message(queue, work_pool_id=work_pool_id)
|
|
167
|
+
reservation = await queue.reserve(work_pool_id=work_pool_id)
|
|
168
|
+
assert reservation is not None
|
|
169
|
+
|
|
170
|
+
rejected = await queue.ack(
|
|
171
|
+
work_pool_id=work_pool_id,
|
|
172
|
+
message_id=message_id,
|
|
173
|
+
reservation_token="not-the-current-token",
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
assert rejected.status == "invalid_token"
|
|
177
|
+
assert (
|
|
178
|
+
await queue.read_message(work_pool_id=work_pool_id, message_id=message_id)
|
|
179
|
+
is not None
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
accepted = await queue.ack(
|
|
183
|
+
work_pool_id=work_pool_id,
|
|
184
|
+
message_id=message_id,
|
|
185
|
+
reservation_token=reservation.reservation_token,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
assert accepted.status == "accepted"
|
|
189
|
+
assert (
|
|
190
|
+
await queue.read_message(work_pool_id=work_pool_id, message_id=message_id)
|
|
191
|
+
is None
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
@pytest.mark.parametrize("operation", ["ack", "release", "renew"])
|
|
195
|
+
async def test_reservation_operations_require_matching_work_pool_scope(
|
|
196
|
+
self,
|
|
197
|
+
queue: WorkerCleanupQueue,
|
|
198
|
+
operation: Literal["ack", "release", "renew"],
|
|
199
|
+
) -> None:
|
|
200
|
+
work_pool_id = uuid4()
|
|
201
|
+
message_id = await _enqueue_message(queue, work_pool_id=work_pool_id)
|
|
202
|
+
reservation = await queue.reserve(work_pool_id=work_pool_id)
|
|
203
|
+
assert reservation is not None
|
|
204
|
+
|
|
205
|
+
other_work_pool_id = uuid4()
|
|
206
|
+
if operation == "ack":
|
|
207
|
+
rejected = await queue.ack(
|
|
208
|
+
work_pool_id=other_work_pool_id,
|
|
209
|
+
message_id=message_id,
|
|
210
|
+
reservation_token=reservation.reservation_token,
|
|
211
|
+
)
|
|
212
|
+
elif operation == "release":
|
|
213
|
+
rejected = await queue.release(
|
|
214
|
+
work_pool_id=other_work_pool_id,
|
|
215
|
+
message_id=message_id,
|
|
216
|
+
reservation_token=reservation.reservation_token,
|
|
217
|
+
reason="wrong_pool",
|
|
218
|
+
)
|
|
219
|
+
else:
|
|
220
|
+
rejected = await queue.renew(
|
|
221
|
+
work_pool_id=other_work_pool_id,
|
|
222
|
+
message_id=message_id,
|
|
223
|
+
reservation_token=reservation.reservation_token,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
assert rejected.status in {"unauthorized", "not_found"}
|
|
227
|
+
assert rejected.reason in {"work_pool_mismatch", "message_not_found"}
|
|
228
|
+
assert (
|
|
229
|
+
await queue.read_message(work_pool_id=work_pool_id, message_id=message_id)
|
|
230
|
+
is not None
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
accepted = await queue.ack(
|
|
234
|
+
work_pool_id=work_pool_id,
|
|
235
|
+
message_id=message_id,
|
|
236
|
+
reservation_token=reservation.reservation_token,
|
|
237
|
+
)
|
|
238
|
+
assert accepted.status == "accepted"
|
|
239
|
+
|
|
240
|
+
async def test_read_helpers_require_matching_work_pool_scope(
|
|
241
|
+
self,
|
|
242
|
+
queue: WorkerCleanupQueue,
|
|
243
|
+
cleanup_policy_settings: CleanupPolicySettings,
|
|
244
|
+
) -> None:
|
|
245
|
+
work_pool_id = uuid4()
|
|
246
|
+
other_work_pool_id = uuid4()
|
|
247
|
+
message_id = await _enqueue_message(queue, work_pool_id=work_pool_id)
|
|
248
|
+
|
|
249
|
+
message = await queue.read_message(
|
|
250
|
+
work_pool_id=work_pool_id, message_id=message_id
|
|
251
|
+
)
|
|
252
|
+
wrong_pool_message = await queue.read_message(
|
|
253
|
+
work_pool_id=other_work_pool_id, message_id=message_id
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
assert message is not None
|
|
257
|
+
assert wrong_pool_message is None
|
|
258
|
+
|
|
259
|
+
with cleanup_policy_settings(max_delivery_attempts=1):
|
|
260
|
+
reservation = await queue.reserve(work_pool_id=work_pool_id)
|
|
261
|
+
assert reservation is not None
|
|
262
|
+
result = await queue.release(
|
|
263
|
+
work_pool_id=work_pool_id,
|
|
264
|
+
message_id=message_id,
|
|
265
|
+
reservation_token=reservation.reservation_token,
|
|
266
|
+
reason="unsupported_cleanup_kind",
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
dead_letter = await queue.read_dead_letter(
|
|
270
|
+
work_pool_id=work_pool_id, message_id=message_id
|
|
271
|
+
)
|
|
272
|
+
wrong_pool_dead_letter = await queue.read_dead_letter(
|
|
273
|
+
work_pool_id=other_work_pool_id, message_id=message_id
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
assert result.status == "dead_lettered"
|
|
277
|
+
assert dead_letter is not None
|
|
278
|
+
assert wrong_pool_dead_letter is None
|
|
279
|
+
|
|
280
|
+
async def test_reserve_prefers_matching_work_queue_but_falls_back_to_pool(
|
|
281
|
+
self,
|
|
282
|
+
queue: WorkerCleanupQueue,
|
|
283
|
+
) -> None:
|
|
284
|
+
work_pool_id = uuid4()
|
|
285
|
+
preferred_work_queue_id = uuid4()
|
|
286
|
+
fallback_work_queue_id = uuid4()
|
|
287
|
+
fallback_message_id = await _enqueue_message(
|
|
288
|
+
queue,
|
|
289
|
+
work_pool_id=work_pool_id,
|
|
290
|
+
idempotency_key="fallback-cleanup",
|
|
291
|
+
work_queue_id=fallback_work_queue_id,
|
|
292
|
+
)
|
|
293
|
+
preferred_message_id = await _enqueue_message(
|
|
294
|
+
queue,
|
|
295
|
+
work_pool_id=work_pool_id,
|
|
296
|
+
idempotency_key="preferred-cleanup",
|
|
297
|
+
work_queue_id=preferred_work_queue_id,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
preferred = await queue.reserve(
|
|
301
|
+
work_pool_id=work_pool_id,
|
|
302
|
+
preferred_work_queue_ids=[preferred_work_queue_id],
|
|
303
|
+
)
|
|
304
|
+
assert preferred is not None
|
|
305
|
+
assert preferred.message_id == preferred_message_id
|
|
306
|
+
|
|
307
|
+
accepted = await queue.ack(
|
|
308
|
+
work_pool_id=work_pool_id,
|
|
309
|
+
message_id=preferred_message_id,
|
|
310
|
+
reservation_token=preferred.reservation_token,
|
|
311
|
+
)
|
|
312
|
+
assert accepted.status == "accepted"
|
|
313
|
+
|
|
314
|
+
fallback = await queue.reserve(
|
|
315
|
+
work_pool_id=work_pool_id,
|
|
316
|
+
preferred_work_queue_ids=[preferred_work_queue_id],
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
assert fallback is not None
|
|
320
|
+
assert fallback.message_id == fallback_message_id
|
|
321
|
+
|
|
322
|
+
async def test_enqueue_after_ack_keeps_idempotency_key_completed(
|
|
323
|
+
self,
|
|
324
|
+
queue: WorkerCleanupQueue,
|
|
325
|
+
) -> None:
|
|
326
|
+
work_pool_id = uuid4()
|
|
327
|
+
message_id = uuid4()
|
|
328
|
+
idempotency_key = "flow-run-cleanup"
|
|
329
|
+
first = await queue.enqueue(
|
|
330
|
+
message_id=message_id,
|
|
331
|
+
idempotency_key=idempotency_key,
|
|
332
|
+
work_pool_id=work_pool_id,
|
|
333
|
+
kind=CANCELLING_TIMEOUT_TEARDOWN,
|
|
334
|
+
target=_target(),
|
|
335
|
+
)
|
|
336
|
+
reservation = await queue.reserve(work_pool_id=work_pool_id)
|
|
337
|
+
assert reservation is not None
|
|
338
|
+
accepted = await queue.ack(
|
|
339
|
+
work_pool_id=work_pool_id,
|
|
340
|
+
message_id=message_id,
|
|
341
|
+
reservation_token=reservation.reservation_token,
|
|
342
|
+
)
|
|
343
|
+
wakeup_sequence = await queue.read_wakeup_sequence(work_pool_id)
|
|
344
|
+
|
|
345
|
+
duplicate = await queue.enqueue(
|
|
346
|
+
message_id=uuid4(),
|
|
347
|
+
idempotency_key=idempotency_key,
|
|
348
|
+
work_pool_id=work_pool_id,
|
|
349
|
+
kind=CANCELLING_TIMEOUT_TEARDOWN,
|
|
350
|
+
target=_target(),
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
assert accepted.status == "accepted"
|
|
354
|
+
assert duplicate.message_id == first.message_id
|
|
355
|
+
assert (
|
|
356
|
+
await queue.read_message(work_pool_id=work_pool_id, message_id=message_id)
|
|
357
|
+
is None
|
|
358
|
+
)
|
|
359
|
+
assert await queue.reserve(work_pool_id=work_pool_id) is None
|
|
360
|
+
assert await queue.read_wakeup_sequence(work_pool_id) == wakeup_sequence
|
|
361
|
+
|
|
362
|
+
async def test_completed_idempotency_retention_can_expire_tombstones(
|
|
363
|
+
self,
|
|
364
|
+
queue: WorkerCleanupQueue,
|
|
365
|
+
cleanup_policy_settings: CleanupPolicySettings,
|
|
366
|
+
) -> None:
|
|
367
|
+
work_pool_id = uuid4()
|
|
368
|
+
message_id = uuid4()
|
|
369
|
+
idempotency_key = "flow-run-cleanup"
|
|
370
|
+
with cleanup_policy_settings(completed_idempotency_retention_seconds=0.0):
|
|
371
|
+
await queue.enqueue(
|
|
372
|
+
message_id=message_id,
|
|
373
|
+
idempotency_key=idempotency_key,
|
|
374
|
+
work_pool_id=work_pool_id,
|
|
375
|
+
kind=CANCELLING_TIMEOUT_TEARDOWN,
|
|
376
|
+
target=_target(),
|
|
377
|
+
)
|
|
378
|
+
reservation = await queue.reserve(work_pool_id=work_pool_id)
|
|
379
|
+
assert reservation is not None
|
|
380
|
+
accepted = await queue.ack(
|
|
381
|
+
work_pool_id=work_pool_id,
|
|
382
|
+
message_id=message_id,
|
|
383
|
+
reservation_token=reservation.reservation_token,
|
|
384
|
+
)
|
|
385
|
+
assert accepted.status == "accepted"
|
|
386
|
+
|
|
387
|
+
new_message_id = uuid4()
|
|
388
|
+
duplicate_after_retention = await queue.enqueue(
|
|
389
|
+
message_id=new_message_id,
|
|
390
|
+
idempotency_key=idempotency_key,
|
|
391
|
+
work_pool_id=work_pool_id,
|
|
392
|
+
kind=CANCELLING_TIMEOUT_TEARDOWN,
|
|
393
|
+
target=_target(),
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
assert duplicate_after_retention.message_id == new_message_id
|
|
397
|
+
reservation = await queue.reserve(work_pool_id=work_pool_id)
|
|
398
|
+
assert reservation is not None
|
|
399
|
+
assert reservation.message_id == new_message_id
|
|
400
|
+
|
|
401
|
+
async def test_release_makes_message_eligible_for_redelivery(
|
|
402
|
+
self,
|
|
403
|
+
queue: WorkerCleanupQueue,
|
|
404
|
+
) -> None:
|
|
405
|
+
work_pool_id = uuid4()
|
|
406
|
+
message_id = await _enqueue_message(queue, work_pool_id=work_pool_id)
|
|
407
|
+
first = await queue.reserve(work_pool_id=work_pool_id)
|
|
408
|
+
assert first is not None
|
|
409
|
+
|
|
410
|
+
released = await queue.release(
|
|
411
|
+
work_pool_id=work_pool_id,
|
|
412
|
+
message_id=message_id,
|
|
413
|
+
reservation_token=first.reservation_token,
|
|
414
|
+
reason="cannot_act",
|
|
415
|
+
)
|
|
416
|
+
second = await queue.reserve(work_pool_id=work_pool_id)
|
|
417
|
+
|
|
418
|
+
assert released.status == "accepted"
|
|
419
|
+
assert second is not None
|
|
420
|
+
assert second.message_id == message_id
|
|
421
|
+
assert second.delivery_count == 2
|
|
422
|
+
assert second.reservation_token != first.reservation_token
|
|
423
|
+
|
|
424
|
+
async def test_release_moves_message_to_dlq_after_retry_limit(
|
|
425
|
+
self,
|
|
426
|
+
queue: WorkerCleanupQueue,
|
|
427
|
+
cleanup_policy_settings: CleanupPolicySettings,
|
|
428
|
+
) -> None:
|
|
429
|
+
work_pool_id = uuid4()
|
|
430
|
+
message_id = await _enqueue_message(queue, work_pool_id=work_pool_id)
|
|
431
|
+
with cleanup_policy_settings(max_delivery_attempts=1):
|
|
432
|
+
reservation = await queue.reserve(work_pool_id=work_pool_id)
|
|
433
|
+
assert reservation is not None
|
|
434
|
+
|
|
435
|
+
result = await queue.release(
|
|
436
|
+
work_pool_id=work_pool_id,
|
|
437
|
+
message_id=message_id,
|
|
438
|
+
reservation_token=reservation.reservation_token,
|
|
439
|
+
reason="unsupported_cleanup_kind",
|
|
440
|
+
)
|
|
441
|
+
dead_letter = await queue.read_dead_letter(
|
|
442
|
+
work_pool_id=work_pool_id, message_id=message_id
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
assert result.status == "dead_lettered"
|
|
446
|
+
assert result.reason == "max_delivery_attempts_reached"
|
|
447
|
+
assert dead_letter is not None
|
|
448
|
+
assert dead_letter.final_delivery_count == 1
|
|
449
|
+
assert dead_letter.release_reason == "unsupported_cleanup_kind"
|
|
450
|
+
assert (
|
|
451
|
+
await queue.read_message(work_pool_id=work_pool_id, message_id=message_id)
|
|
452
|
+
is None
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
async def test_expired_leases_redeliver_then_dlq_at_retry_limit(
|
|
456
|
+
self,
|
|
457
|
+
queue: WorkerCleanupQueue,
|
|
458
|
+
clock: QueueTestClock,
|
|
459
|
+
cleanup_policy_settings: CleanupPolicySettings,
|
|
460
|
+
) -> None:
|
|
461
|
+
work_pool_id = uuid4()
|
|
462
|
+
message_id = await _enqueue_message(queue, work_pool_id=work_pool_id)
|
|
463
|
+
with cleanup_policy_settings(lease_seconds=10.0, max_delivery_attempts=2):
|
|
464
|
+
first = await queue.reserve(work_pool_id=work_pool_id)
|
|
465
|
+
assert first is not None
|
|
466
|
+
|
|
467
|
+
clock.advance(timedelta(seconds=11))
|
|
468
|
+
first_expiry = await queue.expire_leases()
|
|
469
|
+
second = await queue.reserve(work_pool_id=work_pool_id)
|
|
470
|
+
assert second is not None
|
|
471
|
+
|
|
472
|
+
clock.advance(timedelta(seconds=11))
|
|
473
|
+
second_expiry = await queue.expire_leases()
|
|
474
|
+
dead_letter = await queue.read_dead_letter(
|
|
475
|
+
work_pool_id=work_pool_id, message_id=message_id
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
assert [message.message_id for message in first_expiry.redelivered] == [
|
|
479
|
+
message_id
|
|
480
|
+
]
|
|
481
|
+
assert first_expiry.dead_lettered == []
|
|
482
|
+
assert second.delivery_count == 2
|
|
483
|
+
assert second_expiry.redelivered == []
|
|
484
|
+
assert len(second_expiry.dead_lettered) == 1
|
|
485
|
+
assert dead_letter is not None
|
|
486
|
+
assert dead_letter.final_delivery_count == 2
|
|
487
|
+
assert await queue.reserve(work_pool_id=work_pool_id) is None
|
|
488
|
+
|
|
489
|
+
async def test_reserve_wakes_dispatchers_after_expiring_leases(
|
|
490
|
+
self,
|
|
491
|
+
queue: WorkerCleanupQueue,
|
|
492
|
+
clock: QueueTestClock,
|
|
493
|
+
cleanup_policy_settings: CleanupPolicySettings,
|
|
494
|
+
) -> None:
|
|
495
|
+
work_pool_id = uuid4()
|
|
496
|
+
message_id = await _enqueue_message(queue, work_pool_id=work_pool_id)
|
|
497
|
+
|
|
498
|
+
with cleanup_policy_settings(lease_seconds=10.0):
|
|
499
|
+
reservation = await queue.reserve(work_pool_id=work_pool_id)
|
|
500
|
+
assert reservation is not None
|
|
501
|
+
sequence = await queue.read_wakeup_sequence(work_pool_id)
|
|
502
|
+
|
|
503
|
+
clock.advance(timedelta(seconds=11))
|
|
504
|
+
unmatched_reservation = await queue.reserve(
|
|
505
|
+
work_pool_id=work_pool_id,
|
|
506
|
+
cleanup_kinds=[PENDING_CLAIM_TEARDOWN],
|
|
507
|
+
)
|
|
508
|
+
wakeup = await queue.wait_for_wakeup(
|
|
509
|
+
work_pool_id, after=sequence, timeout=1
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
redelivered = await queue.reserve(work_pool_id=work_pool_id)
|
|
513
|
+
|
|
514
|
+
assert unmatched_reservation is None
|
|
515
|
+
assert wakeup is not None
|
|
516
|
+
assert wakeup.sequence == sequence + 1
|
|
517
|
+
assert redelivered is not None
|
|
518
|
+
assert redelivered.message_id == message_id
|
|
519
|
+
|
|
520
|
+
async def test_expire_leases_respects_limit_and_work_pool_scope(
|
|
521
|
+
self,
|
|
522
|
+
queue: WorkerCleanupQueue,
|
|
523
|
+
clock: QueueTestClock,
|
|
524
|
+
cleanup_policy_settings: CleanupPolicySettings,
|
|
525
|
+
) -> None:
|
|
526
|
+
work_pool_id = uuid4()
|
|
527
|
+
other_work_pool_id = uuid4()
|
|
528
|
+
first_message_id = await _enqueue_message(
|
|
529
|
+
queue, work_pool_id=work_pool_id, idempotency_key="first-cleanup"
|
|
530
|
+
)
|
|
531
|
+
second_message_id = await _enqueue_message(
|
|
532
|
+
queue, work_pool_id=work_pool_id, idempotency_key="second-cleanup"
|
|
533
|
+
)
|
|
534
|
+
other_message_id = await _enqueue_message(
|
|
535
|
+
queue, work_pool_id=other_work_pool_id, idempotency_key="other-cleanup"
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
with cleanup_policy_settings(lease_seconds=10.0):
|
|
539
|
+
assert await queue.reserve(work_pool_id=work_pool_id) is not None
|
|
540
|
+
assert await queue.reserve(work_pool_id=work_pool_id) is not None
|
|
541
|
+
assert await queue.reserve(work_pool_id=other_work_pool_id) is not None
|
|
542
|
+
|
|
543
|
+
clock.advance(timedelta(seconds=11))
|
|
544
|
+
first_expiry = await queue.expire_leases(work_pool_id=work_pool_id, limit=1)
|
|
545
|
+
second_expiry = await queue.expire_leases(
|
|
546
|
+
work_pool_id=work_pool_id, limit=10
|
|
547
|
+
)
|
|
548
|
+
other_expiry = await queue.expire_leases(
|
|
549
|
+
work_pool_id=other_work_pool_id, limit=10
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
assert len(first_expiry.redelivered) == 1
|
|
553
|
+
assert len(second_expiry.redelivered) == 1
|
|
554
|
+
assert {
|
|
555
|
+
first_expiry.redelivered[0].message_id,
|
|
556
|
+
second_expiry.redelivered[0].message_id,
|
|
557
|
+
} == {first_message_id, second_message_id}
|
|
558
|
+
assert [message.message_id for message in other_expiry.redelivered] == [
|
|
559
|
+
other_message_id
|
|
560
|
+
]
|
|
561
|
+
|
|
562
|
+
async def test_renew_extends_current_reservation(
|
|
563
|
+
self,
|
|
564
|
+
queue: WorkerCleanupQueue,
|
|
565
|
+
clock: QueueTestClock,
|
|
566
|
+
) -> None:
|
|
567
|
+
work_pool_id = uuid4()
|
|
568
|
+
message_id = await _enqueue_message(queue, work_pool_id=work_pool_id)
|
|
569
|
+
reservation = await queue.reserve(work_pool_id=work_pool_id)
|
|
570
|
+
assert reservation is not None
|
|
571
|
+
|
|
572
|
+
clock.advance(timedelta(seconds=5))
|
|
573
|
+
result = await queue.renew(
|
|
574
|
+
work_pool_id=work_pool_id,
|
|
575
|
+
message_id=message_id,
|
|
576
|
+
reservation_token=reservation.reservation_token,
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
assert result.status == "accepted"
|
|
580
|
+
assert result.lease_expires_at == clock.current + timedelta(seconds=30)
|
|
581
|
+
|
|
582
|
+
async def test_operation_on_expired_lease_wakes_dispatchers(
|
|
583
|
+
self,
|
|
584
|
+
queue: WorkerCleanupQueue,
|
|
585
|
+
clock: QueueTestClock,
|
|
586
|
+
cleanup_policy_settings: CleanupPolicySettings,
|
|
587
|
+
) -> None:
|
|
588
|
+
work_pool_id = uuid4()
|
|
589
|
+
message_id = await _enqueue_message(queue, work_pool_id=work_pool_id)
|
|
590
|
+
with cleanup_policy_settings(lease_seconds=10.0):
|
|
591
|
+
reservation = await queue.reserve(work_pool_id=work_pool_id)
|
|
592
|
+
assert reservation is not None
|
|
593
|
+
sequence = await queue.read_wakeup_sequence(work_pool_id)
|
|
594
|
+
|
|
595
|
+
clock.advance(timedelta(seconds=11))
|
|
596
|
+
result = await queue.renew(
|
|
597
|
+
work_pool_id=work_pool_id,
|
|
598
|
+
message_id=message_id,
|
|
599
|
+
reservation_token=reservation.reservation_token,
|
|
600
|
+
)
|
|
601
|
+
wakeup = await queue.wait_for_wakeup(work_pool_id, after=sequence, timeout=1)
|
|
602
|
+
|
|
603
|
+
assert result.status == "expired"
|
|
604
|
+
assert wakeup is not None
|
|
605
|
+
assert wakeup.sequence == sequence + 1
|
|
606
|
+
|
|
607
|
+
async def test_enqueue_wakes_local_dispatchers(
|
|
608
|
+
self,
|
|
609
|
+
queue: WorkerCleanupQueue,
|
|
610
|
+
) -> None:
|
|
611
|
+
work_pool_id = uuid4()
|
|
612
|
+
sequence = await queue.read_wakeup_sequence(work_pool_id)
|
|
613
|
+
waiter = asyncio.create_task(
|
|
614
|
+
queue.wait_for_wakeup(work_pool_id, after=sequence, timeout=1)
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
await _enqueue_message(queue, work_pool_id=work_pool_id)
|
|
618
|
+
wakeup = await waiter
|
|
619
|
+
|
|
620
|
+
assert wakeup is not None
|
|
621
|
+
assert wakeup.work_pool_id == work_pool_id
|
|
622
|
+
assert wakeup.sequence == sequence + 1
|
prefect/utilities/pydantic.py
CHANGED
|
@@ -333,7 +333,8 @@ def parse_obj_as(
|
|
|
333
333
|
origin: Optional[Any] = get_origin(type_)
|
|
334
334
|
if origin is list and isinstance(data, dict):
|
|
335
335
|
values_dict: dict[Any, Any] = data
|
|
336
|
-
|
|
336
|
+
if values_dict:
|
|
337
|
+
data = next(iter(values_dict.values()))
|
|
337
338
|
|
|
338
339
|
parser: Callable[[Any], T] = getattr(adapter, f"validate_{mode}")
|
|
339
340
|
|
|
@@ -2,7 +2,7 @@ prefect/.prefectignore,sha256=awSprvKT0vI8a64mEOLrMxhxqcO-b0ERQeYpA2rNKVQ,390
|
|
|
2
2
|
prefect/AGENTS.md,sha256=SNF9LRWtYJ2pIHUVswgtMaWlloKszhNTL6dhPreh5kc,10484
|
|
3
3
|
prefect/__init__.py,sha256=Z8rwfLbEOLh-5WcznTZP3FG2-9UgGZxY-prj8sL0-Qk,6828
|
|
4
4
|
prefect/__main__.py,sha256=WFjw3kaYJY6pOTA7WDOgqjsz8zUEUZHCcj3P5wyVa-g,66
|
|
5
|
-
prefect/_build_info.py,sha256=
|
|
5
|
+
prefect/_build_info.py,sha256=Iq3OJkGs6udHR0PUIZry5OcTNeFp4y0M1AD1jcaT2K8,185
|
|
6
6
|
prefect/_flow_run_suspension.py,sha256=5zTTB7ZIBHzoS0pVrhNn23-9hK51qZ3CQA6C-azluC0,4144
|
|
7
7
|
prefect/agent.py,sha256=dPvG1jDGD5HSH7aM2utwtk6RaJ9qg13XjkA0lAIgQmY,287
|
|
8
8
|
prefect/artifacts.py,sha256=ZdMLJeJGK82hibtRzbsVa-g95dMa0D2UP1LiESoXmf4,23951
|
|
@@ -184,7 +184,7 @@ prefect/cli/task.py,sha256=Oqz7o4sAVV8ZuLy3HF0osE_cAral2Zo3KBVmSwrLRyE,3669
|
|
|
184
184
|
prefect/cli/task_run.py,sha256=q3kFAdka1kaoPB-6-797Qe1XyXuZDl3Gh5fVvQmXzNg,11363
|
|
185
185
|
prefect/cli/variable.py,sha256=mosmUsu2jbj57tspicIjLD3dXjglP-C97bB5TsFyQgc,6478
|
|
186
186
|
prefect/cli/version.py,sha256=SQKKtxYqqYoNMviSVz7U0r80GD0noEeVn4v2duYfCyk,4135
|
|
187
|
-
prefect/cli/work_pool.py,sha256=
|
|
187
|
+
prefect/cli/work_pool.py,sha256=1cdpbzVsfr0eqrT-cB8lnIc43h7o17Kgjx50FbzpZ7A,60240
|
|
188
188
|
prefect/cli/work_queue.py,sha256=k_gsedmldy23EMCT7xRDXbuhntPzcxqww5zyw2tNoNA,28200
|
|
189
189
|
prefect/cli/worker.py,sha256=hfj2gCSBahcfz1wj0VHd6w2HkKSqg9MLvTT0oUs0aHs,7697
|
|
190
190
|
prefect/cli/cloud/__init__.py,sha256=RuE_t07oi1axjyuKr8ev_B83jBePW5fHB001RP_mtsA,14978
|
|
@@ -193,7 +193,7 @@ prefect/cli/cloud/ip_allowlist.py,sha256=xNWvDXXLF159q3MgLF7KqjAe2XjyDqEXfGjC72I
|
|
|
193
193
|
prefect/cli/cloud/webhook.py,sha256=j0nzupRem5gr1cLutrO5ALhDaiTeNWTBusq1c-UBArI,8718
|
|
194
194
|
prefect/cli/deploy/__init__.py,sha256=plVbJQ0ZDPsTFCRL7SYZXUhXaUi0KNU86dlydBeKGjs,121
|
|
195
195
|
prefect/cli/deploy/_actions.py,sha256=JLseIc8MdS0kZtAs5doSxVfCFhUIwnD87twtLYA4Wsw,10321
|
|
196
|
-
prefect/cli/deploy/_commands.py,sha256=
|
|
196
|
+
prefect/cli/deploy/_commands.py,sha256=ofKDJ0KVTdEZy3BraJoaltKjxhZbDgTrq8xg4dk4IGg,16280
|
|
197
197
|
prefect/cli/deploy/_config.py,sha256=d1LEuz1tWmYNqYTp3Gwdly0Pg-Wg-RsEmN5AiiF8jG0,19245
|
|
198
198
|
prefect/cli/deploy/_core.py,sha256=0LD5UUynF07cFfbzx4j1IVQgaz4mHEmS7u0pKnsgLkQ,20417
|
|
199
199
|
prefect/cli/deploy/_models.py,sha256=9yuv9RwOxw5u8ldbYpa23Z-60wBtxRA887Jy6dzVmII,5119
|
|
@@ -230,7 +230,7 @@ prefect/client/orchestration/routes.py,sha256=YTA183Auz6LN4fqq6uLWxGLupOv6PWf0Ct
|
|
|
230
230
|
prefect/client/orchestration/_artifacts/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
231
231
|
prefect/client/orchestration/_artifacts/client.py,sha256=BFSWJkJ4P3SNrWfjIkuEXl5WDVhFnQRDCxquQGu_aJU,11056
|
|
232
232
|
prefect/client/orchestration/_automations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
233
|
-
prefect/client/orchestration/_automations/client.py,sha256=
|
|
233
|
+
prefect/client/orchestration/_automations/client.py,sha256=cGBIbicgshufID3UZrybavmyI9B4sovPY6i3hSy4Qbc,11947
|
|
234
234
|
prefect/client/orchestration/_blocks_documents/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
235
235
|
prefect/client/orchestration/_blocks_documents/client.py,sha256=HTGUIsOkHbe-Vh4hod6oN4VnKNSaOyVuhvToDDGOZ3M,11474
|
|
236
236
|
prefect/client/orchestration/_blocks_schemas/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
@@ -310,7 +310,7 @@ prefect/events/AGENTS.md,sha256=ZCrjaA67BQGdDzO5R7yJB7HO5Ahb8z7A3sT8yuKQ6V0,3049
|
|
|
310
310
|
prefect/events/__init__.py,sha256=e3aIcHzTfseOmq_A3hM_jSPNYP_OdndJErzjcdrMuAc,2202
|
|
311
311
|
prefect/events/actions.py,sha256=Ii5P4FX10Xu1nzU0e7okA1KC1eVIHhzExubFurrOr_Q,9785
|
|
312
312
|
prefect/events/clients.py,sha256=gL8EClV_ryULkXvnR8DU0nrOH1kTd8FrMPYKy3c8SdQ,33354
|
|
313
|
-
prefect/events/filters.py,sha256=
|
|
313
|
+
prefect/events/filters.py,sha256=YiM5A9E3KWeVL7HoMHYbATXUK1_pnChg1-g746GaMds,9999
|
|
314
314
|
prefect/events/related.py,sha256=CTeexYUmmA93V4gsR33GIFmw-SS-X_ouOpRg-oeq-BU,6672
|
|
315
315
|
prefect/events/subscribers.py,sha256=OvT6vh6YVS5y_ktqCRM_ayNWQOFzZA_dvakKcSUl-4U,6714
|
|
316
316
|
prefect/events/utilities.py,sha256=UPx_uRC0X7ahADvyP641-pPzgQeT4CcECe3iGScy4J4,3445
|
|
@@ -364,8 +364,8 @@ prefect/runner/_starter_direct.py,sha256=8cneJRIBud3HXUaf93ut92rQJfWEqg7_y0r_EVG
|
|
|
364
364
|
prefect/runner/_starter_engine.py,sha256=FjcHQe_iryk8BFpeiGw8MCWcLSRKhYwmb2iA4eou0zg,6507
|
|
365
365
|
prefect/runner/_state_proposer.py,sha256=iW-SlB5EOWHErWbyRJwRTirfqwo0BJ1fAlbScVi6vcg,7225
|
|
366
366
|
prefect/runner/_workspace_resolver.py,sha256=2wQ7PXTanHY5yVGqhZuGh122DvNpTNZXEY_rMfexEtE,14020
|
|
367
|
-
prefect/runner/_workspace_starter.py,sha256=
|
|
368
|
-
prefect/runner/runner.py,sha256=
|
|
367
|
+
prefect/runner/_workspace_starter.py,sha256=P9tX_iv0zROc6k6YB68L5eNiyuc_HoP2clOSqE--rsg,12096
|
|
368
|
+
prefect/runner/runner.py,sha256=ZNgmuD19yNldoCDCXynNp1NoB8idIbZQb24vdKCnMvM,75785
|
|
369
369
|
prefect/runner/server.py,sha256=YqvQjlxZZHyhSsqyaLvOy2NwTDg1hLSZB2PK3t8FJUg,3636
|
|
370
370
|
prefect/runner/storage.py,sha256=yt_v2cVxYbazkZgfEdDQv0n-BS8rUvlFE8M_X3iN6NM,40525
|
|
371
371
|
prefect/runtime/__init__.py,sha256=JswiTlYRup2zXOYu8AqJ7czKtgcw9Kxo0tTbS6aWCqY,407
|
|
@@ -1263,7 +1263,7 @@ prefect/server/utilities/schemas/serializers.py,sha256=HqawNki57u9BZz-czAxccscdz
|
|
|
1263
1263
|
prefect/server/worker_communication/__init__.py,sha256=QAyUV9J4JM8C-0Yge7fsbOrQepqIxEL3FUi1m8bLrew,53
|
|
1264
1264
|
prefect/server/worker_communication/cleanup_queue/__init__.py,sha256=fNED8IFb3mmznsoxoK2RdL-i7eNnWoSvED8ks1m3ch8,9245
|
|
1265
1265
|
prefect/server/worker_communication/cleanup_queue/memory.py,sha256=DE4H5hb3ngxI1UO4dGXi8ARtZeHNbhJeN4nu71bBZkI,26449
|
|
1266
|
-
prefect/settings/AGENTS.md,sha256=
|
|
1266
|
+
prefect/settings/AGENTS.md,sha256=WciIrqatma3vrWgr9bdJZ5P9tKu7G2GnMfgTsXUT8oo,4625
|
|
1267
1267
|
prefect/settings/__init__.py,sha256=3IbV7BM6RZ7bVCNJUxEayYUL-G0ydIIK0oB_2Vooe7U,2227
|
|
1268
1268
|
prefect/settings/_types.py,sha256=Ud3cph5Rdee3EXfh0MyCxW2Kwurp7DkaUn9TSpG8wzQ,8884
|
|
1269
1269
|
prefect/settings/base.py,sha256=UamKBsN3GBbHsU1CkbrPgT8qn3wlWSNkzq2keYHi9sc,11088
|
|
@@ -1307,7 +1307,7 @@ prefect/settings/models/server/root.py,sha256=j7uAJm82IZjnafFLmQDEk4jdKjMEkcKwUk
|
|
|
1307
1307
|
prefect/settings/models/server/services.py,sha256=DKKzwcQLbG89wfuv-JbqDP2j58jWYO7R1Ajx8xmz344,24989
|
|
1308
1308
|
prefect/settings/models/server/tasks.py,sha256=ckxRbx33HuHldRkGtiyRKerieubBK8U-sLRKxbrdGGI,2976
|
|
1309
1309
|
prefect/settings/models/server/ui.py,sha256=q5Vy2w77GzU0mat3bRih-Plha0JXgUhUQeOpNXRBfsY,2284
|
|
1310
|
-
prefect/settings/models/server/worker_channel.py,sha256=
|
|
1310
|
+
prefect/settings/models/server/worker_channel.py,sha256=FroUoIs2sTL2McQTlKgODOLIg25KXe3vZMWvLnkM7gI,1885
|
|
1311
1311
|
prefect/telemetry/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
1312
1312
|
prefect/telemetry/run_telemetry.py,sha256=2VTRIoLOrC5Prb6i2JCSMUR5bWM-pVMRH9b9mOIupNY,8595
|
|
1313
1313
|
prefect/testing/AGENTS.md,sha256=Q7ys6LcpZYprXnUlIx6gsX73unD36lsnqS0pOMWbF8Y,2156
|
|
@@ -1316,8 +1316,9 @@ prefect/testing/cli.py,sha256=ieHwsS3vlM08KY9jP5QbGwUXXkJiUEiluoTAvGVHn58,13129
|
|
|
1316
1316
|
prefect/testing/docker.py,sha256=N46yDmWaGseej_LEVZy91BJcbDaEhFyMKk30-xDVzPQ,635
|
|
1317
1317
|
prefect/testing/fixtures.py,sha256=tkTsJyh4JkHkdeVRX0E_6QnpwRKAMVJJJITMBU6oRC8,17207
|
|
1318
1318
|
prefect/testing/utilities.py,sha256=bVcXMDdgkcOr2V_Ldl1LM0Vz7MbMj_R4OPKN3JNMtDM,11368
|
|
1319
|
-
prefect/testing/standard_test_suites/__init__.py,sha256=
|
|
1319
|
+
prefect/testing/standard_test_suites/__init__.py,sha256=MpVyC1aPbUEZalvzT4gpH-8XvUzxBKXRBobtRCnbETc,113
|
|
1320
1320
|
prefect/testing/standard_test_suites/blocks.py,sha256=mLA4YCgkLlibmnWvgJD8kWs4w9cwXfuCBl2AqoTPqjY,2835
|
|
1321
|
+
prefect/testing/standard_test_suites/worker_cleanup_queue.py,sha256=WRCulmfuPtSDOYnmdZ1xQ0vvDZ7REoiKrbQEpPzdYwA,22954
|
|
1321
1322
|
prefect/types/__init__.py,sha256=-w7-dPhK3E1EczOR0wDQ5dZWoDc2fLe1e6T2G6QtpWc,8273
|
|
1322
1323
|
prefect/types/_concurrency.py,sha256=ENLOFJlQlt3y2H0gJyZuZmrNyaBDsfFAZq3vaNER33s,354
|
|
1323
1324
|
prefect/types/_datetime.py,sha256=Xd9PqknkNTyUHpnszb3LdVenFpFqwvS14KAAA8mO4Vk,11625
|
|
@@ -1336,7 +1337,7 @@ prefect/utilities/hashing.py,sha256=7jRy26s46IJAFRmVnCnoK9ek9N4p_UfXxQQvu2tW6dM,
|
|
|
1336
1337
|
prefect/utilities/importtools.py,sha256=Ft9pU21xvooJwzMubJqp5Gj9a2y8TpesSYJssAViBiQ,18330
|
|
1337
1338
|
prefect/utilities/math.py,sha256=UPIdJMP13lCU3o0Yz98o4VDw3LTkkrsOAsvAdA3Xifc,2954
|
|
1338
1339
|
prefect/utilities/names.py,sha256=PcNp3IbSoJY6P3UiJDYDjpYQw6BYWtn6OarFDCq1dUE,1744
|
|
1339
|
-
prefect/utilities/pydantic.py,sha256=
|
|
1340
|
+
prefect/utilities/pydantic.py,sha256=5N4Adv2fEU51fAkPDKxuDhrRDyRjacnxXPZy0Mwq6w4,13482
|
|
1340
1341
|
prefect/utilities/render_swagger.py,sha256=y0GcR38qW083lUPrfHIbDVKPm_fyyodtBM8MTLNF8oI,4155
|
|
1341
1342
|
prefect/utilities/services.py,sha256=YV_Cv_ELkzqSM9V9fGDBdiCfHWjc4mgMqwuhApg-b0s,7713
|
|
1342
1343
|
prefect/utilities/slugify.py,sha256=57Vb14t13F3zm1P65KAu8nVeAz0iJCd1Qc5eMG-R5y8,169
|
|
@@ -1375,8 +1376,8 @@ prefect/workers/_worker_channel/_protocol.py,sha256=GpCNh1o3qmmqHA_UOOTge1QVC6IR
|
|
|
1375
1376
|
prefect/workers/_worker_channel/_state.py,sha256=eQTFZtAVDZH1vVWps3SdeY6aW3qu2wx1UKYQXK3AyuE,5369
|
|
1376
1377
|
prefect/workers/_worker_channel/_sync.py,sha256=G5G8_UaQYbeLebi5Mb1Z_KgGfXfyjXo5uT9la5_zwqY,14984
|
|
1377
1378
|
prefect/workers/_worker_channel/_transport.py,sha256=_8aWX16tdbZcpqBg4HCX_vCzl2FX47jsYPKMPLAANRA,9181
|
|
1378
|
-
prefect-3.7.
|
|
1379
|
-
prefect-3.7.
|
|
1380
|
-
prefect-3.7.
|
|
1381
|
-
prefect-3.7.
|
|
1382
|
-
prefect-3.7.
|
|
1379
|
+
prefect-3.7.4.dev2.dist-info/METADATA,sha256=DDLxV4QfjlNJQ27nE3KXI8MZ1hNwWn6CF-w6OgUQ5f8,14030
|
|
1380
|
+
prefect-3.7.4.dev2.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
1381
|
+
prefect-3.7.4.dev2.dist-info/entry_points.txt,sha256=HlY8up83iIq2vU2r33a0qSis4eOFSyb1mRH4l7Xt9X8,126
|
|
1382
|
+
prefect-3.7.4.dev2.dist-info/licenses/LICENSE,sha256=MCxsn8osAkzfxKC4CC_dLcUkU8DZLkyihZ8mGs3Ah3Q,11357
|
|
1383
|
+
prefect-3.7.4.dev2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|