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.
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/PKG-INFO +1 -1
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/pyproject.toml +1 -1
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/pyproject.toml.orig +1 -1
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/alembic/versions/0001_baseline_schema.py +2 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/alerting.py +65 -22
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/engine/claim.py +3 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/engine/context.py +5 -10
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/engine/executor.py +30 -12
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/engine/recovery.py +1 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/engine/runs.py +1 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/engine/services.py +23 -1
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/models.py +12 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/reporting.py +2 -1
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/scheduler.py +2 -1
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/storage.py +41 -8
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/worker.py +6 -6
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/LICENSE +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/README.md +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/__init__.py +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/alembic/env.py +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/alembic/script.py.mako +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/artifacts.py +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/auth.py +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/blockdocs.py +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/config.py +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/configdocs.py +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/database.py +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/directory.py +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/documents.py +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/documentschema.py +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/engine/__init__.py +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/engine/definition.py +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/engine/failure.py +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/engine/references.py +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/engine/state.py +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/examples.py +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/ids.py +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/logging.py +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/migrations.py +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/pipelines.py +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/plugins.py +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/protocol.py +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/py.typed +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/ratelimit.py +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/registry.py +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/retention.py +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/schemas.py +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/secrets.py +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/telemetry.py +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/trigger_documents.py +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/triggers/__init__.py +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/triggers/backfill.py +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/triggers/materialize.py +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/triggers/schedules.py +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/triggers/webhooks.py +0 -0
- {dirigent_core-0.15.2 → dirigent_core-0.16.0}/src/dirigent_core/types.py +0 -0
|
@@ -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
|
-
) ->
|
|
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
|
|
659
|
-
outlasted its lease cannot overwrite the outcome of the
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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(
|
|
942
|
-
delivered = await send_notification(
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
461
|
-
|
|
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
|
|
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,
|
|
883
|
-
"""Refresh the leases
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
271
|
-
"""Return the same namespace as
|
|
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
|
|
274
|
-
and one
|
|
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
|
|
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:
|
|
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
|
|
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.
|
|
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 =
|
|
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
|
|
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
|