dirigent-core 0.15.2__tar.gz → 0.16.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/PKG-INFO +1 -1
  2. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/pyproject.toml +1 -1
  3. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/pyproject.toml.orig +1 -1
  4. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/alembic/versions/0001_baseline_schema.py +2 -0
  5. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/alerting.py +65 -22
  6. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/engine/claim.py +3 -0
  7. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/engine/context.py +5 -10
  8. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/engine/executor.py +30 -12
  9. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/engine/recovery.py +1 -0
  10. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/engine/runs.py +1 -0
  11. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/engine/services.py +23 -1
  12. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/models.py +12 -0
  13. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/reporting.py +2 -1
  14. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/scheduler.py +2 -1
  15. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/storage.py +41 -8
  16. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/worker.py +6 -6
  17. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/LICENSE +0 -0
  18. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/README.md +0 -0
  19. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/__init__.py +0 -0
  20. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/alembic/env.py +0 -0
  21. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/alembic/script.py.mako +0 -0
  22. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/artifacts.py +0 -0
  23. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/auth.py +0 -0
  24. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/blockdocs.py +0 -0
  25. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/config.py +0 -0
  26. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/configdocs.py +0 -0
  27. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/database.py +0 -0
  28. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/directory.py +0 -0
  29. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/documents.py +0 -0
  30. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/documentschema.py +0 -0
  31. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/engine/__init__.py +0 -0
  32. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/engine/definition.py +0 -0
  33. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/engine/failure.py +0 -0
  34. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/engine/references.py +0 -0
  35. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/engine/state.py +0 -0
  36. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/examples.py +0 -0
  37. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/ids.py +0 -0
  38. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/logging.py +0 -0
  39. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/migrations.py +0 -0
  40. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/pipelines.py +0 -0
  41. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/plugins.py +0 -0
  42. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/protocol.py +0 -0
  43. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/py.typed +0 -0
  44. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/ratelimit.py +0 -0
  45. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/registry.py +0 -0
  46. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/retention.py +0 -0
  47. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/schemas.py +0 -0
  48. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/secrets.py +0 -0
  49. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/telemetry.py +0 -0
  50. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/trigger_documents.py +0 -0
  51. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/triggers/__init__.py +0 -0
  52. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/triggers/backfill.py +0 -0
  53. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/triggers/materialize.py +0 -0
  54. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/triggers/schedules.py +0 -0
  55. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/triggers/webhooks.py +0 -0
  56. {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirigent-core
3
- Version: 0.15.2
3
+ Version: 0.16.0
4
4
  Summary: Dirigent engine core: schema, configuration, and plugin host.
5
5
  License-Expression: LicenseRef-Proprietary
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-core"
3
- version = "0.15.2"
3
+ version = "0.16.0"
4
4
  description = "Dirigent engine core: schema, configuration, and plugin host."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dirigent-core"
3
- version = "0.15.2"
3
+ version = "0.16.0"
4
4
  description = "Dirigent engine core: schema, configuration, and plugin host."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -607,6 +607,7 @@ def upgrade() -> None:
607
607
  sa.Column("attempt", sa.Integer(), server_default=sa.text("0"), nullable=False),
608
608
  sa.Column("available_at", dirigent_core.types.UtcDateTime(timezone=True), nullable=False),
609
609
  sa.Column("lease_owner", sa.String(length=200), nullable=True),
610
+ sa.Column("lease_token", sa.Uuid(), nullable=True),
610
611
  sa.Column("lease_expires_at", dirigent_core.types.UtcDateTime(timezone=True), nullable=True),
611
612
  sa.Column("sent_at", dirigent_core.types.UtcDateTime(timezone=True), nullable=True),
612
613
  sa.Column("error", sa.Text(), nullable=True),
@@ -799,6 +800,7 @@ def upgrade() -> None:
799
800
  sa.Column("next_poll_at", dirigent_core.types.UtcDateTime(timezone=True), nullable=True),
800
801
  sa.Column("deadline_at", dirigent_core.types.UtcDateTime(timezone=True), nullable=True),
801
802
  sa.Column("lease_owner", sa.String(length=200), nullable=True),
803
+ sa.Column("lease_token", sa.Uuid(), nullable=True),
802
804
  sa.Column("lease_expires_at", dirigent_core.types.UtcDateTime(timezone=True), nullable=True),
803
805
  sa.Column("heartbeat_at", dirigent_core.types.UtcDateTime(timezone=True), nullable=True),
804
806
  sa.Column(
@@ -30,6 +30,7 @@ from dirigent_common import (
30
30
  from dirigent_core.database import session_scope
31
31
  from dirigent_core.engine.definition import load_definition
32
32
  from dirigent_core.engine.services import EngineServices
33
+ from dirigent_core.ids import uuid7
33
34
  from dirigent_core.logging import get_logger
34
35
  from dirigent_core.models import (
35
36
  AlertRule,
@@ -608,13 +609,23 @@ async def queue_test_message(
608
609
  return notification
609
610
 
610
611
 
612
+ class ClaimedNotification(BaseModel):
613
+ """A notification one claim holds, and the generation that claim took."""
614
+
615
+ model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True)
616
+
617
+ notification: Notification
618
+ lease_token: UUID
619
+ """The generation this claim took, which every later write of the row is fenced against."""
620
+
621
+
611
622
  async def claim_notification(
612
623
  session: AsyncSession,
613
624
  *,
614
625
  owner: str,
615
626
  now: datetime,
616
627
  lease_seconds: int,
617
- ) -> Notification | None:
628
+ ) -> ClaimedNotification | None:
618
629
  """Claim the next due notification, the same way a worker claims an attempt."""
619
630
  statement = (
620
631
  sa.select(Notification)
@@ -628,12 +639,14 @@ async def claim_notification(
628
639
  notification = found.scalars().first()
629
640
  if notification is None:
630
641
  return None
642
+ token = uuid7()
631
643
  notification.status = NotificationStatus.SENDING
632
644
  notification.lease_owner = owner
645
+ notification.lease_token = token
633
646
  notification.lease_expires_at = now + timedelta(seconds=lease_seconds)
634
647
  notification.attempt += 1
635
648
  await session.flush()
636
- return notification
649
+ return ClaimedNotification(notification=notification, lease_token=token)
637
650
 
638
651
 
639
652
  def notifier_config(services: EngineServices, notifier: Notifier, connection: Connection | None) -> BaseModel:
@@ -651,12 +664,13 @@ async def send_notification(
651
664
  notification: Notification,
652
665
  *,
653
666
  owner: str,
667
+ lease_token: UUID,
654
668
  now: datetime | None = None,
655
669
  ) -> bool:
656
670
  """Deliver one claimed notification, returning whether it was delivered.
657
671
 
658
- The outcome is written only while this worker still holds the lease, so a delivery that
659
- outlasted its lease cannot overwrite the outcome of the worker the row was handed to.
672
+ The outcome is written only while this claim still holds the lease, so a delivery that
673
+ outlasted its lease cannot overwrite the outcome of the claim the row was handed to.
660
674
 
661
675
  THE SESSION IS COMMITTED BEFORE THE SEND, so no transaction is open while an outbound
662
676
  call is in flight: on SQLite that transaction holds the one write lock, and everything
@@ -674,33 +688,42 @@ async def send_notification(
674
688
  await session.commit()
675
689
  await notifier.send(_message(notification), config)
676
690
  except Exception as error:
677
- if not await _holds_lease(session, notification, owner):
691
+ if not await _holds_lease(session, notification, owner, lease_token):
678
692
  return False
679
693
  return await _record_failure(session, services, notification, error, moment)
680
- if not await _holds_lease(session, notification, owner):
694
+ if not await _holds_lease(session, notification, owner, lease_token):
681
695
  return False
682
696
  notification.status = NotificationStatus.SENT
683
697
  notification.sent_at = moment
684
698
  notification.error = None
685
699
  notification.lease_owner = None
700
+ notification.lease_token = None
686
701
  notification.lease_expires_at = None
687
702
  _timeline(session, notification, LogLevel.INFO, f"alert delivered through {notification.notifier!r}", moment)
688
703
  _logger.info("notification delivered", notification_id=str(notification.id), notifier=notification.notifier)
689
704
  return True
690
705
 
691
706
 
692
- async def _holds_lease(session: AsyncSession, notification: Notification, owner: str) -> bool:
693
- """Re-read the row and report whether this worker may still write this outcome.
707
+ async def _holds_lease(session: AsyncSession, notification: Notification, owner: str, lease_token: UUID) -> bool:
708
+ """Re-read the row and report whether this claim may still write this outcome.
694
709
 
695
710
  The lease is a fence, not a hint: a row the sweeper returned to the queue belongs to
696
711
  whoever claimed it next, and writing into it would replace a live delivery's outcome
697
- with the outcome of an abandoned one.
712
+ with the outcome of an abandoned one. The token is what makes that hold when the
713
+ somebody else is this worker again: a name matches its own next claim, a token never does.
698
714
  """
699
715
  found = await session.execute(
700
- sa.select(Notification.status, Notification.lease_owner).where(Notification.id == notification.id)
716
+ sa.select(Notification.status, Notification.lease_owner, Notification.lease_token).where(
717
+ Notification.id == notification.id
718
+ )
701
719
  )
702
720
  row = found.one_or_none()
703
- if row is not None and row.status is NotificationStatus.SENDING and row.lease_owner == owner:
721
+ if (
722
+ row is not None
723
+ and row.status is NotificationStatus.SENDING
724
+ and row.lease_owner == owner
725
+ and row.lease_token == lease_token
726
+ ):
704
727
  return True
705
728
  _logger.warning(
706
729
  "lease lost, notification outcome discarded",
@@ -717,10 +740,16 @@ async def renew_lease(
717
740
  notification_id: UUID,
718
741
  *,
719
742
  owner: str,
743
+ lease_token: UUID,
720
744
  lease_seconds: int,
721
745
  now: datetime | None = None,
722
746
  ) -> bool:
723
- """Push a notification's lease out, reporting whether this worker still held it."""
747
+ """Push a notification's lease out, reporting whether this claim still held it.
748
+
749
+ A renewal names the claim's generation as well as its row, so one that outlived its lease
750
+ extends nothing: the row it names is either unclaimed or held by a later claim, this
751
+ worker's own next one included.
752
+ """
724
753
  moment = now or utcnow()
725
754
  renewed = await session.execute(
726
755
  sa.update(Notification)
@@ -728,6 +757,7 @@ async def renew_lease(
728
757
  Notification.id == notification_id,
729
758
  Notification.status == NotificationStatus.SENDING,
730
759
  Notification.lease_owner == owner,
760
+ Notification.lease_token == lease_token,
731
761
  )
732
762
  .values(lease_expires_at=moment + timedelta(seconds=lease_seconds))
733
763
  .returning(Notification.id)
@@ -762,6 +792,7 @@ async def _record_failure(
762
792
  message = f"{type(error).__name__}: {error}"
763
793
  notification.error = message
764
794
  notification.lease_owner = None
795
+ notification.lease_token = None
765
796
  notification.lease_expires_at = None
766
797
  budget = services.settings.notification_max_attempts
767
798
  if notification.attempt >= budget:
@@ -873,6 +904,7 @@ async def retry_notification(
873
904
  notification.error = None
874
905
  notification.sent_at = None
875
906
  notification.lease_owner = None
907
+ notification.lease_token = None
876
908
  notification.lease_expires_at = None
877
909
  await session.flush()
878
910
  _logger.info("notification queued again", notification_id=str(notification.id), notifier=notification.notifier)
@@ -898,6 +930,7 @@ async def recover_notifications(session: AsyncSession, *, now: datetime | None =
898
930
  status=NotificationStatus.PENDING,
899
931
  available_at=moment,
900
932
  lease_owner=None,
933
+ lease_token=None,
901
934
  lease_expires_at=None,
902
935
  )
903
936
  .returning(Notification.id)
@@ -926,37 +959,40 @@ class NotificationDispatcher(BaseModel):
926
959
  for _ in range(self.max_per_pass):
927
960
  async with session_scope(self.sessions) as session:
928
961
  moment = now or utcnow()
929
- notification = await claim_notification(
962
+ claim = await claim_notification(
930
963
  session,
931
964
  owner=self.owner,
932
965
  now=moment,
933
966
  lease_seconds=int(self.services.settings.notification_lease.total_seconds()),
934
967
  )
935
- if notification is None:
968
+ if claim is None:
936
969
  return sent
970
+ notification_id, lease_token = claim.notification.id, claim.lease_token
937
971
  async with session_scope(self.sessions) as session:
938
- claimed = await session.get(Notification, notification.id)
972
+ claimed = await session.get(Notification, notification_id)
939
973
  if claimed is None: # pragma: no cover - it was just claimed
940
974
  continue
941
- async with self._renewing(claimed.id):
942
- delivered = await send_notification(session, self.services, claimed, owner=self.owner, now=now)
975
+ async with self._renewing(notification_id, lease_token):
976
+ delivered = await send_notification(
977
+ session, self.services, claimed, owner=self.owner, lease_token=lease_token, now=now
978
+ )
943
979
  if delivered:
944
980
  sent += 1
945
981
  return sent
946
982
 
947
983
  @asynccontextmanager
948
- async def _renewing(self, notification_id: UUID) -> AsyncGenerator[None]:
984
+ async def _renewing(self, notification_id: UUID, lease_token: UUID) -> AsyncGenerator[None]:
949
985
  """Hold a notification's lease open for as long as its delivery is in flight."""
950
986
  halting = asyncio.Event()
951
- renewing = asyncio.create_task(self._renew_until_halted(notification_id, halting))
987
+ renewing = asyncio.create_task(self._renew_until_halted(notification_id, lease_token, halting))
952
988
  try:
953
989
  yield
954
990
  finally:
955
991
  halting.set()
956
992
  await renewing
957
993
 
958
- async def _renew_until_halted(self, notification_id: UUID, halting: asyncio.Event) -> None:
959
- """Extend the lease on a cadence until halted, or until the row is no longer this worker's.
994
+ async def _renew_until_halted(self, notification_id: UUID, lease_token: UUID, halting: asyncio.Event) -> None:
995
+ """Extend the lease on a cadence until halted, or until the row is no longer this claim's.
960
996
 
961
997
  The halt is a flag rather than a cancel: a renewal already inside its transaction runs
962
998
  to its own end, where a cancel landing in a database await strands the session's
@@ -970,7 +1006,14 @@ class NotificationDispatcher(BaseModel):
970
1006
  return
971
1007
  try:
972
1008
  async with session_scope(self.sessions) as session:
973
- if not await renew_lease(session, notification_id, owner=self.owner, lease_seconds=lease_seconds):
1009
+ renewed = await renew_lease(
1010
+ session,
1011
+ notification_id,
1012
+ owner=self.owner,
1013
+ lease_token=lease_token,
1014
+ lease_seconds=lease_seconds,
1015
+ )
1016
+ if not renewed:
974
1017
  return
975
1018
  except Exception as error: # the delivery runs on; the sweeper reclaims if it outlasts the lease
976
1019
  _logger.warning(
@@ -34,6 +34,9 @@ class ClaimedUnit(BaseModel):
34
34
  model_config = ConfigDict(frozen=True)
35
35
 
36
36
  attempt_id: UUID
37
+ lease_token: UUID
38
+ """The generation this claim took, which every later write of it is fenced against."""
39
+
37
40
  run_id: UUID
38
41
  run_item_id: UUID | None
39
42
  step_name: str
@@ -24,8 +24,8 @@ from dirigent_common import JsonMap
24
24
  from dirigent_core.logging import debug_kept, get_logger
25
25
  from dirigent_core.models import Connection, Schema
26
26
  from dirigent_core.secrets import SecretBox
27
- from dirigent_core.storage import AttemptStorage, Storage, work_dir
28
- from dirigent_plugin import BlockFailure, ConnectionRef, ErrorClass, Logger, Runs, StorageBackend
27
+ from dirigent_core.storage import AttemptStorage, Storage, connection_binder, work_dir
28
+ from dirigent_plugin import BlockFailure, ConnectionRef, ErrorClass, Logger, Runs
29
29
 
30
30
  #: What an HTTP connection is assumed to call its fields, so core can build a client for
31
31
  #: any connection kind without importing the package that contributed it.
@@ -304,16 +304,11 @@ class EngineStepContext:
304
304
  where connection secrets are opened and nowhere else.
305
305
  """
306
306
  if self._bound_storage is None:
307
- self._bound_storage = self._storage.bound_by(self._configure_backend)
307
+ self._bound_storage = self._storage.bound_by(
308
+ connection_binder(self._storage_connections, lambda _scheme, ref, model: self.connection(ref, model))
309
+ )
308
310
  return self._bound_storage
309
311
 
310
- def _configure_backend(self, scheme: str, backend: StorageBackend) -> StorageBackend:
311
- """Bind one scheme to the connection this instance configures it from, if it names one."""
312
- ref = self._storage_connections.get(scheme)
313
- if ref is None:
314
- return backend
315
- return backend.configured(self.connection(ref, backend.config_model))
316
-
317
312
  @property
318
313
  def scratch(self) -> str:
319
314
  """Return the run-scoped URI prefix for intermediate artifacts."""
@@ -55,6 +55,7 @@ from dirigent_core.engine.runs import (
55
55
  )
56
56
  from dirigent_core.engine.services import EngineServices
57
57
  from dirigent_core.engine.state import advance, lock_run
58
+ from dirigent_core.ids import uuid7
58
59
  from dirigent_core.logging import get_logger, log_context
59
60
  from dirigent_core.models import LogEntry, PipelineVersion, Run, RunItem, StepAttempt, utcnow
60
61
  from dirigent_core.storage import AttemptStorage, scratch_prefix
@@ -262,7 +263,9 @@ class Engine:
262
263
  attempt.status = AttemptStatus.RUNNING
263
264
  started = attempt.started_at or now
264
265
  attempt.started_at = started
266
+ token = uuid7()
265
267
  attempt.lease_owner = self.owner
268
+ attempt.lease_token = token
266
269
  attempt.lease_expires_at = now + self.services.settings.lease
267
270
  attempt.heartbeat_at = now
268
271
  attempt.input = {**(attempt.input or {}), "config": config}
@@ -274,6 +277,7 @@ class Engine:
274
277
 
275
278
  return ClaimedUnit(
276
279
  attempt_id=attempt.id,
280
+ lease_token=token,
277
281
  run_id=run.id,
278
282
  run_item_id=attempt.run_item_id,
279
283
  step_name=attempt.step_name,
@@ -453,14 +457,20 @@ class Engine:
453
457
 
454
458
  # -- recording ---------------------------------------------------------------
455
459
 
456
- def _holds_lease(self, attempt: StepAttempt) -> bool:
457
- """Report whether this worker may still write the outcome of an attempt.
460
+ def _holds_lease(self, unit: ClaimedUnit, attempt: StepAttempt) -> bool:
461
+ """Report whether this claim may still write the outcome of an attempt.
458
462
 
459
463
  The lease is a fence, not a hint. If the sweeper decided this worker was dead and
460
- handed the attempt to somebody else, the row belongs to that other worker, and
461
- writing into it would clobber a live attempt with the result of an abandoned one.
464
+ handed the attempt to somebody else, the row belongs to that other claim, and writing
465
+ into it would clobber a live attempt with the result of an abandoned one. The token
466
+ is what makes that hold when the somebody else is this worker again: a name matches
467
+ its own next claim, a token never does.
462
468
  """
463
- return attempt.status is AttemptStatus.RUNNING and attempt.lease_owner == self.owner
469
+ return (
470
+ attempt.status is AttemptStatus.RUNNING
471
+ and attempt.lease_owner == self.owner
472
+ and attempt.lease_token == unit.lease_token
473
+ )
464
474
 
465
475
  def _log_lost_lease(self, unit: ClaimedUnit, attempt: StepAttempt | None, what: str) -> None:
466
476
  """Log that an outcome was dropped, which means the work may have run twice."""
@@ -490,7 +500,7 @@ class Engine:
490
500
  if attempt is not None and attempt.status is AttemptStatus.CANCELLED:
491
501
  await self._cancel_what_was_just_submitted(session, unit, attempt, handle)
492
502
  return
493
- if attempt is None or not self._holds_lease(attempt):
503
+ if attempt is None or not self._holds_lease(unit, attempt):
494
504
  self._log_lost_lease(unit, attempt, "remote handle")
495
505
  return
496
506
  attempt.remote_handle = handle.model_dump(mode="json")
@@ -593,7 +603,7 @@ class Engine:
593
603
  attempt = await session.get(StepAttempt, unit.attempt_id)
594
604
  if attempt is None: # pragma: no cover - foreign keys prevent this
595
605
  return
596
- if not self._holds_lease(attempt):
606
+ if not self._holds_lease(unit, attempt):
597
607
  self._log_lost_lease(unit, attempt, "attempt outcome")
598
608
  return
599
609
  if entries:
@@ -879,21 +889,28 @@ class Engine:
879
889
 
880
890
  # -- leases ------------------------------------------------------------------
881
891
 
882
- async def heartbeat(self, attempt_ids: Sequence[UUID], *, now: datetime | None = None) -> set[UUID]:
883
- """Refresh the leases this worker holds, and say which ones it still holds.
892
+ async def heartbeat(self, units: Sequence[ClaimedUnit], *, now: datetime | None = None) -> set[UUID]:
893
+ """Refresh the leases these claims hold, and say which ones they still hold.
894
+
895
+ A claim is named by its attempt and its token, so a call that outlived its lease
896
+ refreshes nothing: the row it names is either unclaimed or held by a later claim,
897
+ this worker's own next one included.
884
898
 
885
899
  An attempt that does not come back was taken away by the sweeper, and the caller has
886
900
  to stop working on it: its outcome will be refused by :meth:`_holds_lease` anyway.
887
901
  """
888
- if not attempt_ids:
902
+ if not units:
889
903
  return set()
890
904
  moment = now or utcnow()
891
905
  expires = moment + self.services.settings.lease
906
+ held_leases = sa.or_(
907
+ *(sa.and_(StepAttempt.id == unit.attempt_id, StepAttempt.lease_token == unit.lease_token) for unit in units)
908
+ )
892
909
  async with session_scope(self.sessions) as session:
893
910
  refreshed = await session.execute(
894
911
  sa.update(StepAttempt)
895
912
  .where(
896
- StepAttempt.id.in_(attempt_ids),
913
+ held_leases,
897
914
  StepAttempt.lease_owner == self.owner,
898
915
  StepAttempt.status == AttemptStatus.RUNNING,
899
916
  )
@@ -905,7 +922,7 @@ class Engine:
905
922
  "leases refreshed",
906
923
  worker=self.owner,
907
924
  held=len(held),
908
- lost=len(attempt_ids) - len(held),
925
+ lost=len(units) - len(held),
909
926
  expires_at=expires.isoformat(),
910
927
  )
911
928
  return held
@@ -914,6 +931,7 @@ class Engine:
914
931
  def _release(attempt: StepAttempt) -> None:
915
932
  """Drop the lease from an attempt that is no longer being worked on."""
916
933
  attempt.lease_owner = None
934
+ attempt.lease_token = None
917
935
  attempt.lease_expires_at = None
918
936
 
919
937
 
@@ -56,6 +56,7 @@ async def sweep_leases(session: AsyncSession, *, now: datetime | None = None) ->
56
56
  attempt.available_at = moment
57
57
  landing = "queued"
58
58
  attempt.lease_owner = None
59
+ attempt.lease_token = None
59
60
  attempt.lease_expires_at = None
60
61
  recovered.append(attempt.id)
61
62
  _logger.warning(
@@ -425,6 +425,7 @@ async def cancel_run(
425
425
  attempt.status = AttemptStatus.CANCELLED
426
426
  attempt.finished_at = moment
427
427
  attempt.lease_owner = None
428
+ attempt.lease_token = None
428
429
  attempt.lease_expires_at = None
429
430
  await _cancel_items(session, run, moment)
430
431
  run.status = RunStatus.CANCELLED
@@ -4,14 +4,16 @@ from functools import cached_property
4
4
 
5
5
  from jsonschema import FormatChecker
6
6
  from pydantic import BaseModel, ConfigDict
7
+ from sqlalchemy.ext.asyncio import AsyncSession
7
8
 
8
9
  from dirigent_client.schemas import BlockKind
9
10
  from dirigent_common import format_checker_with
10
11
  from dirigent_core.config import Settings
12
+ from dirigent_core.engine.context import load_connections
11
13
  from dirigent_core.engine.failure import Failure
12
14
  from dirigent_core.plugins import PluginHost
13
15
  from dirigent_core.secrets import SecretBox
14
- from dirigent_core.storage import Storage, build_storage
16
+ from dirigent_core.storage import AttemptStorage, Storage, UnknownStorageConnection, build_storage, connection_binder
15
17
  from dirigent_plugin import AnyOperator, AnySensor, ErrorClass
16
18
 
17
19
 
@@ -46,6 +48,26 @@ class EngineServices(BaseModel):
46
48
  """The one checker every instance validation asserts against: base plus contributed formats."""
47
49
  return format_checker_with(self.host.formats)
48
50
 
51
+ async def bound_storage(self, session: AsyncSession) -> AttemptStorage:
52
+ """Return storage with every scheme configured from the connection this instance names for it.
53
+
54
+ The configuration a step's context binds, for the paths that address artifacts outside
55
+ a step. ``self.storage`` configures nothing: a scheme reached through it is opened with
56
+ no endpoint and no credentials.
57
+
58
+ The facade caches the credentials its binder opened, so it belongs to the call that
59
+ asked for it, and a connection edited in the database is read by the next call.
60
+ """
61
+ records = await load_connections(session) if self.settings.storage_connections else {}
62
+
63
+ def open_connection(scheme: str, ref: str, model: type[BaseModel]) -> BaseModel:
64
+ record = records.get(ref)
65
+ if record is None:
66
+ raise UnknownStorageConnection(scheme, ref, records)
67
+ return self.secrets.decrypt_config(model, record.config, record.envelope, key_id=record.key_id)
68
+
69
+ return self.storage.bound_by(connection_binder(self.settings.storage_connections, open_connection))
70
+
49
71
  def block(self, block_id: str) -> AnyOperator | AnySensor:
50
72
  """Resolve a block id to the instance the engine calls."""
51
73
  return self.host.block(block_id)
@@ -275,6 +275,12 @@ class StepAttempt(Entity):
275
275
  deadline_at: Mapped[datetime | None] = mapped_column(Timestamp)
276
276
 
277
277
  lease_owner: Mapped[str | None] = mapped_column(sa.String(200))
278
+ lease_token: Mapped[UUID | None] = mapped_column(sa.Uuid)
279
+ """Minted afresh by every claim, so a write names the claim it came from.
280
+
281
+ A worker's name repeats: the same worker can reclaim an attempt the sweeper took from it,
282
+ and the abandoned call's outcome would pass a fence that only compares names."""
283
+
278
284
  lease_expires_at: Mapped[datetime | None] = mapped_column(Timestamp)
279
285
  heartbeat_at: Mapped[datetime | None] = mapped_column(Timestamp)
280
286
 
@@ -516,6 +522,12 @@ class Notification(Entity):
516
522
  attempt: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0, server_default=sa.text("0"))
517
523
  available_at: Mapped[datetime] = mapped_column(Timestamp, nullable=False, default=utcnow)
518
524
  lease_owner: Mapped[str | None] = mapped_column(sa.String(200))
525
+ lease_token: Mapped[UUID | None] = mapped_column(sa.Uuid)
526
+ """Minted afresh by every claim, so a write names the claim it came from.
527
+
528
+ A worker's name repeats: the same worker can reclaim a notification recovery took from it,
529
+ and the abandoned delivery's outcome would pass a fence that only compares names."""
530
+
519
531
  lease_expires_at: Mapped[datetime | None] = mapped_column(Timestamp)
520
532
  sent_at: Mapped[datetime | None] = mapped_column(Timestamp)
521
533
  error: Mapped[str | None] = mapped_column(sa.Text)
@@ -323,10 +323,11 @@ async def render_run_report(
323
323
  timeout=services.settings.report_render_timeout.total_seconds(),
324
324
  )
325
325
  existing = await _report_row(session, run.id)
326
+ storage = await services.bound_storage(session)
326
327
  async with session.begin_nested():
327
328
  reference = await persist_document(
328
329
  session,
329
- services.storage,
330
+ storage,
330
331
  run.id,
331
332
  name=REPORT_NAME,
332
333
  text=markdown,
@@ -298,7 +298,8 @@ async def prune(
298
298
  total = retention.Swept()
299
299
  for _ in range(max_batches):
300
300
  async with session_scope(sessions) as session:
301
- swept = await retention.sweep(session, chosen, storage=services.storage, now=now, limit=limit)
301
+ storage = await services.bound_storage(session)
302
+ swept = await retention.sweep(session, chosen, storage=storage, now=now, limit=limit)
302
303
  for family, count in swept.counts.items():
303
304
  total.record(family, count)
304
305
  total.scratch_deleted += swept.scratch_deleted
@@ -46,6 +46,25 @@ class OutsideRoot(StorageError):
46
46
  self.uri = uri
47
47
 
48
48
 
49
+ class UnknownStorageConnection(StorageError):
50
+ """A scheme is configured from a connection code this instance does not hold."""
51
+
52
+ def __init__(self, scheme: str, ref: str, known: Iterable[str]) -> None:
53
+ """Name the scheme, the connection it is configured from, and the ones that exist."""
54
+ available = ", ".join(sorted(known)) or "none are configured"
55
+ super().__init__(f"no connection coded {ref!r} to configure the {scheme!r} scheme from ({available})")
56
+ self.scheme = scheme
57
+ self.ref = ref
58
+
59
+
60
+ #: How one scheme is configured before it is addressed: what a bound facade calls once per scheme.
61
+ type Binder = Callable[[str, StorageBackend], StorageBackend]
62
+
63
+ #: How a binder reads the connection a scheme is configured from, opened and validated
64
+ #: against the backend's own config model.
65
+ type OpenConnection = Callable[[str, str, type[BaseModel]], BaseModel]
66
+
67
+
49
68
  def parse_uri(uri: str) -> tuple[str, str]:
50
69
  """Split a URI into its scheme and the backend-specific remainder."""
51
70
  split = urlsplit(uri)
@@ -241,7 +260,7 @@ class Storage:
241
260
  self,
242
261
  backends: Mapping[str, StorageBackend],
243
262
  artifact_root: str,
244
- binder: "Callable[[str, StorageBackend], StorageBackend] | None" = None,
263
+ binder: Binder | None = None,
245
264
  ) -> None:
246
265
  """Bind the facade to the scheme index, the default scratch root, and how a scheme is configured."""
247
266
  self._backends = dict(backends)
@@ -267,11 +286,11 @@ class Storage:
267
286
  self._bound[scheme] = backend
268
287
  return backend
269
288
 
270
- def bound_by(self, binder: "Callable[[str, StorageBackend], StorageBackend]") -> "AttemptStorage":
271
- """Return the same namespace as an attempt-scoped facade, configured on first use.
289
+ def bound_by(self, binder: Binder) -> "AttemptStorage":
290
+ """Return the same namespace as a caller-scoped facade, configured on first use.
272
291
 
273
- A new facade rather than a mutation: the process-wide one is shared by every attempt,
274
- and one attempt must not reconfigure a scheme under another.
292
+ A new facade rather than a mutation: the process-wide one is shared by every caller,
293
+ and one must not reconfigure a scheme under another.
275
294
  """
276
295
  return AttemptStorage(self._backends, self.artifact_root, binder)
277
296
 
@@ -332,14 +351,28 @@ class Storage:
332
351
 
333
352
 
334
353
  class AttemptStorage(Storage):
335
- """A storage facade belonging to exactly one attempt.
354
+ """A storage facade belonging to exactly one caller: one attempt, one request, one sweep.
336
355
 
337
356
  The binder opens connection secrets and the backend it returns is cached for the life of
338
- the facade, so sharing one across attempts would hand one attempt's credentials to
339
- another.
357
+ the facade, so sharing one would hand one caller's credentials to the next.
340
358
  """
341
359
 
342
360
 
361
+ def connection_binder(storage_connections: Mapping[str, str], open_connection: OpenConnection) -> Binder:
362
+ """Build the binder that configures each scheme from the connection the instance names for it.
363
+
364
+ A scheme no connection is named for keeps the backend the plugin contributed.
365
+ """
366
+
367
+ def configure(scheme: str, backend: StorageBackend) -> StorageBackend:
368
+ ref = storage_connections.get(scheme)
369
+ if ref is None:
370
+ return backend
371
+ return backend.configured(open_connection(scheme, ref, backend.config_model))
372
+
373
+ return configure
374
+
375
+
343
376
  def build_storage(artifact_root: str, backends: Iterable[StorageBackend] = ()) -> Storage:
344
377
  """Assemble the facade: the plugin-contributed backends plus ``file://`` from core."""
345
378
  scheme, remainder = parse_uri(artifact_root)
@@ -83,7 +83,7 @@ class Worker:
83
83
  self.alerts = NotificationDispatcher(sessions=sessions, services=services, owner=self.name)
84
84
  self.sweeper = sweeper
85
85
  self.chores = list(chores)
86
- self.in_flight: set[UUID] = set()
86
+ self.in_flight: dict[UUID, ClaimedUnit] = {}
87
87
  self._stopping = asyncio.Event()
88
88
  self._halting = asyncio.Event()
89
89
  self._tasks: set[asyncio.Task[None]] = set()
@@ -141,7 +141,7 @@ class Worker:
141
141
  semaphore.release()
142
142
  await self._idle()
143
143
  continue
144
- self.in_flight.add(unit.attempt_id)
144
+ self.in_flight[unit.attempt_id] = unit
145
145
  telemetry.gauges.in_flight = len(self.in_flight)
146
146
  task = asyncio.create_task(self._execute(unit, semaphore))
147
147
  self._tasks.add(task)
@@ -164,7 +164,7 @@ class Worker:
164
164
  except Exception as error: # the outcome transaction owns failures; this is a bug
165
165
  _logger.error("executing an attempt raised", attempt_id=str(unit.attempt_id), error=str(error))
166
166
  finally:
167
- self.in_flight.discard(unit.attempt_id)
167
+ self.in_flight.pop(unit.attempt_id, None)
168
168
  self._running.pop(unit.attempt_id, None)
169
169
  telemetry.gauges.in_flight = len(self.in_flight)
170
170
  semaphore.release()
@@ -189,9 +189,9 @@ class Worker:
189
189
  if self._halting.is_set():
190
190
  return
191
191
  try:
192
- claimed = set(self.in_flight)
193
- held = await self.engine.heartbeat(list(claimed))
194
- self._abandon(claimed - held)
192
+ claimed = dict(self.in_flight)
193
+ held = await self.engine.heartbeat(list(claimed.values()))
194
+ self._abandon(set(claimed) - held)
195
195
  await self._register(WorkerStatus.DRAINING if self.draining else WorkerStatus.RUNNING)
196
196
  except Exception as error: # a missed heartbeat is recoverable; a dead task is not
197
197
  _logger.warning("heartbeat failed", worker=self.name, error=str(error))
File without changes
File without changes