dirigent-core 0.9.0__py3-none-any.whl
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/__init__.py +26 -0
- dirigent_core/alembic/env.py +71 -0
- dirigent_core/alembic/script.py.mako +26 -0
- dirigent_core/alembic/versions/0001_baseline_schema.py +1016 -0
- dirigent_core/alerting.py +805 -0
- dirigent_core/artifacts.py +73 -0
- dirigent_core/auth.py +529 -0
- dirigent_core/blockdocs.py +223 -0
- dirigent_core/config.py +421 -0
- dirigent_core/configdocs.py +134 -0
- dirigent_core/database.py +177 -0
- dirigent_core/directory.py +339 -0
- dirigent_core/documents.py +878 -0
- dirigent_core/documentschema.py +92 -0
- dirigent_core/engine/__init__.py +120 -0
- dirigent_core/engine/claim.py +156 -0
- dirigent_core/engine/context.py +420 -0
- dirigent_core/engine/definition.py +543 -0
- dirigent_core/engine/executor.py +1029 -0
- dirigent_core/engine/failure.py +71 -0
- dirigent_core/engine/recovery.py +137 -0
- dirigent_core/engine/references.py +239 -0
- dirigent_core/engine/runs.py +865 -0
- dirigent_core/engine/services.py +74 -0
- dirigent_core/engine/state.py +434 -0
- dirigent_core/ids.py +39 -0
- dirigent_core/logging.py +310 -0
- dirigent_core/migrations.py +98 -0
- dirigent_core/models.py +626 -0
- dirigent_core/pipelines.py +494 -0
- dirigent_core/plugins.py +255 -0
- dirigent_core/protocol.py +276 -0
- dirigent_core/py.typed +0 -0
- dirigent_core/ratelimit.py +42 -0
- dirigent_core/registry.py +32 -0
- dirigent_core/retention.py +293 -0
- dirigent_core/scheduler.py +491 -0
- dirigent_core/schemas.py +147 -0
- dirigent_core/secrets.py +164 -0
- dirigent_core/storage.py +349 -0
- dirigent_core/telemetry.py +402 -0
- dirigent_core/trigger_documents.py +193 -0
- dirigent_core/triggers/__init__.py +117 -0
- dirigent_core/triggers/backfill.py +131 -0
- dirigent_core/triggers/materialize.py +218 -0
- dirigent_core/triggers/schedules.py +531 -0
- dirigent_core/triggers/webhooks.py +586 -0
- dirigent_core/types.py +59 -0
- dirigent_core/worker.py +350 -0
- dirigent_core-0.9.0.dist-info/METADATA +29 -0
- dirigent_core-0.9.0.dist-info/RECORD +53 -0
- dirigent_core-0.9.0.dist-info/WHEEL +4 -0
- dirigent_core-0.9.0.dist-info/licenses/LICENSE +18 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Rendering the settings model as the two things a person reads: a page, and an example file.
|
|
2
|
+
|
|
3
|
+
Both are generated from :class:`~dirigent_core.config.Settings` itself, so a setting added
|
|
4
|
+
without a description fails the build rather than shipping an empty line.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import textwrap
|
|
9
|
+
from datetime import timedelta
|
|
10
|
+
from typing import Any, Final
|
|
11
|
+
|
|
12
|
+
from pydantic_core import PydanticUndefined
|
|
13
|
+
|
|
14
|
+
from dirigent_common import as_markdown, format_duration
|
|
15
|
+
from dirigent_core.config import Settings
|
|
16
|
+
|
|
17
|
+
#: Relative to the repository root.
|
|
18
|
+
PAGE_PATH: Final = "docs/settings.md"
|
|
19
|
+
|
|
20
|
+
BANNER: Final = "<!-- Generated from dirigent_core.config.Settings by dirigent_core.configdocs. Do not edit. -->"
|
|
21
|
+
|
|
22
|
+
PREAMBLE: Final = """# Settings reference
|
|
23
|
+
|
|
24
|
+
Every setting an instance has, its default, and what it does. This page is generated from the
|
|
25
|
+
settings model, so it cannot drift from the code: a setting added either shows up here or
|
|
26
|
+
fails the build.
|
|
27
|
+
|
|
28
|
+
Each one is read from three places, most specific first: a `DIRIGENT_`-prefixed environment
|
|
29
|
+
variable, the YAML configuration file, then the default below. `dg init` writes a
|
|
30
|
+
`dirigent.example.yaml` holding this same list, commented out, beside the working
|
|
31
|
+
`dirigent.yaml` -- so the file a person edits stays short and the whole surface is still
|
|
32
|
+
one file away.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
#: The settings a scaffolded project sets rather than leaves to a default. Everything else
|
|
36
|
+
#: reaches the example file commented out.
|
|
37
|
+
SCAFFOLDED: Final[tuple[str, ...]] = ("environment", "log_level", "worker_concurrency")
|
|
38
|
+
|
|
39
|
+
EXAMPLE_HEADER: Final = """\
|
|
40
|
+
# Every setting dirigent has, with its default and what it does.
|
|
41
|
+
#
|
|
42
|
+
# This file is read, never loaded: dirigent does not look for it. Copy a line into
|
|
43
|
+
# dirigent.yaml to change it, or set the environment variable named beside it.
|
|
44
|
+
#
|
|
45
|
+
# Generated from the settings model, so it lists what this version actually has.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
PROJECT_HEADER: Final = """\
|
|
49
|
+
# This instance's settings. Everything not named here keeps its default; the whole list,
|
|
50
|
+
# with defaults and descriptions, is in dirigent.example.yaml beside this file.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def environment_name(field: str) -> str:
|
|
55
|
+
"""Name the environment variable one setting is read from."""
|
|
56
|
+
return f"DIRIGENT_{field.upper()}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def rendered_default(name: str) -> Any:
|
|
60
|
+
"""Read one field's default as the YAML and the table should show it."""
|
|
61
|
+
field = Settings.model_fields[name]
|
|
62
|
+
if field.default is not PydanticUndefined:
|
|
63
|
+
return field.default
|
|
64
|
+
return field.default_factory() if field.default_factory is not None else None # type: ignore[call-arg]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def described(name: str) -> str:
|
|
68
|
+
"""Read what one field is, as a single line, in the spelling markdown reads.
|
|
69
|
+
|
|
70
|
+
The first paragraph only. A docstring says what the setting is and then, often, how to
|
|
71
|
+
choose a value and what it interacts with; a reference table wants the first of those,
|
|
72
|
+
and a page of tuning advice inside one cell is unreadable. The rest stays where somebody
|
|
73
|
+
reading the code will find it.
|
|
74
|
+
"""
|
|
75
|
+
description = Settings.model_fields[name].description or ""
|
|
76
|
+
return one_line(first_paragraph(description))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def first_paragraph(text: str) -> str:
|
|
80
|
+
"""Take what a description says before its first blank line."""
|
|
81
|
+
return text.strip().split("\n\n", 1)[0]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def one_line(text: str) -> str:
|
|
85
|
+
"""Flatten a description onto one line, in the spelling markdown reads."""
|
|
86
|
+
return as_markdown(" ".join(text.split()))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _commented(text: str, width: int = 92) -> list[str]:
|
|
90
|
+
"""Wrap a description into comment lines, in the spelling a YAML file reads."""
|
|
91
|
+
return [f"# {line}" for line in textwrap.wrap(text.replace("`", ""), width=width)] or ["#"]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _yaml_value(value: Any) -> str:
|
|
95
|
+
"""Render one default the way the YAML file writes it."""
|
|
96
|
+
if value is None:
|
|
97
|
+
return "null"
|
|
98
|
+
if isinstance(value, timedelta):
|
|
99
|
+
return json.dumps(format_duration(value))
|
|
100
|
+
if isinstance(value, bool):
|
|
101
|
+
return "true" if value else "false"
|
|
102
|
+
if isinstance(value, (int, float)):
|
|
103
|
+
return json.dumps(value)
|
|
104
|
+
if isinstance(value, (list, dict)):
|
|
105
|
+
return json.dumps(value)
|
|
106
|
+
return json.dumps(str(value))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def example_document() -> str:
|
|
110
|
+
"""Render the commented reference file, every setting with its default and description."""
|
|
111
|
+
lines = [EXAMPLE_HEADER]
|
|
112
|
+
for name in Settings.model_fields:
|
|
113
|
+
lines.extend(_commented(described(name)))
|
|
114
|
+
lines.append(f"# {environment_name(name)}")
|
|
115
|
+
lines.append(f"# {name}: {_yaml_value(rendered_default(name))}")
|
|
116
|
+
lines.append("")
|
|
117
|
+
return "\n".join(lines)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def project_document(**chosen: Any) -> str:
|
|
121
|
+
"""Render the working configuration file, holding only what a project is likely to change."""
|
|
122
|
+
values = {name: chosen.get(name, rendered_default(name)) for name in SCAFFOLDED}
|
|
123
|
+
body = "\n".join(f"{name}: {_yaml_value(value)}" for name, value in values.items())
|
|
124
|
+
return f"{PROJECT_HEADER}\n{body}\n"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def render() -> str:
|
|
128
|
+
"""Render the settings reference page."""
|
|
129
|
+
rows = "\n".join(
|
|
130
|
+
f"| `{name}` | `{environment_name(name)}` | `{_yaml_value(rendered_default(name))}` | {described(name)} |"
|
|
131
|
+
for name in Settings.model_fields
|
|
132
|
+
)
|
|
133
|
+
header = "| Setting | Environment | Default | What it does |\n| --- | --- | --- | --- |"
|
|
134
|
+
return f"{BANNER}\n{PREAMBLE}\n{header}\n{rows}\n"
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Async engine and session plumbing."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import random
|
|
5
|
+
from collections.abc import AsyncGenerator, Awaitable, Callable
|
|
6
|
+
from contextlib import asynccontextmanager, suppress
|
|
7
|
+
from typing import Any, Final
|
|
8
|
+
|
|
9
|
+
import sqlalchemy as sa
|
|
10
|
+
import structlog
|
|
11
|
+
from sqlalchemy import event
|
|
12
|
+
from sqlalchemy.exc import DBAPIError
|
|
13
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
|
|
14
|
+
from sqlalchemy.pool import NullPool
|
|
15
|
+
|
|
16
|
+
from dirigent_core.config import Settings
|
|
17
|
+
|
|
18
|
+
_logger = structlog.get_logger("dirigent.database")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
#: How long a SQLite connection waits for the write lock before it gives up.
|
|
22
|
+
#:
|
|
23
|
+
#: Every transaction takes the lock at ``BEGIN IMMEDIATE`` and holds it to commit, so this is
|
|
24
|
+
#: how long one caller waits behind the callers ahead of it. A fan-out settling forty items
|
|
25
|
+
#: puts forty short transactions in that queue, and an apply arriving mid-fan-out waits for
|
|
26
|
+
#: all of them; fifteen seconds outlasts that queue on a laptop whose fsync stalls, and is
|
|
27
|
+
#: still short enough that a transaction nothing will ever release fails rather than hangs.
|
|
28
|
+
SQLITE_BUSY_TIMEOUT_MS: Final = 15_000
|
|
29
|
+
|
|
30
|
+
#: What every SQLite connection is configured with, in order.
|
|
31
|
+
#:
|
|
32
|
+
#: SQLite enforces no foreign key without being asked to, per connection, and the engine
|
|
33
|
+
#: relies on them: an attempt's run and a log entry's attempt are what make an orphan
|
|
34
|
+
#: impossible. WAL is what lets the API, the scheduler and a worker share one file, since a
|
|
35
|
+
#: reader no longer blocks a writer; ``NORMAL`` is the durability that goes with it, losing
|
|
36
|
+
#: at most the last commits to a power cut and nothing to a crashed process. WAL still
|
|
37
|
+
#: allows one writer at a time, and without a busy timeout the second writer fails with
|
|
38
|
+
#: "database is locked" instead of waiting its turn.
|
|
39
|
+
SQLITE_PRAGMAS: Final[tuple[str, ...]] = (
|
|
40
|
+
"foreign_keys = ON",
|
|
41
|
+
"journal_mode = WAL",
|
|
42
|
+
"synchronous = NORMAL",
|
|
43
|
+
f"busy_timeout = {SQLITE_BUSY_TIMEOUT_MS}",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _configure_sqlite(engine: AsyncEngine) -> None:
|
|
48
|
+
"""Apply the pragmas to every SQLite connection, and open every transaction immediate.
|
|
49
|
+
|
|
50
|
+
The busy timeout only covers a connection that waits for the write lock. It does not
|
|
51
|
+
cover the one case SQLite refuses outright: a transaction that began as a reader and
|
|
52
|
+
then asks to write while another connection holds the write lock. SQLite fails that
|
|
53
|
+
upgrade with "database is locked" the instant it is asked, because the reader's snapshot
|
|
54
|
+
is what the writer ahead of it is invalidating and waiting could only deadlock. An apply
|
|
55
|
+
reads the pipeline, its versions and the plan before it inserts, which is exactly that
|
|
56
|
+
shape whenever a worker is claiming or settling an attempt at the same moment.
|
|
57
|
+
|
|
58
|
+
``BEGIN IMMEDIATE`` takes the write lock at the start of the transaction instead, where
|
|
59
|
+
waiting is safe, so the busy timeout covers it. Every transaction opens that way rather
|
|
60
|
+
than only the ones that write: which statements a session will run is not known when it
|
|
61
|
+
begins, and one process is what SQLite is sanctioned for here.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
@event.listens_for(engine.sync_engine, "connect")
|
|
65
|
+
def _pragmas(connection: Any, _record: Any) -> None: # pyright: ignore[reportUnusedFunction] - the event registers it
|
|
66
|
+
# None hands transaction control to SQLAlchemy: the driver stops emitting its own
|
|
67
|
+
# deferred BEGIN before a write, and the begin below is what opens every transaction.
|
|
68
|
+
connection.isolation_level = None
|
|
69
|
+
cursor = connection.cursor()
|
|
70
|
+
try:
|
|
71
|
+
for pragma in SQLITE_PRAGMAS:
|
|
72
|
+
cursor.execute(f"PRAGMA {pragma}")
|
|
73
|
+
finally:
|
|
74
|
+
cursor.close()
|
|
75
|
+
|
|
76
|
+
@event.listens_for(engine.sync_engine, "begin")
|
|
77
|
+
def _immediate(connection: Any) -> None: # pyright: ignore[reportUnusedFunction] - the event registers it
|
|
78
|
+
connection.exec_driver_sql("BEGIN IMMEDIATE")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def create_engine(settings: Settings) -> AsyncEngine:
|
|
82
|
+
"""Build the async engine for the configured database."""
|
|
83
|
+
if settings.is_sqlite:
|
|
84
|
+
ensure_sqlite_directory(settings)
|
|
85
|
+
engine = create_async_engine(settings.database_url, echo=settings.database_echo, future=True)
|
|
86
|
+
_configure_sqlite(engine)
|
|
87
|
+
return engine
|
|
88
|
+
return create_async_engine(
|
|
89
|
+
settings.database_url,
|
|
90
|
+
echo=settings.database_echo,
|
|
91
|
+
pool_size=settings.database_pool_size,
|
|
92
|
+
max_overflow=settings.database_max_overflow,
|
|
93
|
+
pool_timeout=settings.database_pool_timeout.total_seconds(),
|
|
94
|
+
pool_recycle=int(settings.database_pool_recycle.total_seconds()) if settings.database_pool_recycle else -1,
|
|
95
|
+
pool_pre_ping=True,
|
|
96
|
+
future=True,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def ensure_sqlite_directory(settings: Settings) -> None:
|
|
101
|
+
"""Create the directory a SQLite file lives in; the driver opens a file, never a path.
|
|
102
|
+
|
|
103
|
+
A directory that cannot be created is left to surface as the connection error it causes.
|
|
104
|
+
"""
|
|
105
|
+
path = settings.sqlite_path
|
|
106
|
+
if path is None:
|
|
107
|
+
return
|
|
108
|
+
with suppress(OSError):
|
|
109
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def create_lock_engine(settings: Settings) -> AsyncEngine:
|
|
113
|
+
"""Build an engine for a connection that is held, not pooled.
|
|
114
|
+
|
|
115
|
+
The scheduler's advisory lock is session-scoped, so its connection is held for the
|
|
116
|
+
process's lifetime; taking it from the API's pool would permanently consume a slot.
|
|
117
|
+
"""
|
|
118
|
+
return create_async_engine(settings.database_url, poolclass=NullPool, pool_pre_ping=True, future=True)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def create_session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
|
|
122
|
+
"""Build the session factory the engine and the API share."""
|
|
123
|
+
return async_sessionmaker(engine, expire_on_commit=False, autoflush=False)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@asynccontextmanager
|
|
127
|
+
async def session_scope(factory: async_sessionmaker[AsyncSession]) -> AsyncGenerator[AsyncSession]:
|
|
128
|
+
"""Run a unit of work in one transaction: one commit per state transition."""
|
|
129
|
+
async with factory() as session:
|
|
130
|
+
try:
|
|
131
|
+
yield session
|
|
132
|
+
await session.commit()
|
|
133
|
+
except BaseException:
|
|
134
|
+
await session.rollback()
|
|
135
|
+
raise
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
#: PostgreSQL's SQLSTATE for a deadlock it broke by aborting one of the transactions.
|
|
139
|
+
DEADLOCK: Final = "40P01"
|
|
140
|
+
|
|
141
|
+
#: How many times a transaction aborted by a deadlock is run again before it is raised.
|
|
142
|
+
DEADLOCK_ATTEMPTS: Final = 3
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def is_deadlock(error: BaseException) -> bool:
|
|
146
|
+
"""Report whether the database aborted this transaction to break a deadlock."""
|
|
147
|
+
original = getattr(error, "orig", None)
|
|
148
|
+
return getattr(original, "sqlstate", None) == DEADLOCK
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def with_deadlock_retry[T](work: Callable[[], Awaitable[T]], *, attempts: int = DEADLOCK_ATTEMPTS) -> T:
|
|
152
|
+
"""Run one transaction again when the database aborted it to break a deadlock.
|
|
153
|
+
|
|
154
|
+
The claim orders its locks attempt-then-run and an outcome orders them run-then-attempt,
|
|
155
|
+
which two workers can close into a cycle. Both transactions re-read everything they
|
|
156
|
+
decide on, so the one PostgreSQL picked off is safe to run again; nothing of it survived
|
|
157
|
+
the rollback.
|
|
158
|
+
"""
|
|
159
|
+
for remaining in range(attempts - 1, -1, -1):
|
|
160
|
+
try:
|
|
161
|
+
return await work()
|
|
162
|
+
except DBAPIError as error:
|
|
163
|
+
if remaining == 0 or not is_deadlock(error):
|
|
164
|
+
raise
|
|
165
|
+
_logger.debug("transaction retried after a deadlock", attempts_left=remaining)
|
|
166
|
+
await asyncio.sleep(random.uniform(0.01, 0.05))
|
|
167
|
+
raise AssertionError("unreachable: the loop returns or raises") # pragma: no cover
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def ping(engine: AsyncEngine) -> bool:
|
|
171
|
+
"""Report whether the database answers."""
|
|
172
|
+
try:
|
|
173
|
+
async with engine.connect() as connection:
|
|
174
|
+
await connection.execute(sa.text("SELECT 1"))
|
|
175
|
+
except Exception:
|
|
176
|
+
return False
|
|
177
|
+
return True
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""Applying a directory of documents: the seed at boot, and the reconcile behind prune.
|
|
2
|
+
|
|
3
|
+
A DIRECTORY CARRIES SCHEMAS AS WELL AS DOCUMENTS. A plain JSON Schema file is stored the way
|
|
4
|
+
``dg schema create`` stores it, and stored before the pipelines, so a document whose
|
|
5
|
+
``requires.schemas`` names one converges in the same pass.
|
|
6
|
+
|
|
7
|
+
THE WALK NEVER STOPS FOR ONE DOCUMENT. A directory deployed from git holds many pipelines,
|
|
8
|
+
and a broken one must not keep a server from starting the others: a document that cannot be
|
|
9
|
+
read, carries its own connections or schemas, or fails validation lands in the summary as
|
|
10
|
+
refused, with a warning in the log, and the walk carries on.
|
|
11
|
+
|
|
12
|
+
PRUNE ONLY EVER TOUCHES WHAT A DIRECTORY APPLIED. A pipeline is deactivated -- never
|
|
13
|
+
deleted, so history survives -- and only when its current version's provenance says a
|
|
14
|
+
directory apply wrote it. A pipeline authored in the UI or applied from a file by hand is
|
|
15
|
+
never pruned, however absent it is. A walk that discovered nothing refuses to prune at all:
|
|
16
|
+
an empty mount is an accident, not an instruction to turn everything off.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Final, cast
|
|
21
|
+
|
|
22
|
+
import sqlalchemy as sa
|
|
23
|
+
import yaml
|
|
24
|
+
from pydantic import BaseModel
|
|
25
|
+
from sqlalchemy.exc import SQLAlchemyError
|
|
26
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
27
|
+
|
|
28
|
+
from dirigent_client.enums import ProvenanceSource
|
|
29
|
+
from dirigent_client.schemas import PlanAction, PruneResult
|
|
30
|
+
from dirigent_common import JsonMap
|
|
31
|
+
from dirigent_core.database import session_scope
|
|
32
|
+
from dirigent_core.documents import DocumentError, carried_refusal, load_text, safe_load
|
|
33
|
+
from dirigent_core.engine.definition import KIND_TRIGGERS
|
|
34
|
+
from dirigent_core.engine.runs import Provenance
|
|
35
|
+
from dirigent_core.engine.services import EngineServices
|
|
36
|
+
from dirigent_core.logging import get_logger
|
|
37
|
+
from dirigent_core.models import Pipeline, PipelineVersion
|
|
38
|
+
from dirigent_core.pipelines import apply_document, set_active
|
|
39
|
+
from dirigent_core.scheduler import release_lead, try_lead
|
|
40
|
+
from dirigent_core.schemas import SchemaRefused, check_valid_schema, find_schema, resolve_identity, store_schema
|
|
41
|
+
from dirigent_core.trigger_documents import delete_absent_trigger_documents
|
|
42
|
+
|
|
43
|
+
_logger = get_logger(__name__)
|
|
44
|
+
|
|
45
|
+
#: The suffixes a document may carry, the same set a project's pipelines directory takes.
|
|
46
|
+
DOCUMENT_SUFFIXES: Final = (".yaml", ".yml", ".json")
|
|
47
|
+
|
|
48
|
+
#: Where a file sorts in the walk, and which applier it goes to.
|
|
49
|
+
_RANK_SCHEMA: Final = 0
|
|
50
|
+
_RANK_PIPELINE: Final = 1
|
|
51
|
+
_RANK_TRIGGERS: Final = 2
|
|
52
|
+
|
|
53
|
+
#: The keywords a plain JSON Schema is recognised by when it names no ``$schema``.
|
|
54
|
+
_SCHEMA_KEYWORDS: Final = ("type", "properties", "$id")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def discover_documents(root: Path) -> list[Path]:
|
|
58
|
+
"""List every document under the directory, however deep, in a stable order.
|
|
59
|
+
|
|
60
|
+
A corpus deployed from git nests -- a directory per team, per system, per cadence -- and
|
|
61
|
+
a walk that stopped at the first level would silently apply half of it. The order is the
|
|
62
|
+
relative path, so two runs of one tree agree.
|
|
63
|
+
"""
|
|
64
|
+
if not root.is_dir():
|
|
65
|
+
return []
|
|
66
|
+
return sorted(
|
|
67
|
+
(path for path in root.rglob("*") if path.suffix in DOCUMENT_SUFFIXES and path.is_file()),
|
|
68
|
+
key=lambda path: path.relative_to(root).as_posix(),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class RefusedDocument(BaseModel):
|
|
73
|
+
"""One document the walk could not apply, and the first reason why."""
|
|
74
|
+
|
|
75
|
+
path: str
|
|
76
|
+
code: str | None = None
|
|
77
|
+
message: str
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class DirectorySummary(BaseModel):
|
|
81
|
+
"""What one walk of the directory did, list by list."""
|
|
82
|
+
|
|
83
|
+
applied: list[str] = []
|
|
84
|
+
updated: list[str] = []
|
|
85
|
+
unchanged: list[str] = []
|
|
86
|
+
refused: list[RefusedDocument] = []
|
|
87
|
+
pruned: list[str] = []
|
|
88
|
+
trigger_documents_removed: list[str] = []
|
|
89
|
+
"""The triggers documents the directory no longer holds, deleted with the rows they owned."""
|
|
90
|
+
|
|
91
|
+
schemas: list[str] = []
|
|
92
|
+
"""Which of the codes above are schemas rather than pipelines, so a reader can tell them apart."""
|
|
93
|
+
|
|
94
|
+
prune_refusal: str | None = None
|
|
95
|
+
"""Why nothing was pruned when pruning was asked for, or nothing at all."""
|
|
96
|
+
|
|
97
|
+
skipped: bool = False
|
|
98
|
+
"""Another process held the apply lock, so it is doing this work."""
|
|
99
|
+
|
|
100
|
+
aborted: str | None = None
|
|
101
|
+
"""Why the walk stopped early: an instance fault, which no list of refusals should wear."""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def prune_absent(session: AsyncSession, keep: set[str], *, dry_run: bool = False) -> PruneResult:
|
|
105
|
+
"""Reconcile against a directory: deactivate absent pipelines, delete absent triggers documents.
|
|
106
|
+
|
|
107
|
+
The narrowing is by the CURRENT version's provenance: what a directory applied last, a
|
|
108
|
+
directory may take away. Deactivation pauses nothing retroactively beyond what
|
|
109
|
+
deactivation always means, and history survives untouched. A triggers document has no
|
|
110
|
+
history worth keeping, so it is deleted instead, and the cascade takes the schedules and
|
|
111
|
+
webhooks it owned -- never the pipeline's own inline ones. A schema is never pruned.
|
|
112
|
+
"""
|
|
113
|
+
rows = await session.execute(
|
|
114
|
+
sa.select(Pipeline.code)
|
|
115
|
+
.join(
|
|
116
|
+
PipelineVersion,
|
|
117
|
+
sa.and_(
|
|
118
|
+
PipelineVersion.pipeline_id == Pipeline.id,
|
|
119
|
+
PipelineVersion.version == Pipeline.current_version,
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
.where(
|
|
123
|
+
Pipeline.active.is_(True),
|
|
124
|
+
PipelineVersion.provenance_source == ProvenanceSource.DIRECTORY,
|
|
125
|
+
Pipeline.code.notin_(keep) if keep else sa.true(),
|
|
126
|
+
)
|
|
127
|
+
.order_by(Pipeline.code)
|
|
128
|
+
)
|
|
129
|
+
absent = [str(code) for (code,) in rows.all()]
|
|
130
|
+
documents = await delete_absent_trigger_documents(session, keep, dry_run=dry_run)
|
|
131
|
+
if not dry_run:
|
|
132
|
+
for code in absent:
|
|
133
|
+
await set_active(session, code, active=False)
|
|
134
|
+
return PruneResult(pruned=absent, trigger_documents_removed=documents, dry_run=dry_run)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def apply_directory(
|
|
138
|
+
sessions: async_sessionmaker[AsyncSession],
|
|
139
|
+
services: EngineServices,
|
|
140
|
+
root: Path,
|
|
141
|
+
*,
|
|
142
|
+
prune: bool = False,
|
|
143
|
+
dry_run: bool = False,
|
|
144
|
+
pause_schedules: bool = False,
|
|
145
|
+
lock_key: int | None = None,
|
|
146
|
+
) -> DirectorySummary:
|
|
147
|
+
"""Apply every schema and document in the directory, then reconcile when asked to.
|
|
148
|
+
|
|
149
|
+
Each one is applied in its own transaction, so one that fails rolls back alone.
|
|
150
|
+
With a ``lock_key``, the whole walk runs under the same PostgreSQL advisory lock the
|
|
151
|
+
scheduler uses for leadership: several servers booting together elect one applier, and
|
|
152
|
+
the others skip with a log line rather than racing it.
|
|
153
|
+
"""
|
|
154
|
+
summary = DirectorySummary()
|
|
155
|
+
async with session_scope(sessions) as holder:
|
|
156
|
+
if lock_key is not None and not await try_lead(holder, lock_key):
|
|
157
|
+
_logger.info("directory apply skipped: another process holds the lock", root=str(root))
|
|
158
|
+
summary.skipped = True
|
|
159
|
+
return summary
|
|
160
|
+
try:
|
|
161
|
+
await _walk(sessions, services, root, summary, dry_run=dry_run, pause_schedules=pause_schedules)
|
|
162
|
+
if prune:
|
|
163
|
+
await _prune(sessions, summary, dry_run=dry_run)
|
|
164
|
+
except SQLAlchemyError as error:
|
|
165
|
+
summary.aborted = str(error).splitlines()[0]
|
|
166
|
+
_logger.error("directory apply aborted by an instance fault", root=str(root), error=summary.aborted)
|
|
167
|
+
finally:
|
|
168
|
+
if lock_key is not None:
|
|
169
|
+
await release_lead(holder, lock_key)
|
|
170
|
+
return summary
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
async def _walk(
|
|
174
|
+
sessions: async_sessionmaker[AsyncSession],
|
|
175
|
+
services: EngineServices,
|
|
176
|
+
root: Path,
|
|
177
|
+
summary: DirectorySummary,
|
|
178
|
+
*,
|
|
179
|
+
dry_run: bool,
|
|
180
|
+
pause_schedules: bool,
|
|
181
|
+
) -> None:
|
|
182
|
+
"""Apply each discovered file into the summary, refusing without stopping.
|
|
183
|
+
|
|
184
|
+
Schemas go first, pipeline documents after them, and triggers documents last, so a
|
|
185
|
+
directory carrying all three converges in one pass: a pipeline is refused while a schema
|
|
186
|
+
its ``requires`` names is absent, and a triggers document is refused while the pipeline it
|
|
187
|
+
names is absent. Applying each ahead of what needs it is what makes it present.
|
|
188
|
+
"""
|
|
189
|
+
documents = discover_documents(root)
|
|
190
|
+
if not documents:
|
|
191
|
+
_logger.warning("directory apply found no documents", root=str(root))
|
|
192
|
+
return
|
|
193
|
+
walked: dict[str, str] = {}
|
|
194
|
+
for rank, path in sorted(((_walk_rank(path), path) for path in documents), key=lambda pair: pair[0]):
|
|
195
|
+
if rank == _RANK_SCHEMA:
|
|
196
|
+
await _apply_schema(sessions, root, path, summary, dry_run=dry_run)
|
|
197
|
+
else:
|
|
198
|
+
await _apply_one(
|
|
199
|
+
sessions, services, root, path, summary, walked, dry_run=dry_run, pause_schedules=pause_schedules
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _walk_rank(path: Path) -> int:
|
|
204
|
+
"""Peek at a file once to place it in the walk, without holding it against the format.
|
|
205
|
+
|
|
206
|
+
A ``.json`` file that parses to an object carrying neither ``format`` nor ``kind`` is a
|
|
207
|
+
plain JSON Schema when it names ``$schema``, or failing that any of the keywords a schema
|
|
208
|
+
is written with. One that cannot be read at all sorts with the pipelines, so it is refused
|
|
209
|
+
where its own path says it should be.
|
|
210
|
+
"""
|
|
211
|
+
try:
|
|
212
|
+
raw = safe_load(path.read_text())
|
|
213
|
+
except (OSError, ValueError, yaml.YAMLError):
|
|
214
|
+
return _RANK_PIPELINE
|
|
215
|
+
if not isinstance(raw, dict):
|
|
216
|
+
return _RANK_PIPELINE
|
|
217
|
+
body = cast("JsonMap", raw)
|
|
218
|
+
if body.get("kind") == KIND_TRIGGERS:
|
|
219
|
+
return _RANK_TRIGGERS
|
|
220
|
+
unenveloped = path.suffix == ".json" and "format" not in body and "kind" not in body
|
|
221
|
+
if unenveloped and ("$schema" in body or any(keyword in body for keyword in _SCHEMA_KEYWORDS)):
|
|
222
|
+
return _RANK_SCHEMA
|
|
223
|
+
return _RANK_PIPELINE
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
async def _apply_schema(
|
|
227
|
+
sessions: async_sessionmaker[AsyncSession],
|
|
228
|
+
root: Path,
|
|
229
|
+
path: Path,
|
|
230
|
+
summary: DirectorySummary,
|
|
231
|
+
*,
|
|
232
|
+
dry_run: bool,
|
|
233
|
+
) -> None:
|
|
234
|
+
"""Store one JSON Schema the directory carries, in its own transaction."""
|
|
235
|
+
ref = path.relative_to(root).as_posix()
|
|
236
|
+
try:
|
|
237
|
+
body = cast("JsonMap", safe_load(path.read_text()))
|
|
238
|
+
check_valid_schema(body)
|
|
239
|
+
code, name, description = resolve_identity(
|
|
240
|
+
body, code=None, name=None, description=None, fallback_code=path.stem
|
|
241
|
+
)
|
|
242
|
+
except (OSError, ValueError, yaml.YAMLError, SchemaRefused) as error:
|
|
243
|
+
summary.refused.append(RefusedDocument(path=ref, message=str(error)))
|
|
244
|
+
_logger.warning("directory schema refused", path=ref, error=str(error))
|
|
245
|
+
return
|
|
246
|
+
async with session_scope(sessions) as session:
|
|
247
|
+
existing = await find_schema(session, code)
|
|
248
|
+
if existing is None:
|
|
249
|
+
summary.applied.append(code)
|
|
250
|
+
elif existing.body == body and existing.name == name and existing.description == description:
|
|
251
|
+
summary.unchanged.append(code)
|
|
252
|
+
else:
|
|
253
|
+
summary.updated.append(code)
|
|
254
|
+
if not dry_run:
|
|
255
|
+
await store_schema(session, body, fallback_code=path.stem)
|
|
256
|
+
summary.schemas.append(code)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
async def _apply_one(
|
|
260
|
+
sessions: async_sessionmaker[AsyncSession],
|
|
261
|
+
services: EngineServices,
|
|
262
|
+
root: Path,
|
|
263
|
+
path: Path,
|
|
264
|
+
summary: DirectorySummary,
|
|
265
|
+
walked: dict[str, str],
|
|
266
|
+
*,
|
|
267
|
+
dry_run: bool,
|
|
268
|
+
pause_schedules: bool,
|
|
269
|
+
) -> None:
|
|
270
|
+
"""Apply one document into the summary."""
|
|
271
|
+
ref = path.relative_to(root).as_posix()
|
|
272
|
+
try:
|
|
273
|
+
definition = load_text(path.read_text())
|
|
274
|
+
except (OSError, DocumentError) as error:
|
|
275
|
+
summary.refused.append(RefusedDocument(path=ref, message=str(error)))
|
|
276
|
+
_logger.warning("directory document refused", path=ref, error=str(error))
|
|
277
|
+
return
|
|
278
|
+
if definition.code in walked:
|
|
279
|
+
message = f"code '{definition.code}' was already applied from {walked[definition.code]} in this walk"
|
|
280
|
+
summary.refused.append(RefusedDocument(path=ref, code=definition.code, message=message))
|
|
281
|
+
_logger.warning("directory document refused", path=ref, pipeline=definition.code, error=message)
|
|
282
|
+
return
|
|
283
|
+
walked[definition.code] = ref
|
|
284
|
+
carried = carried_refusal(definition)
|
|
285
|
+
if carried is not None:
|
|
286
|
+
summary.refused.append(RefusedDocument(path=ref, code=definition.code, message=carried))
|
|
287
|
+
_logger.warning("directory document refused", path=ref, pipeline=definition.code, error=carried)
|
|
288
|
+
return
|
|
289
|
+
try:
|
|
290
|
+
async with session_scope(sessions) as session:
|
|
291
|
+
result = await apply_document(
|
|
292
|
+
session,
|
|
293
|
+
services,
|
|
294
|
+
definition,
|
|
295
|
+
provenance=Provenance(source=ProvenanceSource.DIRECTORY, ref=ref),
|
|
296
|
+
dry_run=dry_run,
|
|
297
|
+
pause_schedules=pause_schedules,
|
|
298
|
+
)
|
|
299
|
+
except SQLAlchemyError:
|
|
300
|
+
# A database fault is the instance's, not this document's: refusing 59 documents
|
|
301
|
+
# one by one is how a schema drift hides. The walk stops and says so once.
|
|
302
|
+
raise
|
|
303
|
+
except Exception as error: # noqa: BLE001 - one broken document must not stop the walk
|
|
304
|
+
summary.refused.append(RefusedDocument(path=ref, code=definition.code, message=str(error)))
|
|
305
|
+
_logger.warning("directory document refused", path=ref, pipeline=definition.code, error=str(error))
|
|
306
|
+
return
|
|
307
|
+
plan = result.plan
|
|
308
|
+
if plan.action is PlanAction.INVALID:
|
|
309
|
+
first = plan.issues[0].message if plan.issues else "invalid"
|
|
310
|
+
summary.refused.append(RefusedDocument(path=ref, code=plan.code, message=first))
|
|
311
|
+
_logger.warning("directory document refused", path=ref, pipeline=plan.code, error=first)
|
|
312
|
+
elif plan.action is PlanAction.CREATE:
|
|
313
|
+
summary.applied.append(plan.code)
|
|
314
|
+
elif plan.action is PlanAction.UPDATE:
|
|
315
|
+
summary.updated.append(plan.code)
|
|
316
|
+
else:
|
|
317
|
+
summary.unchanged.append(plan.code)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
async def _prune(sessions: async_sessionmaker[AsyncSession], summary: DirectorySummary, *, dry_run: bool) -> None:
|
|
321
|
+
"""Reconcile after the walk: deactivate what the directory no longer holds.
|
|
322
|
+
|
|
323
|
+
Schema codes are dropped from what is kept: they name no pipeline, and no schema is pruned.
|
|
324
|
+
"""
|
|
325
|
+
codes = {
|
|
326
|
+
*summary.applied,
|
|
327
|
+
*summary.updated,
|
|
328
|
+
*summary.unchanged,
|
|
329
|
+
*(refused.code for refused in summary.refused if refused.code is not None),
|
|
330
|
+
}
|
|
331
|
+
if not codes:
|
|
332
|
+
summary.prune_refusal = "the directory held no documents, and an empty directory never prunes"
|
|
333
|
+
_logger.warning("directory prune refused", reason=summary.prune_refusal)
|
|
334
|
+
return
|
|
335
|
+
keep = codes - set(summary.schemas)
|
|
336
|
+
async with session_scope(sessions) as session:
|
|
337
|
+
result = await prune_absent(session, keep, dry_run=dry_run)
|
|
338
|
+
summary.pruned = result.pruned
|
|
339
|
+
summary.trigger_documents_removed = result.trigger_documents_removed
|