dirigent-core 0.17.2__tar.gz → 0.17.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 (58) hide show
  1. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/PKG-INFO +4 -4
  2. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/pyproject.toml +4 -4
  3. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/pyproject.toml.orig +4 -4
  4. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/alembic/versions/0001_baseline_schema.py +3 -0
  5. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/engine/claim.py +3 -0
  6. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/engine/context.py +36 -2
  7. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/engine/executor.py +32 -5
  8. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/models.py +4 -0
  9. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/LICENSE +0 -0
  10. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/README.md +0 -0
  11. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/__init__.py +0 -0
  12. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/alembic/env.py +0 -0
  13. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/alembic/script.py.mako +0 -0
  14. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/alerting.py +0 -0
  15. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/artifacts.py +0 -0
  16. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/auth.py +0 -0
  17. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/blockdocs.py +0 -0
  18. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/config.py +0 -0
  19. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/configdocs.py +0 -0
  20. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/database.py +0 -0
  21. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/directory.py +0 -0
  22. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/documents.py +0 -0
  23. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/documentschema.py +0 -0
  24. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/engine/__init__.py +0 -0
  25. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/engine/definition.py +0 -0
  26. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/engine/failure.py +0 -0
  27. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/engine/recovery.py +0 -0
  28. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/engine/references.py +0 -0
  29. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/engine/runs.py +0 -0
  30. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/engine/services.py +0 -0
  31. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/engine/state.py +0 -0
  32. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/errors.py +0 -0
  33. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/examples.py +0 -0
  34. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/ids.py +0 -0
  35. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/logging.py +0 -0
  36. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/messages.py +0 -0
  37. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/migrations.py +0 -0
  38. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/pipelines.py +0 -0
  39. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/plugins.py +0 -0
  40. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/protocol.py +0 -0
  41. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/py.typed +0 -0
  42. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/ratelimit.py +0 -0
  43. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/registry.py +0 -0
  44. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/reporting.py +0 -0
  45. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/retention.py +0 -0
  46. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/scheduler.py +0 -0
  47. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/schemas.py +0 -0
  48. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/secrets.py +0 -0
  49. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/storage.py +0 -0
  50. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/telemetry.py +0 -0
  51. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/trigger_documents.py +0 -0
  52. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/triggers/__init__.py +0 -0
  53. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/triggers/backfill.py +0 -0
  54. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/triggers/materialize.py +0 -0
  55. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/triggers/schedules.py +0 -0
  56. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/triggers/webhooks.py +0 -0
  57. {dirigent_core-0.17.2 → dirigent_core-0.17.3}/src/dirigent_core/types.py +0 -0
  58. {dirigent_core-0.17.2 → dirigent_core-0.17.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.17.2
3
+ Version: 0.17.3
4
4
  Summary: Dirigent engine core: schema, configuration, and plugin host.
5
5
  License-Expression: LicenseRef-Proprietary
6
6
  License-File: LICENSE
@@ -12,9 +12,9 @@ Requires-Dist: argon2-cffi>=25.1.0
12
12
  Requires-Dist: asyncpg>=0.31.0
13
13
  Requires-Dist: cronsim>=2.7
14
14
  Requires-Dist: cryptography>=46.0.5
15
- Requires-Dist: dirigent-client==0.17.2
16
- Requires-Dist: dirigent-common==0.17.2
17
- Requires-Dist: dirigent-plugin==0.17.2
15
+ Requires-Dist: dirigent-client==0.17.3
16
+ Requires-Dist: dirigent-common==0.17.3
17
+ Requires-Dist: dirigent-plugin==0.17.3
18
18
  Requires-Dist: jsonschema>=4.26.0
19
19
  Requires-Dist: opentelemetry-api>=1.44.0
20
20
  Requires-Dist: pydantic-settings>=2.15.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-core"
3
- version = "0.17.2"
3
+ version = "0.17.3"
4
4
  description = "Dirigent engine core: schema, configuration, and plugin host."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -17,9 +17,9 @@ dependencies = [
17
17
  "asyncpg>=0.31.0",
18
18
  "cronsim>=2.7",
19
19
  "cryptography>=46.0.5",
20
- "dirigent-client==0.17.2",
21
- "dirigent-common==0.17.2",
22
- "dirigent-plugin==0.17.2",
20
+ "dirigent-client==0.17.3",
21
+ "dirigent-common==0.17.3",
22
+ "dirigent-plugin==0.17.3",
23
23
  "jsonschema>=4.26.0",
24
24
  "opentelemetry-api>=1.44.0",
25
25
  "pydantic-settings>=2.15.0",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-core"
3
- version = "0.17.2"
3
+ version = "0.17.3"
4
4
  description = "Dirigent engine core: schema, configuration, and plugin host."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -17,9 +17,9 @@ dependencies = [
17
17
  "asyncpg>=0.31.0",
18
18
  "cronsim>=2.7",
19
19
  "cryptography>=46.0.5",
20
- "dirigent-client==0.17.2",
21
- "dirigent-common==0.17.2",
22
- "dirigent-plugin==0.17.2",
20
+ "dirigent-client==0.17.3",
21
+ "dirigent-common==0.17.3",
22
+ "dirigent-plugin==0.17.3",
23
23
  "jsonschema>=4.26.0",
24
24
  "opentelemetry-api>=1.44.0",
25
25
  "pydantic-settings>=2.15.0",
@@ -808,6 +808,9 @@ def upgrade() -> None:
808
808
  sa.Column(
809
809
  "remote_handle", sa.JSON().with_variant(postgresql.JSONB(astext_type=Text()), "postgresql"), nullable=True
810
810
  ),
811
+ sa.Column(
812
+ "fetched_output", sa.JSON().with_variant(postgresql.JSONB(astext_type=Text()), "postgresql"), nullable=True
813
+ ),
811
814
  sa.Column(
812
815
  "poke_cursor", sa.JSON().with_variant(postgresql.JSONB(astext_type=Text()), "postgresql"), nullable=True
813
816
  ),
@@ -60,6 +60,9 @@ class ClaimedUnit(BaseModel):
60
60
  """The run's trace context, so an attempt's span joins the trace the run started in."""
61
61
 
62
62
  remote_handle: RemoteHandle | None = None
63
+ fetched_output: JsonMap | None = None
64
+ """The result a previous call already fetched and committed, which settles this claim."""
65
+
63
66
  poke_cursor: JsonMap | None = None
64
67
  """The cursor the last committed poke returned, handed to the next one."""
65
68
 
@@ -6,7 +6,8 @@ long step is visible working and its last lines still land with the outcome.
6
6
  """
7
7
 
8
8
  import asyncio
9
- from collections.abc import Mapping
9
+ from collections.abc import AsyncGenerator, Mapping
10
+ from contextlib import AbstractAsyncContextManager, asynccontextmanager
10
11
  from datetime import UTC, datetime, timedelta
11
12
  from fnmatch import fnmatch
12
13
  from pathlib import Path
@@ -26,7 +27,7 @@ from dirigent_core.messages import RUN_UNKNOWN_CONNECTION, RUN_UNKNOWN_SCHEMA, U
26
27
  from dirigent_core.models import Connection, Schema
27
28
  from dirigent_core.secrets import SecretBox
28
29
  from dirigent_core.storage import AttemptStorage, Storage, connection_binder, work_dir
29
- from dirigent_plugin import BlockFailure, ConnectionRef, ErrorClass, Logger, Runs
30
+ from dirigent_plugin import BlockFailure, ByteSink, Capture, ConnectionRef, ErrorClass, Logger, Runs
30
31
 
31
32
  #: What an HTTP connection is assumed to call its fields, so core can build a client for
32
33
  #: any connection kind without importing the package that contributed it.
@@ -230,6 +231,19 @@ class BufferedLogger(BaseModel):
230
231
  self.full.set()
231
232
 
232
233
 
234
+ class CapturedStream:
235
+ """A sink that carries the URI the engine opened it under, for a block to put in its output."""
236
+
237
+ def __init__(self, uri: str, sink: ByteSink) -> None:
238
+ """Bind the storage sink to the URI naming it."""
239
+ self.uri = uri
240
+ self._sink = sink
241
+
242
+ async def write(self, data: bytes) -> int:
243
+ """Append bytes to the stream and return how many were accepted."""
244
+ return await self._sink.write(data)
245
+
246
+
233
247
  class EngineStepContext:
234
248
  """The concrete StepContext the engine hands a block for exactly one call."""
235
249
 
@@ -297,6 +311,26 @@ class EngineStepContext:
297
311
  """Return the run-scoped URI prefix for intermediate artifacts."""
298
312
  return self._scratch
299
313
 
314
+ def capture(self, name: str, *, content_type: str = "text/plain") -> AbstractAsyncContextManager[Capture]:
315
+ """Open the storage object a block's named stream is written to, under the run's scratch."""
316
+ uri = f"{self._scratch.rstrip('/')}/{self._segment()}-{name}"
317
+
318
+ @asynccontextmanager
319
+ async def opened() -> AsyncGenerator[Capture]:
320
+ async with self.storage.open_write(uri, content_type=content_type) as sink:
321
+ yield CapturedStream(uri, sink)
322
+
323
+ return opened()
324
+
325
+ def _segment(self) -> str:
326
+ """The path this attempt's captures sit under: the step, the fan-out item, and the attempt.
327
+
328
+ ``{step}[/{item}]/attempt-{n}``. The step key is unique within the run and the item id
329
+ within a fan-out, so no two attempts of one run write over each other's streams.
330
+ """
331
+ item = f"/{self.run_item_id}" if self.run_item_id is not None else ""
332
+ return f"{self.step}{item}/attempt-{self.attempt}"
333
+
300
334
  @property
301
335
  def work(self) -> Path:
302
336
  """Return the run's directory on this worker's own filesystem, made on first read.
@@ -1,9 +1,10 @@
1
1
  """The engine proper: claim, call one block, record the outcome, ready the dependents.
2
2
 
3
3
  Two invariants carry everything here. One transaction per state transition, so there is no
4
- window in which a step is finished but its dependents have not been told. And a remote
5
- handle is committed on its own the moment ``execute`` returns it, so submit-then-crash
6
- recovers into polling rather than into a duplicate job.
4
+ window in which a step is finished but its dependents have not been told. And what a remote
5
+ call hands back is committed on its own the moment it arrives -- the handle ``execute``
6
+ returns, and the result ``fetch`` returns -- so a crash recovers into polling or settling
7
+ rather than into a duplicate job or a second fetch.
7
8
  """
8
9
 
9
10
  import asyncio
@@ -97,6 +98,8 @@ class Produced(BaseModel):
97
98
  model_config = ConfigDict(frozen=True)
98
99
 
99
100
  output: JsonMap
101
+ fetched: bool = False
102
+ """True when a fetch returned this output and nothing has committed it yet."""
100
103
 
101
104
 
102
105
  class Submitted(BaseModel):
@@ -320,6 +323,7 @@ class Engine:
320
323
  schemas=await load_schemas(session),
321
324
  traceparent=run.traceparent,
322
325
  remote_handle=RemoteHandle.model_validate(attempt.remote_handle) if attempt.remote_handle else None,
326
+ fetched_output=attempt.fetched_output,
323
327
  poke_cursor=attempt.poke_cursor,
324
328
  deadline_at=attempt.deadline_at,
325
329
  gone_probes=attempt.gone_probes,
@@ -376,6 +380,8 @@ class Engine:
376
380
  telemetry.record_failure(span, result.failure.message)
377
381
  if isinstance(result, Submitted):
378
382
  await self.record_handle(unit, result.handle)
383
+ if isinstance(result, Produced) and result.fetched:
384
+ await self._record_fetched(unit, result.output)
379
385
  await self._record(
380
386
  unit,
381
387
  result,
@@ -440,6 +446,8 @@ class Engine:
440
446
  unit: ClaimedUnit,
441
447
  ) -> CallResult:
442
448
  """Drive an operator's half of the contract: execute once, then probe and fetch."""
449
+ if unit.fetched_output is not None:
450
+ return Produced(output=unit.fetched_output)
443
451
  if not unit.is_probe or unit.remote_handle is None:
444
452
  with self._timed(unit.block_id, "execute"):
445
453
  produced = await operator.execute(config, context)
@@ -460,8 +468,8 @@ class Engine:
460
468
  )
461
469
  case ProbeStatus.SUCCEEDED:
462
470
  with self._timed(unit.block_id, "fetch"):
463
- fetched = await operator.fetch(handle, config, context)
464
- return Produced(output=fetched.model_dump(mode="json"))
471
+ retrieved = await operator.fetch(handle, config, context)
472
+ return Produced(output=retrieved.model_dump(mode="json"), fetched=True)
465
473
  case ProbeStatus.FAILED:
466
474
  failed = (
467
475
  Failure.unknown(REMOTE_JOB_SAID, detail=probe.message)
@@ -532,6 +540,24 @@ class Engine:
532
540
  return
533
541
  attempt.remote_handle = handle.model_dump(mode="json")
534
542
 
543
+ async def _record_fetched(self, unit: ClaimedUnit, output: JsonMap) -> None:
544
+ """Commit a fetched result on its own, before the outcome that settles it."""
545
+ await with_deadlock_retry(lambda: self._record_fetched_once(unit, output))
546
+
547
+ async def _record_fetched_once(self, unit: ClaimedUnit, output: JsonMap) -> None:
548
+ """Run that transaction once; the caller runs it again after a deadlock.
549
+
550
+ It locks the run and then writes the attempt, the order every outcome uses and the
551
+ opposite of the claim's. Nothing of it survives a rollback, so it is safe to repeat.
552
+ """
553
+ async with session_scope(self.sessions) as session:
554
+ await lock_run(session, unit.run_id)
555
+ attempt = await session.get(StepAttempt, unit.attempt_id)
556
+ if attempt is None or not self._holds_lease(unit, attempt):
557
+ self._log_lost_lease(unit, attempt, "fetched result")
558
+ return
559
+ attempt.fetched_output = output
560
+
535
561
  async def _cancel_what_was_just_submitted(
536
562
  self,
537
563
  session: AsyncSession,
@@ -746,6 +772,7 @@ class Engine:
746
772
  )
747
773
  attempt.status = AttemptStatus.SUCCEEDED
748
774
  attempt.finished_at = now
775
+ attempt.fetched_output = None
749
776
  attempt.error = None
750
777
  attempt.error_code = None
751
778
  attempt.error_params = None
@@ -288,6 +288,10 @@ class StepAttempt(Entity):
288
288
 
289
289
  remote_handle: Mapped[JsonMap | None] = mapped_column(JsonDocument)
290
290
 
291
+ fetched_output: Mapped[JsonMap | None] = mapped_column(JsonDocument)
292
+ """The result a fetch returned, committed before the outcome so a worker that dies
293
+ between the two settles from it rather than fetching again."""
294
+
291
295
  poke_cursor: Mapped[JsonMap | None] = mapped_column(JsonDocument)
292
296
  """How far a sensor's poke has read, handed to the next poke as ``ctx.cursor``."""
293
297
 
File without changes
File without changes