dirigent-core 0.16.2__tar.gz → 0.16.3__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.
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/PKG-INFO +4 -4
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/pyproject.toml +4 -4
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/pyproject.toml.orig +4 -4
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/alerting.py +37 -1
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/engine/executor.py +5 -1
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/engine/references.py +112 -28
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/engine/runs.py +10 -1
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/engine/state.py +14 -5
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/LICENSE +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/README.md +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/__init__.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/alembic/env.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/alembic/script.py.mako +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/alembic/versions/0001_baseline_schema.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/artifacts.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/auth.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/blockdocs.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/config.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/configdocs.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/database.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/directory.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/documents.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/documentschema.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/engine/__init__.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/engine/claim.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/engine/context.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/engine/definition.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/engine/failure.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/engine/recovery.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/engine/services.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/examples.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/ids.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/logging.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/migrations.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/models.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/pipelines.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/plugins.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/protocol.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/py.typed +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/ratelimit.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/registry.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/reporting.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/retention.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/scheduler.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/schemas.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/secrets.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/storage.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/telemetry.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/trigger_documents.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/triggers/__init__.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/triggers/backfill.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/triggers/materialize.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/triggers/schedules.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/triggers/webhooks.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/types.py +0 -0
- {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/worker.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dirigent-core
|
|
3
|
-
Version: 0.16.
|
|
3
|
+
Version: 0.16.3
|
|
4
4
|
Summary: Dirigent engine core: schema, configuration, and plugin host.
|
|
5
5
|
License-Expression: LicenseRef-Proprietary
|
|
6
6
|
License-File: LICENSE
|
|
@@ -10,9 +10,9 @@ Requires-Dist: argon2-cffi>=25.1.0
|
|
|
10
10
|
Requires-Dist: asyncpg>=0.31.0
|
|
11
11
|
Requires-Dist: cronsim>=2.7
|
|
12
12
|
Requires-Dist: cryptography>=46.0.5
|
|
13
|
-
Requires-Dist: dirigent-client==0.16.
|
|
14
|
-
Requires-Dist: dirigent-common==0.16.
|
|
15
|
-
Requires-Dist: dirigent-plugin==0.16.
|
|
13
|
+
Requires-Dist: dirigent-client==0.16.3
|
|
14
|
+
Requires-Dist: dirigent-common==0.16.3
|
|
15
|
+
Requires-Dist: dirigent-plugin==0.16.3
|
|
16
16
|
Requires-Dist: jsonschema>=4.26.0
|
|
17
17
|
Requires-Dist: opentelemetry-api>=1.44.0
|
|
18
18
|
Requires-Dist: pydantic-settings>=2.15.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "dirigent-core"
|
|
3
|
-
version = "0.16.
|
|
3
|
+
version = "0.16.3"
|
|
4
4
|
description = "Dirigent engine core: schema, configuration, and plugin host."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.13"
|
|
@@ -13,9 +13,9 @@ dependencies = [
|
|
|
13
13
|
"asyncpg>=0.31.0",
|
|
14
14
|
"cronsim>=2.7",
|
|
15
15
|
"cryptography>=46.0.5",
|
|
16
|
-
"dirigent-client==0.16.
|
|
17
|
-
"dirigent-common==0.16.
|
|
18
|
-
"dirigent-plugin==0.16.
|
|
16
|
+
"dirigent-client==0.16.3",
|
|
17
|
+
"dirigent-common==0.16.3",
|
|
18
|
+
"dirigent-plugin==0.16.3",
|
|
19
19
|
"jsonschema>=4.26.0",
|
|
20
20
|
"opentelemetry-api>=1.44.0",
|
|
21
21
|
"pydantic-settings>=2.15.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "dirigent-core"
|
|
3
|
-
version = "0.16.
|
|
3
|
+
version = "0.16.3"
|
|
4
4
|
description = "Dirigent engine core: schema, configuration, and plugin host."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.13"
|
|
@@ -13,9 +13,9 @@ dependencies = [
|
|
|
13
13
|
"asyncpg>=0.31.0",
|
|
14
14
|
"cronsim>=2.7",
|
|
15
15
|
"cryptography>=46.0.5",
|
|
16
|
-
"dirigent-client==0.16.
|
|
17
|
-
"dirigent-common==0.16.
|
|
18
|
-
"dirigent-plugin==0.16.
|
|
16
|
+
"dirigent-client==0.16.3",
|
|
17
|
+
"dirigent-common==0.16.3",
|
|
18
|
+
"dirigent-plugin==0.16.3",
|
|
19
19
|
"jsonschema>=4.26.0",
|
|
20
20
|
"opentelemetry-api>=1.44.0",
|
|
21
21
|
"pydantic-settings>=2.15.0",
|
|
@@ -27,7 +27,7 @@ from dirigent_common import (
|
|
|
27
27
|
format_duration,
|
|
28
28
|
render,
|
|
29
29
|
)
|
|
30
|
-
from dirigent_core.database import session_scope
|
|
30
|
+
from dirigent_core.database import is_deadlock, session_scope
|
|
31
31
|
from dirigent_core.engine.definition import load_definition
|
|
32
32
|
from dirigent_core.engine.services import EngineServices
|
|
33
33
|
from dirigent_core.ids import uuid7
|
|
@@ -326,7 +326,43 @@ async def raise_for_run(
|
|
|
326
326
|
has already assembled them, and they are read here when a rule matched and the caller had
|
|
327
327
|
none, so a run nothing watches pays for no facts at all. The rendered document is what the
|
|
328
328
|
templates read as ``report``; it is not stored on the notification.
|
|
329
|
+
|
|
330
|
+
A savepoint, and everything that fails inside it is logged rather than raised: the caller
|
|
331
|
+
has already written the outcome this reports on, and an alert nobody can queue must not
|
|
332
|
+
cost the work a second execution. A deadlock is the one failure passed on, because the
|
|
333
|
+
caller answers that by running its whole transaction again.
|
|
329
334
|
"""
|
|
335
|
+
try:
|
|
336
|
+
async with session.begin_nested():
|
|
337
|
+
return await _queue_for_run(
|
|
338
|
+
session,
|
|
339
|
+
services,
|
|
340
|
+
run,
|
|
341
|
+
event,
|
|
342
|
+
now=now,
|
|
343
|
+
facts=facts,
|
|
344
|
+
report=report,
|
|
345
|
+
report_artifact_id=report_artifact_id,
|
|
346
|
+
)
|
|
347
|
+
except Exception as error:
|
|
348
|
+
if is_deadlock(error):
|
|
349
|
+
raise
|
|
350
|
+
_logger.error("alerts not raised", run_id=str(run.id), alert_event=event.value, error=str(error))
|
|
351
|
+
return []
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
async def _queue_for_run(
|
|
355
|
+
session: AsyncSession,
|
|
356
|
+
services: EngineServices,
|
|
357
|
+
run: Run,
|
|
358
|
+
event: AlertEvent,
|
|
359
|
+
*,
|
|
360
|
+
now: datetime | None = None,
|
|
361
|
+
facts: "RunFacts | None" = None,
|
|
362
|
+
report: str | None = None,
|
|
363
|
+
report_artifact_id: UUID | None = None,
|
|
364
|
+
) -> list[Notification]:
|
|
365
|
+
"""Write down the notifications and the timeline entries one raised event owes."""
|
|
330
366
|
moment = now or utcnow()
|
|
331
367
|
pipeline = await session.get(Pipeline, run.pipeline_id)
|
|
332
368
|
if pipeline is None: # pragma: no cover - the foreign key makes this unreachable
|
|
@@ -547,13 +547,17 @@ class Engine:
|
|
|
547
547
|
"""Append what the buffer holds to the run, in a transaction of its own.
|
|
548
548
|
|
|
549
549
|
Committed entries leave the buffer for good, so the outcome cannot write them twice;
|
|
550
|
-
a flush that failed puts them back, for the next flush or for the outcome.
|
|
550
|
+
a flush that failed puts them back, for the next flush or for the outcome. The run's
|
|
551
|
+
lock holds from the first id this takes until the commit that makes it readable, so
|
|
552
|
+
two attempts flushing at once cannot commit their entries out of id order and leave a
|
|
553
|
+
stream paging by id past one that was still in flight.
|
|
551
554
|
"""
|
|
552
555
|
entries = logger.drain()
|
|
553
556
|
if not entries:
|
|
554
557
|
return
|
|
555
558
|
try:
|
|
556
559
|
async with session_scope(self.sessions) as session:
|
|
560
|
+
await lock_run(session, logger.run_id)
|
|
557
561
|
await session.execute(sa.insert(LogEntry), entries)
|
|
558
562
|
except Exception as error:
|
|
559
563
|
logger.restore(entries)
|
|
@@ -18,8 +18,7 @@ how a compose file, a shell command or a template reaches a tool with its own br
|
|
|
18
18
|
"""
|
|
19
19
|
|
|
20
20
|
import re
|
|
21
|
-
import
|
|
22
|
-
from collections.abc import Callable, Container
|
|
21
|
+
from collections.abc import Callable, Collection
|
|
23
22
|
from datetime import datetime
|
|
24
23
|
from typing import Final, cast
|
|
25
24
|
from uuid import UUID
|
|
@@ -27,6 +26,7 @@ from uuid import UUID
|
|
|
27
26
|
from pydantic import BaseModel, ConfigDict, Field, JsonValue
|
|
28
27
|
|
|
29
28
|
from dirigent_common import JsonMap
|
|
29
|
+
from dirigent_plugin import SHELL_VARIABLE_PREFIX, SHELL_VARIABLES_FIELD
|
|
30
30
|
|
|
31
31
|
REFERENCE_PATTERN: Final = re.compile(r"(\$+)\{([^{}]+)\}")
|
|
32
32
|
"""A run of dollars before a braced name.
|
|
@@ -88,28 +88,38 @@ class ReferenceScope(BaseModel):
|
|
|
88
88
|
"""The end of the run's logical data interval, exclusive, when it carries one."""
|
|
89
89
|
|
|
90
90
|
|
|
91
|
-
def resolve(value: JsonValue, scope: ReferenceScope
|
|
91
|
+
def resolve(value: JsonValue, scope: ReferenceScope) -> JsonValue:
|
|
92
92
|
"""Resolve every reference in a config value, recursively, preserving structure."""
|
|
93
93
|
match value:
|
|
94
94
|
case str():
|
|
95
|
-
return _resolve_string(value, scope
|
|
95
|
+
return _resolve_string(value, scope)
|
|
96
96
|
case list():
|
|
97
|
-
return [resolve(element, scope
|
|
97
|
+
return [resolve(element, scope) for element in value]
|
|
98
98
|
case dict():
|
|
99
|
-
return {key: resolve(element, scope
|
|
99
|
+
return {key: resolve(element, scope) for key, element in value.items()}
|
|
100
100
|
case _:
|
|
101
101
|
return value
|
|
102
102
|
|
|
103
103
|
|
|
104
|
-
def resolve_config(config: JsonMap, scope: ReferenceScope, *, shell_fields:
|
|
104
|
+
def resolve_config(config: JsonMap, scope: ReferenceScope, *, shell_fields: Collection[str] = ()) -> JsonMap:
|
|
105
105
|
"""Resolve a step's whole config map, which is what the engine hands a block.
|
|
106
106
|
|
|
107
107
|
``shell_fields`` names the config keys the block marked with
|
|
108
|
-
:class:`~dirigent_plugin.ShellString`, whose value is handed to ``sh -c``.
|
|
109
|
-
substituted value
|
|
110
|
-
|
|
108
|
+
:class:`~dirigent_plugin.ShellString`, whose value is handed to ``sh -c``. There a
|
|
109
|
+
substituted value never reaches the shell's parser: each reference is rewritten to a
|
|
110
|
+
variable of the engine's own, and the values are collected in ``shell_variables`` for the
|
|
111
|
+
block to set in the command's environment. The metacharacters the pipeline author typed
|
|
112
|
+
keep their meaning, and a parameter that arrived in a webhook payload is never shell
|
|
113
|
+
source, however the author quoted the reference.
|
|
111
114
|
"""
|
|
112
|
-
|
|
115
|
+
variables: dict[str, str] = {}
|
|
116
|
+
resolved = {
|
|
117
|
+
key: (_resolve_shell(value, scope, variables) if key in shell_fields else resolve(value, scope))
|
|
118
|
+
for key, value in config.items()
|
|
119
|
+
}
|
|
120
|
+
if shell_fields:
|
|
121
|
+
resolved[SHELL_VARIABLES_FIELD] = cast("JsonValue", variables)
|
|
122
|
+
return resolved
|
|
113
123
|
|
|
114
124
|
|
|
115
125
|
def has_reference(value: str) -> bool:
|
|
@@ -124,15 +134,24 @@ def substitute(value: str, render: Callable[[str], str]) -> str:
|
|
|
124
134
|
"""
|
|
125
135
|
|
|
126
136
|
def one(match: re.Match[str]) -> str:
|
|
127
|
-
|
|
128
|
-
literal
|
|
129
|
-
if len(dollars) % 2 == 0:
|
|
130
|
-
return f"{literal}{{{reference}}}"
|
|
131
|
-
return literal + render(reference)
|
|
137
|
+
literal, resolves = _collapse(match.group(1), match.group(2))
|
|
138
|
+
return literal + render(match.group(2)) if resolves else literal
|
|
132
139
|
|
|
133
140
|
return REFERENCE_PATTERN.sub(one, value)
|
|
134
141
|
|
|
135
142
|
|
|
143
|
+
def _collapse(dollars: str, reference: str) -> tuple[str, bool]:
|
|
144
|
+
"""The literal text a run of dollars leaves, and whether the reference after it resolves.
|
|
145
|
+
|
|
146
|
+
The dollars collapse in pairs, so an even run leaves the braces as text and an odd one
|
|
147
|
+
makes what follows a reference.
|
|
148
|
+
"""
|
|
149
|
+
literal = "$" * (len(dollars) // 2)
|
|
150
|
+
if len(dollars) % 2 == 0:
|
|
151
|
+
return f"{literal}{{{reference}}}", False
|
|
152
|
+
return literal, True
|
|
153
|
+
|
|
154
|
+
|
|
136
155
|
def references_in(value: JsonValue) -> list[str]:
|
|
137
156
|
"""List every reference a value names, skipping the escaped ones."""
|
|
138
157
|
match value:
|
|
@@ -146,22 +165,87 @@ def references_in(value: JsonValue) -> list[str]:
|
|
|
146
165
|
return []
|
|
147
166
|
|
|
148
167
|
|
|
149
|
-
def _resolve_string(value: str, scope: ReferenceScope
|
|
150
|
-
"""Resolve a string, typed when it is one whole reference and textual when embedded.
|
|
151
|
-
|
|
152
|
-
Under ``quote`` the whole-reference shortcut is dropped too: handing a command that is
|
|
153
|
-
entirely one reference to a shell unquoted would let a parameter be an arbitrary program.
|
|
154
|
-
"""
|
|
168
|
+
def _resolve_string(value: str, scope: ReferenceScope) -> JsonValue:
|
|
169
|
+
"""Resolve a string, typed when it is one whole reference and textual when embedded."""
|
|
155
170
|
whole = WHOLE_REFERENCE.match(value)
|
|
156
|
-
if whole is not None
|
|
171
|
+
if whole is not None:
|
|
157
172
|
return lookup(whole.group(1).strip(), scope)
|
|
158
|
-
|
|
159
|
-
|
|
173
|
+
return substitute(value, lambda reference: _as_text(lookup(reference.strip(), scope)))
|
|
174
|
+
|
|
160
175
|
|
|
176
|
+
def _resolve_shell(value: JsonValue, scope: ReferenceScope, variables: dict[str, str]) -> JsonValue:
|
|
177
|
+
"""Rewrite a shell string so that nothing it substitutes is ever parsed by the shell.
|
|
161
178
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
179
|
+
Each reference becomes a reference to a variable the engine invents, and its value is
|
|
180
|
+
recorded in ``variables`` under that name for the block to set in the command's
|
|
181
|
+
environment. A variable's value is text a shell expands and never re-reads, so what a
|
|
182
|
+
webhook payload carries cannot become shell source however the author wrote the
|
|
183
|
+
reference; what the quoting below decides is only whether that text stays one word.
|
|
184
|
+
|
|
185
|
+
A shell string is always text, so the whole-reference shortcut does not apply: a command
|
|
186
|
+
that is entirely one reference is one word, and therefore the name of a program to run
|
|
187
|
+
and never a program.
|
|
188
|
+
"""
|
|
189
|
+
if not isinstance(value, str):
|
|
190
|
+
return value
|
|
191
|
+
rewritten: list[str] = []
|
|
192
|
+
context: tuple[str, ...] = ()
|
|
193
|
+
read = 0
|
|
194
|
+
for match in REFERENCE_PATTERN.finditer(value):
|
|
195
|
+
before = value[read : match.start()]
|
|
196
|
+
context = _shell_context(before, context)
|
|
197
|
+
literal, resolves = _collapse(match.group(1), match.group(2))
|
|
198
|
+
rewritten.append(before + literal)
|
|
199
|
+
if resolves:
|
|
200
|
+
name = f"{SHELL_VARIABLE_PREFIX}{len(variables)}"
|
|
201
|
+
variables[name] = _as_text(lookup(match.group(2).strip(), scope))
|
|
202
|
+
rewritten.append(_shell_word(name, context))
|
|
203
|
+
read = match.end()
|
|
204
|
+
rewritten.append(value[read:])
|
|
205
|
+
return "".join(rewritten)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _shell_word(name: str, context: tuple[str, ...]) -> str:
|
|
209
|
+
"""A reference to the variable that is one word where the author put it.
|
|
210
|
+
|
|
211
|
+
Inside the author's quotes it takes none of its own: a second pair would end theirs and
|
|
212
|
+
leave the value to be split into words. Anywhere else it takes a pair, or the shell would
|
|
213
|
+
split the value and expand any glob in it -- bare, and inside a ``$( )`` or a backquoted
|
|
214
|
+
command, which quote nothing they hold. Inside single quotes, which a shell keeps
|
|
215
|
+
literal, this text is what the author gets rather than the value.
|
|
216
|
+
"""
|
|
217
|
+
return f"${name}" if context and context[-1] in "'\"" else f'"${name}"'
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _shell_context(text: str, context: tuple[str, ...]) -> tuple[str, ...]:
|
|
221
|
+
"""Where in a shell's quoting this text leaves it, given where it began.
|
|
222
|
+
|
|
223
|
+
The stack holds what the shell has opened and not yet closed: a quote, a ``$( )``
|
|
224
|
+
substitution, or a backquoted one, each of which quotes what is inside it in its own
|
|
225
|
+
right. Only the innermost decides how a reference there is written, and getting that
|
|
226
|
+
wrong can only cost a value its word boundaries -- never make it shell source.
|
|
227
|
+
"""
|
|
228
|
+
stack = list(context)
|
|
229
|
+
escaped = skipped = False
|
|
230
|
+
for index, character in enumerate(text):
|
|
231
|
+
inner = stack[-1] if stack else ""
|
|
232
|
+
if skipped or escaped:
|
|
233
|
+
skipped = escaped = False
|
|
234
|
+
elif inner == "'":
|
|
235
|
+
if character == "'":
|
|
236
|
+
stack.pop()
|
|
237
|
+
elif character == "\\":
|
|
238
|
+
escaped = True
|
|
239
|
+
elif inner in '"`' and character == inner:
|
|
240
|
+
stack.pop()
|
|
241
|
+
elif character == "$" and text[index + 1 : index + 2] == "(":
|
|
242
|
+
stack.append("(")
|
|
243
|
+
skipped = True
|
|
244
|
+
elif character == "`" or (character in "'\"" and inner != '"'):
|
|
245
|
+
stack.append(character)
|
|
246
|
+
elif character == ")" and inner == "(":
|
|
247
|
+
stack.pop()
|
|
248
|
+
return tuple(stack)
|
|
165
249
|
|
|
166
250
|
|
|
167
251
|
def _as_text(value: JsonValue) -> str:
|
|
@@ -399,7 +399,10 @@ async def cancel_run(
|
|
|
399
399
|
raised and the attempt reaches its terminal state either way.
|
|
400
400
|
"""
|
|
401
401
|
moment = now or utcnow()
|
|
402
|
-
# The lock
|
|
402
|
+
# The pipeline lock first, because this frees the concurrency slot at the end and taking
|
|
403
|
+
# it there would meet a run creation holding it and waiting for this very run.
|
|
404
|
+
await lock_pipeline(session, run.pipeline_id)
|
|
405
|
+
# The run lock comes before the terminal check, not after. Without it, a cancel racing the
|
|
403
406
|
# outcome transaction that settles the run's last attempt reads "still running" and then
|
|
404
407
|
# overwrites an already-succeeded run with "cancelled".
|
|
405
408
|
await lock_run(session, run.id)
|
|
@@ -512,10 +515,16 @@ async def promote_queued_run(session: AsyncSession, services: EngineServices, pi
|
|
|
512
515
|
|
|
513
516
|
A run settling frees the slot only if it held it: cancelling a held run settles a run that
|
|
514
517
|
never did, and releasing there would run two of a ``queue`` pipeline at once.
|
|
518
|
+
|
|
519
|
+
Releasing and creating are the same read-then-decide-then-write over one slot, so both
|
|
520
|
+
make it under the pipeline lock. Without it a creation that reads the settling run as
|
|
521
|
+
still active writes a held run this call cannot see, and that run waits for a sibling
|
|
522
|
+
that has already gone.
|
|
515
523
|
"""
|
|
516
524
|
# The caller's own writes decide whether the slot is free, and the session does not
|
|
517
525
|
# autoflush, so the run and attempts read below would otherwise be the ones on disk.
|
|
518
526
|
await session.flush()
|
|
527
|
+
await lock_pipeline(session, pipeline_id)
|
|
519
528
|
held: list[Run] = []
|
|
520
529
|
for run in await active_runs(session, pipeline_id):
|
|
521
530
|
if await _occupies_slot(session, run):
|
|
@@ -261,10 +261,13 @@ def derive_run_status(states: dict[str, StepState], *, cancelled: bool = False)
|
|
|
261
261
|
|
|
262
262
|
|
|
263
263
|
async def lock_run(session: AsyncSession, run_id: UUID) -> None:
|
|
264
|
-
"""Serialise the
|
|
264
|
+
"""Serialise the transactions of one run, so its status is derived once.
|
|
265
265
|
|
|
266
266
|
Without this, two workers settling the run's last two attempts each read the other as
|
|
267
|
-
still in flight and neither concludes the run finished.
|
|
267
|
+
still in flight and neither concludes the run finished. An attempt's log flush takes it
|
|
268
|
+
too: an entry's id is taken when its row is inserted and readable only once its
|
|
269
|
+
transaction commits, so two writers that do not exclude each other can leave a stream
|
|
270
|
+
paging by id past an entry that was still in flight. PostgreSQL only.
|
|
268
271
|
"""
|
|
269
272
|
if session.get_bind().dialect.name != "postgresql":
|
|
270
273
|
return
|
|
@@ -272,10 +275,16 @@ async def lock_run(session: AsyncSession, run_id: UUID) -> None:
|
|
|
272
275
|
|
|
273
276
|
|
|
274
277
|
async def lock_pipeline(session: AsyncSession, pipeline_id: UUID) -> None:
|
|
275
|
-
"""Serialise the
|
|
278
|
+
"""Serialise the concurrency-slot decisions of one pipeline, so its policy holds.
|
|
276
279
|
|
|
277
|
-
``skip`` and ``queue`` are read-then-decide-then-write across processes
|
|
278
|
-
as :func:`lock_run` is.
|
|
280
|
+
``skip`` and ``queue`` are read-then-decide-then-write across processes, and so is
|
|
281
|
+
releasing the run one of them held. PostgreSQL only, as :func:`lock_run` is.
|
|
282
|
+
|
|
283
|
+
A transaction that takes both locks takes this one first: creating a run, retrying a
|
|
284
|
+
step, and cancelling all do. The settle path is the single exception, because it holds
|
|
285
|
+
the run lock from its first statement and reaches the pipeline only at the end, when the
|
|
286
|
+
run it settled frees the slot. That leaves one cycle of two, which PostgreSQL breaks and
|
|
287
|
+
``with_deadlock_retry`` runs again.
|
|
279
288
|
"""
|
|
280
289
|
if session.get_bind().dialect.name != "postgresql":
|
|
281
290
|
return
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|