imbi-workflows 2.38.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.
@@ -0,0 +1,4 @@
1
+ from importlib import metadata
2
+
3
+ version = metadata.version('imbi-workflows')
4
+ del metadata
imbi/workflows/app.py ADDED
@@ -0,0 +1,134 @@
1
+ """imbi-workflows: the workflow engine with a ``/status`` endpoint."""
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import datetime
6
+ import logging
7
+ import os
8
+ import socket
9
+ import typing
10
+ import uuid
11
+ from collections import abc
12
+
13
+ import fastapi
14
+ import httpx
15
+ import psycopg_pool
16
+ import pydantic
17
+ import typer
18
+
19
+ import imbi.workflows
20
+ from imbi.common import graph, iggy, lifespan, server
21
+ from imbi.common import settings as common_settings
22
+ from imbi.common.prompts import pool as prompt_pool
23
+ from imbi.workflows import bindings, engine, executors, settings, store
24
+
25
+ LOGGER = logging.getLogger(__name__)
26
+
27
+ #: How long shutdown waits for the calls in flight before it cancels.
28
+ SHUTDOWN_DRAIN_TIMEOUT = 25.0
29
+
30
+
31
+ @contextlib.asynccontextmanager
32
+ async def engine_lifespan() -> abc.AsyncGenerator[engine.Engine]:
33
+ """Run the engine for the life of the process.
34
+
35
+ The engine borrows nothing from other hooks: it opens its own graph
36
+ and Postgres pool. imbi-api applies the schemas; this process only
37
+ reads and writes them.
38
+ """
39
+ config = settings.Workflows()
40
+ db = graph.Graph()
41
+ await db.open()
42
+ postgres = common_settings.Postgres()
43
+ pool: store.Pool = psycopg_pool.AsyncConnectionPool(
44
+ conninfo=str(postgres.url),
45
+ min_size=postgres.min_pool_size,
46
+ max_size=postgres.max_pool_size,
47
+ open=False,
48
+ )
49
+ await pool.open()
50
+ stop = asyncio.Event()
51
+ worker_id = f'{socket.gethostname()}:{os.getpid()}:{uuid.uuid4().hex[:8]}'
52
+ try:
53
+ async with httpx.AsyncClient(timeout=60) as client:
54
+ steps = executors.Executors(
55
+ executors.Api(client, config), db, config
56
+ )
57
+ runner = engine.Engine(store.Store(pool, worker_id), steps, config)
58
+ reconciler = bindings.Reconciler(pool, client, steps, config)
59
+ listener = asyncio.create_task(runner.listen(reconciler.notify))
60
+ loop = asyncio.create_task(runner.run_forever(stop))
61
+ sync = asyncio.create_task(reconciler.run_forever(stop))
62
+ try:
63
+ yield runner
64
+ finally:
65
+ stop.set()
66
+ runner.notify()
67
+ await asyncio.wait({loop}, timeout=SHUTDOWN_DRAIN_TIMEOUT)
68
+ for task in (loop, listener, sync):
69
+ task.cancel()
70
+ await asyncio.gather(
71
+ loop, listener, sync, return_exceptions=True
72
+ )
73
+ finally:
74
+ await pool.close()
75
+ await db.close()
76
+
77
+
78
+ @contextlib.asynccontextmanager
79
+ async def iggy_lifespan() -> abc.AsyncGenerator[None]:
80
+ """Connect to Iggy, which takes run history to ClickHouse.
81
+
82
+ Runs go on without it: the archive sweep of imbi-api publishes what
83
+ the engine could not (ADR 0021, Run history).
84
+ """
85
+ try:
86
+ connected = await iggy.initialize()
87
+ except Exception: # noqa: BLE001 -- the sweep publishes later
88
+ LOGGER.warning('Iggy initialization failed', exc_info=True)
89
+ connected = False
90
+ if not connected:
91
+ LOGGER.warning('Iggy is not available; the sweep publishes history')
92
+ try:
93
+ yield
94
+ finally:
95
+ await iggy.aclose()
96
+
97
+
98
+ class Status(pydantic.BaseModel):
99
+ service: str = 'imbi-workflows'
100
+ status: typing.Literal['ok']
101
+ version: str
102
+ started_at: datetime.datetime
103
+
104
+
105
+ def create_app() -> fastapi.FastAPI:
106
+ app = fastapi.FastAPI(
107
+ version=imbi.workflows.version,
108
+ started_at=datetime.datetime.now(datetime.UTC),
109
+ lifespan=lifespan.Lifespan(
110
+ prompt_pool.pool_lifespan, iggy_lifespan, engine_lifespan
111
+ ),
112
+ )
113
+
114
+ @app.get('/status', operation_id='getStatus')
115
+ def status(request: fastapi.Request) -> Status: # pyright: ignore[reportUnusedFunction]
116
+ """Report that this process is up. It checks no dependency."""
117
+ return Status(
118
+ status='ok',
119
+ version=request.app.version,
120
+ started_at=request.app.extra['started_at'],
121
+ )
122
+
123
+ return app
124
+
125
+
126
+ cli = typer.Typer(no_args_is_help=True)
127
+ cli.command('serve')(
128
+ server.bind_entrypoint('imbi.workflows.app:create_app', default_port=8006)
129
+ )
130
+
131
+
132
+ @cli.callback()
133
+ def _callback() -> None: # pyright: ignore[reportUnusedFunction]
134
+ """Imbi Workflows CLI"""
@@ -0,0 +1,329 @@
1
+ """The trigger reconciler (ADR 0021, Triggers).
2
+
3
+ imbi-api writes the desired trigger bindings when a workflow activates,
4
+ pauses, or is deleted, in Postgres only. This writes the scheduler and
5
+ the gateway to match, and records each binding as ``synced`` or
6
+ ``failed`` with the error. It runs when imbi-api notifies and on a
7
+ schedule, so a lost notification or a failed call delays a binding and
8
+ leaves nothing half made: the next pass tries again.
9
+
10
+ - A ``schedule`` binding is the scheduler task ``workflow-<binding id>``.
11
+ Its ``api`` target posts to the run endpoint as the scheduler's service
12
+ account. A pause disables it. The scheduler checks the cron
13
+ expression: a bad one fails the binding with the scheduler's error.
14
+ - A ``webhook`` binding is a ``gateway-actions#start_workflow`` rule on
15
+ the Webhook that the trigger names. A pause keeps it, because the run
16
+ endpoint refuses a paused workflow with ``409`` and the gateway records
17
+ that delivery as no effect.
18
+ """
19
+
20
+ import asyncio
21
+ import json
22
+ import logging
23
+ import typing
24
+
25
+ import httpx
26
+ from psycopg import rows
27
+
28
+ from imbi.common import graph
29
+ from imbi.common.graph import cypher
30
+ from imbi.workflows import executors, settings, store
31
+
32
+ LOGGER = logging.getLogger(__name__)
33
+
34
+ HANDLER = 'gateway-actions#start_workflow'
35
+
36
+ #: The scheduler runs an ``api`` task only as its own service account
37
+ #: (scheduler ADR 0002).
38
+ SCHEDULER_ACCOUNT = 'imbi-scheduler'
39
+
40
+ HTTP_NOT_FOUND = 404
41
+
42
+ _RULES: typing.LiteralString = """
43
+ MATCH (r:WebhookRule)-[:ACTIONS]->(w:Webhook)
44
+ WHERE r.handler = {handler} AND r.handler_config CONTAINS {binding}
45
+ RETURN w.id AS webhook, r.filter_expression AS filter,
46
+ r.handler_config AS config
47
+ """
48
+
49
+ _DELETE_RULES: typing.LiteralString = """
50
+ MATCH (r:WebhookRule)
51
+ WHERE r.handler = {handler} AND r.handler_config CONTAINS {binding}
52
+ DETACH DELETE r
53
+ RETURN 1 AS ok
54
+ """
55
+
56
+ _CREATE_RULE: typing.LiteralString = """
57
+ MATCH (w:Webhook {{id: {webhook}}})
58
+ -[:BELONGS_TO]->(:Organization {{id: {org}}})
59
+ OPTIONAL MATCH (r:WebhookRule)-[:ACTIONS]->(w)
60
+ WITH w, max(r.ordinal) AS top
61
+ CREATE (n:WebhookRule {{filter_expression: {filter}, handler: {handler},
62
+ handler_config: {config},
63
+ ordinal: coalesce(top, -1) + 1}})-[:ACTIONS]->(w)
64
+ RETURN w.id AS id
65
+ """
66
+
67
+
68
+ class SyncError(Exception):
69
+ """The scheduler or the gateway did not take the binding."""
70
+
71
+
72
+ class Reconciler:
73
+ def __init__(
74
+ self,
75
+ pool: store.Pool,
76
+ http: httpx.AsyncClient,
77
+ steps: executors.Executors,
78
+ config: settings.Workflows,
79
+ organization_id: str | None = None,
80
+ ) -> None:
81
+ self.pool = pool
82
+ self.http = http
83
+ self.steps = steps
84
+ self.config = config
85
+ #: Sync only the bindings of this organization. ``None``, as in
86
+ #: production, syncs every binding. Tests that share one
87
+ #: database with other engines set it, as for ``store.Store``.
88
+ self.organization_id = organization_id
89
+ self._wake = asyncio.Event()
90
+
91
+ def notify(self) -> None:
92
+ """Start the next pass now."""
93
+ self._wake.set()
94
+
95
+ async def run_forever(self, stop: asyncio.Event) -> None:
96
+ while not stop.is_set():
97
+ self._wake.clear()
98
+ try:
99
+ await self.reconcile()
100
+ except Exception:
101
+ LOGGER.exception('Trigger reconcile failed; continuing')
102
+ waiters = [
103
+ asyncio.create_task(stop.wait()),
104
+ asyncio.create_task(self._wake.wait()),
105
+ ]
106
+ try:
107
+ await asyncio.wait(
108
+ waiters,
109
+ timeout=self.config.reconcile_interval,
110
+ return_when=asyncio.FIRST_COMPLETED,
111
+ )
112
+ finally:
113
+ for waiter in waiters:
114
+ waiter.cancel()
115
+ await asyncio.gather(*waiters, return_exceptions=True)
116
+
117
+ async def reconcile(self) -> None:
118
+ """Sync every binding once."""
119
+ async with (
120
+ self.pool.connection() as conn,
121
+ conn.cursor(row_factory=rows.dict_row) as cur,
122
+ ):
123
+ await cur.execute(
124
+ 'SELECT b.*, w.slug AS workflow_slug'
125
+ ' FROM workflows.trigger_bindings AS b'
126
+ ' LEFT JOIN workflows.workflows AS w ON w.id = b.workflow_id'
127
+ ' WHERE %(org)s::text IS NULL OR b.organization_id = %(org)s'
128
+ ' ORDER BY b.updated_at',
129
+ {'org': self.organization_id},
130
+ )
131
+ bindings = await cur.fetchall()
132
+ for binding in bindings:
133
+ try:
134
+ if binding['type'] == 'schedule':
135
+ target_id = await self._schedule(binding)
136
+ else:
137
+ target_id = await self._webhook(binding)
138
+ except Exception as e: # noqa: BLE001 -- recorded on the binding
139
+ LOGGER.warning(
140
+ 'Trigger binding %s failed: %s', binding['id'], e
141
+ )
142
+ await self._record(binding, None, str(e) or type(e).__name__)
143
+ else:
144
+ await self._record(binding, target_id, None)
145
+
146
+ async def _record(
147
+ self, binding: store.Row, target_id: str | None, error: str | None
148
+ ) -> None:
149
+ """Write the outcome, unless imbi-api changed the binding since.
150
+
151
+ A changed binding has a new ``updated_at`` and is ``pending``,
152
+ and imbi-api notified, so the next pass syncs it.
153
+ """
154
+ where = (binding['id'], binding['updated_at'])
155
+ async with self.pool.connection() as conn:
156
+ if error is not None:
157
+ await conn.execute(
158
+ 'UPDATE workflows.trigger_bindings SET sync_state ='
159
+ " 'failed', error = %s WHERE id = %s AND updated_at = %s",
160
+ (error[:1000], *where),
161
+ )
162
+ elif binding['removed']:
163
+ await conn.execute(
164
+ 'DELETE FROM workflows.trigger_bindings'
165
+ ' WHERE id = %s AND updated_at = %s',
166
+ where,
167
+ )
168
+ else:
169
+ await conn.execute(
170
+ 'UPDATE workflows.trigger_bindings SET sync_state ='
171
+ " 'synced', error = NULL, target_id = %s,"
172
+ ' synced_at = NOW() WHERE id = %s AND updated_at = %s',
173
+ (target_id, *where),
174
+ )
175
+
176
+ # --- Scheduler -----------------------------------------------------
177
+
178
+ async def _scheduler(
179
+ self, method: str, path: str, body: typing.Any = None
180
+ ) -> httpx.Response:
181
+ return await self.http.request(
182
+ method,
183
+ self.config.scheduler_url.rstrip('/') + path,
184
+ json=body,
185
+ headers={
186
+ 'Authorization': f'Bearer {await self.steps.api.bearer()}'
187
+ },
188
+ )
189
+
190
+ async def _schedule(self, binding: store.Row) -> str | None:
191
+ """Make the scheduler task match the binding. Return its id."""
192
+ path = f'/tasks/workflow-{binding["id"]}'
193
+ if binding['removed']:
194
+ response = await self._scheduler('DELETE', path)
195
+ if response.status_code != HTTP_NOT_FOUND:
196
+ _check(response)
197
+ return None
198
+ desired = await self._task(binding)
199
+ response = await self._scheduler('GET', path)
200
+ if response.status_code == HTTP_NOT_FOUND:
201
+ response = await self._scheduler(
202
+ 'POST',
203
+ '/tasks',
204
+ {'slug': path.rsplit('/', 1)[1], 'kind': 'user', **desired},
205
+ )
206
+ else:
207
+ _check(response)
208
+ task = response.json()
209
+ patch = [
210
+ {'op': 'replace', 'path': f'/{key}', 'value': value}
211
+ for key, value in desired.items()
212
+ if task.get(key) != value
213
+ ]
214
+ if patch:
215
+ response = await self._scheduler('PATCH', path, patch)
216
+ _check(response)
217
+ return str(response.json()['id'])
218
+
219
+ async def _task(self, binding: store.Row) -> dict[str, typing.Any]:
220
+ """Return the fields of the scheduler task, as the scheduler
221
+ returns them, so that a task that matches is not patched."""
222
+ slug = binding['workflow_slug']
223
+ params = binding['params']
224
+ return {
225
+ 'name': f'Workflow {slug}: {binding["node_id"]}',
226
+ 'description': 'Schedule trigger of a workflow. imbi-workflows '
227
+ 'manages this task: a change to it is undone.',
228
+ 'organization': await self.steps.org_slug(
229
+ binding['organization_id']
230
+ ),
231
+ 'enabled': binding['enabled'],
232
+ 'trigger': {
233
+ 'kind': 'cron',
234
+ 'expression': params['cron'],
235
+ 'jitter': None,
236
+ },
237
+ 'timezone': params.get('timezone', 'UTC'),
238
+ 'identity': {
239
+ 'kind': 'service_account',
240
+ 'subject': SCHEDULER_ACCOUNT,
241
+ 'consent_id': None,
242
+ 'scope': 'imbi-api:*',
243
+ },
244
+ 'target': {
245
+ 'kind': 'api',
246
+ 'method': 'POST',
247
+ 'path': f'/workflows/{slug}/runs',
248
+ 'query': {},
249
+ # The scheduler renders the body; its run id is the
250
+ # Idempotency-Key, so a retry starts no second run.
251
+ 'body': {
252
+ 'trigger': binding['node_id'],
253
+ 'payload': {'fired_at': '{{ run.fired_at }}'},
254
+ },
255
+ 'organization': None,
256
+ },
257
+ }
258
+
259
+ # --- Gateway -------------------------------------------------------
260
+
261
+ async def _webhook(self, binding: store.Row) -> str | None:
262
+ """Make the gateway rule match the binding. Return the webhook id.
263
+
264
+ The rule is found by the binding id in its config, so a rule on a
265
+ Webhook that the trigger no longer names is removed too.
266
+ """
267
+ # The exact field, as json.dumps writes it here and in the
268
+ # webhooks API: an id elsewhere in the config (a trigger node id
269
+ # can be any string) must not match.
270
+ marker = {
271
+ 'handler': HANDLER,
272
+ 'binding': f'"binding": "{binding["id"]}"',
273
+ }
274
+ if binding['removed']:
275
+ await self.steps.db.execute(_DELETE_RULES, marker, ['ok'])
276
+ return None
277
+ params = binding['params']
278
+ rule_filter = params.get('filter') or 'true'
279
+ config = {
280
+ 'workflow_slug': binding['workflow_slug'],
281
+ 'trigger': binding['node_id'],
282
+ 'binding': str(binding['id']),
283
+ }
284
+ found = await self.steps.db.execute(
285
+ _RULES, marker, ['webhook', 'filter', 'config']
286
+ )
287
+ current = [
288
+ (
289
+ graph.parse_agtype(r['webhook']),
290
+ graph.parse_agtype(r['filter']),
291
+ json.loads(str(graph.parse_agtype(r['config']))),
292
+ )
293
+ for r in found
294
+ ]
295
+ if current == [(params['webhook_id'], rule_filter, config)]:
296
+ return str(params['webhook_id'])
297
+ # One transaction, delete first: the gateway never sees the
298
+ # webhook without the rule, and a webhook outside the org rolls
299
+ # the delete back.
300
+ try:
301
+ await self.steps.db._execute_batch( # pyright: ignore[reportPrivateUsage]
302
+ [
303
+ cypher.Statement(_DELETE_RULES, marker),
304
+ cypher.Statement(
305
+ _CREATE_RULE,
306
+ {
307
+ **marker,
308
+ 'webhook': params['webhook_id'],
309
+ 'org': binding['organization_id'],
310
+ 'filter': rule_filter,
311
+ 'config': json.dumps(config),
312
+ },
313
+ expect_rows=True,
314
+ ),
315
+ ]
316
+ )
317
+ except graph.StatementMatchedNothing as e:
318
+ raise SyncError(
319
+ f'Webhook {params["webhook_id"]!r} is not in the organization'
320
+ ) from e
321
+ return str(params['webhook_id'])
322
+
323
+
324
+ def _check(response: httpx.Response) -> None:
325
+ if response.is_error:
326
+ raise SyncError(
327
+ f'imbi-scheduler returned {response.status_code}: '
328
+ f'{response.text[:500]}'
329
+ )