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.
Files changed (56) hide show
  1. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/PKG-INFO +4 -4
  2. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/pyproject.toml +4 -4
  3. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/pyproject.toml.orig +4 -4
  4. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/alerting.py +37 -1
  5. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/engine/executor.py +5 -1
  6. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/engine/references.py +112 -28
  7. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/engine/runs.py +10 -1
  8. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/engine/state.py +14 -5
  9. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/LICENSE +0 -0
  10. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/README.md +0 -0
  11. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/__init__.py +0 -0
  12. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/alembic/env.py +0 -0
  13. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/alembic/script.py.mako +0 -0
  14. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/alembic/versions/0001_baseline_schema.py +0 -0
  15. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/artifacts.py +0 -0
  16. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/auth.py +0 -0
  17. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/blockdocs.py +0 -0
  18. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/config.py +0 -0
  19. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/configdocs.py +0 -0
  20. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/database.py +0 -0
  21. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/directory.py +0 -0
  22. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/documents.py +0 -0
  23. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/documentschema.py +0 -0
  24. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/engine/__init__.py +0 -0
  25. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/engine/claim.py +0 -0
  26. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/engine/context.py +0 -0
  27. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/engine/definition.py +0 -0
  28. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/engine/failure.py +0 -0
  29. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/engine/recovery.py +0 -0
  30. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/engine/services.py +0 -0
  31. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/examples.py +0 -0
  32. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/ids.py +0 -0
  33. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/logging.py +0 -0
  34. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/migrations.py +0 -0
  35. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/models.py +0 -0
  36. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/pipelines.py +0 -0
  37. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/plugins.py +0 -0
  38. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/protocol.py +0 -0
  39. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/py.typed +0 -0
  40. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/ratelimit.py +0 -0
  41. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/registry.py +0 -0
  42. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/reporting.py +0 -0
  43. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/retention.py +0 -0
  44. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/scheduler.py +0 -0
  45. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/schemas.py +0 -0
  46. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/secrets.py +0 -0
  47. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/storage.py +0 -0
  48. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/telemetry.py +0 -0
  49. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/trigger_documents.py +0 -0
  50. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/triggers/__init__.py +0 -0
  51. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/triggers/backfill.py +0 -0
  52. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/triggers/materialize.py +0 -0
  53. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/triggers/schedules.py +0 -0
  54. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/triggers/webhooks.py +0 -0
  55. {dirigent_core-0.16.2 → dirigent_core-0.16.3}/src/dirigent_core/types.py +0 -0
  56. {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.2
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.2
14
- Requires-Dist: dirigent-common==0.16.2
15
- Requires-Dist: dirigent-plugin==0.16.2
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.2"
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.2",
17
- "dirigent-common==0.16.2",
18
- "dirigent-plugin==0.16.2",
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.2"
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.2",
17
- "dirigent-common==0.16.2",
18
- "dirigent-plugin==0.16.2",
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 shlex
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, *, quote: bool = False) -> JsonValue:
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, quote=quote)
95
+ return _resolve_string(value, scope)
96
96
  case list():
97
- return [resolve(element, scope, quote=quote) for element in value]
97
+ return [resolve(element, scope) for element in value]
98
98
  case dict():
99
- return {key: resolve(element, scope, quote=quote) for key, element in value.items()}
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: Container[str] = frozenset()) -> JsonMap:
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``. In those, every
109
- substituted value is shell-quoted, so a parameter that arrived in a webhook payload
110
- becomes exactly one word; the metacharacters the pipeline author typed keep their meaning.
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
- return {key: resolve(value, scope, quote=key in shell_fields) for key, value in config.items()}
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
- dollars, reference = match.group(1), match.group(2)
128
- literal = "$" * (len(dollars) // 2)
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, *, quote: bool = False) -> JsonValue:
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 and not quote:
171
+ if whole is not None:
157
172
  return lookup(whole.group(1).strip(), scope)
158
- render = _as_shell_word if quote else _as_text
159
- return substitute(value, lambda reference: render(lookup(reference.strip(), scope)))
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
- def _as_shell_word(value: JsonValue) -> str:
163
- """Render a resolved value as exactly one word for a shell, whatever it contains."""
164
- return shlex.quote(_as_text(value))
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 comes before the terminal check, not after. Without it, a cancel racing the
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 outcome transactions of one run, so its status is derived once.
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. PostgreSQL only.
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 run-creation decisions of one pipeline, so its concurrency policy holds.
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. PostgreSQL only,
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