aflib 0.0.6__tar.gz → 0.0.7__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 (36) hide show
  1. {aflib-0.0.6 → aflib-0.0.7}/.gitignore +1 -0
  2. {aflib-0.0.6 → aflib-0.0.7}/PKG-INFO +39 -6
  3. {aflib-0.0.6 → aflib-0.0.7}/README.md +38 -5
  4. {aflib-0.0.6 → aflib-0.0.7}/pyproject.toml +1 -1
  5. {aflib-0.0.6 → aflib-0.0.7}/src/aflib/__init__.py +11 -1
  6. aflib-0.0.7/src/aflib/batch.py +141 -0
  7. aflib-0.0.7/src/aflib/events.py +176 -0
  8. aflib-0.0.7/src/aflib/workflow.py +161 -0
  9. {aflib-0.0.6 → aflib-0.0.7}/tests/conftest.py +12 -3
  10. aflib-0.0.7/tests/test_batch.py +145 -0
  11. aflib-0.0.7/tests/test_events.py +184 -0
  12. {aflib-0.0.6 → aflib-0.0.7}/tests/test_public_api.py +12 -0
  13. aflib-0.0.7/tests/test_workflow.py +133 -0
  14. {aflib-0.0.6 → aflib-0.0.7}/LICENSE +0 -0
  15. {aflib-0.0.6 → aflib-0.0.7}/src/aflib/authz.py +0 -0
  16. {aflib-0.0.6 → aflib-0.0.7}/src/aflib/codec.py +0 -0
  17. {aflib-0.0.6 → aflib-0.0.7}/src/aflib/db.py +0 -0
  18. {aflib-0.0.6 → aflib-0.0.7}/src/aflib/errors.py +0 -0
  19. {aflib-0.0.6 → aflib-0.0.7}/src/aflib/functions.py +0 -0
  20. {aflib-0.0.6 → aflib-0.0.7}/src/aflib/identity.py +0 -0
  21. {aflib-0.0.6 → aflib-0.0.7}/src/aflib/logging.py +0 -0
  22. {aflib-0.0.6 → aflib-0.0.7}/src/aflib/params.py +0 -0
  23. {aflib-0.0.6 → aflib-0.0.7}/src/aflib/result.py +0 -0
  24. {aflib-0.0.6 → aflib-0.0.7}/src/aflib/transaction.py +0 -0
  25. {aflib-0.0.6 → aflib-0.0.7}/src/aflib/trigger.py +0 -0
  26. {aflib-0.0.6 → aflib-0.0.7}/tests/test_authz.py +0 -0
  27. {aflib-0.0.6 → aflib-0.0.7}/tests/test_codec.py +0 -0
  28. {aflib-0.0.6 → aflib-0.0.7}/tests/test_db.py +0 -0
  29. {aflib-0.0.6 → aflib-0.0.7}/tests/test_errors.py +0 -0
  30. {aflib-0.0.6 → aflib-0.0.7}/tests/test_functions.py +0 -0
  31. {aflib-0.0.6 → aflib-0.0.7}/tests/test_identity.py +0 -0
  32. {aflib-0.0.6 → aflib-0.0.7}/tests/test_logging.py +0 -0
  33. {aflib-0.0.6 → aflib-0.0.7}/tests/test_params.py +0 -0
  34. {aflib-0.0.6 → aflib-0.0.7}/tests/test_result.py +0 -0
  35. {aflib-0.0.6 → aflib-0.0.7}/tests/test_transaction.py +0 -0
  36. {aflib-0.0.6 → aflib-0.0.7}/tests/test_trigger.py +0 -0
@@ -5,3 +5,4 @@ build/
5
5
  dist/
6
6
  .venv/
7
7
  .pytest_cache/
8
+ .env
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: aflib
3
- Version: 0.0.6
3
+ Version: 0.0.7
4
4
  Summary: Helper library for business logic inside Airflows plpython function bodies
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -27,16 +27,49 @@ so low.
27
27
  Airflows resolves Python packages from public PyPI by name and version only; there is no
28
28
  local-import, git or private-index route. Declare it in
29
29
  `models/pythonPackages/pythonPackages.airflows` by pinning **name and version in
30
- separate fields** — never as a single `aflib==0.0.6` string (that form produced
30
+ separate fields** — never as a single `aflib==0.0.7` string (that form produced
31
31
  malformed `name==ver==ver` entries in a real instance's package list):
32
32
 
33
33
  ```
34
34
  name: aflib
35
- version: 0.0.6
35
+ version: 0.0.7
36
36
  ```
37
37
 
38
38
  Then import it from a function body.
39
39
 
40
+ ## What 0.0.7 gives you
41
+
42
+ Everything in 0.0.6, plus three capabilities that ran on a real instance on
43
+ 2026-10-01 before they were extracted:
44
+
45
+ - **`Batch(plpy).run(...)`** — one committed chunk. The pending predicate is
46
+ placed in the outer `WHERE` and in the subquery, and the result carries
47
+ `done`, `remaining` and `seconds`.
48
+ - **`workflow.start` / `status` / `stuck`** — insert a `PENDING` workflow
49
+ instance and its variables, read the newest instance for a business key, and
50
+ list instances that are still `PENDING` or `IN_PROGRESS` after an interval.
51
+ - **`Outbox(plpy, outbox=..., delivery=..., subscription=...).emit` / `pump`** —
52
+ one event plus a pending delivery per active subscriber, then a chunk of
53
+ deliveries. Each subscriber runs inside `plpy.subtransaction()`. Failures
54
+ retry with `attempts`, `next_attempt_at` and `last_error`, and dead-letter
55
+ after 3 attempts.
56
+
57
+ ```python
58
+ from aflib import workflow
59
+ from aflib.events import Outbox
60
+
61
+ workflow.start(plpy, "WFP_cadena", "case-58", {
62
+ "SomeSchema.some_function.p_id": ("INTEGER", 42),
63
+ })
64
+
65
+ box = Outbox(plpy, outbox="app.outbox", delivery="app.delivery", subscription="app.sub")
66
+ box.emit("order.placed", {"id": 42}, 42)
67
+ box.pump(deliver, limit=50)
68
+ ```
69
+
70
+ `deliver(subscriber, outbox_id, attempt)` does the work and raises to fail the
71
+ attempt. Table names are identifiers you wrote, not request data.
72
+
40
73
  ## What 0.0.6 gives you
41
74
 
42
75
  0.0.1–0.0.3 (read/result, errors/logging, `transaction.atomic`) plus what
@@ -58,10 +91,10 @@ the 2026-09 platform probes showed, plus two verified fixes:
58
91
  resolve.
59
92
  - **`identity.current_user(plpy)`** — `SELECT current_user`. Not a User id.
60
93
 
61
- Pin **name and version in separate fields** (`aflib` / `0.0.6`), not
62
- `aflib==0.0.6` as one string.
94
+ Pin **name and version in separate fields** (`aflib` / `0.0.7`), not
95
+ `aflib==0.0.7` as one string.
63
96
 
64
- ## Core API (0.0.1–0.0.3, still in 0.0.6)
97
+ ## Core API (0.0.1–0.0.3, still in 0.0.7)
65
98
 
66
99
  ```python
67
100
  from aflib import Db, errors, fail, logging, ok, params
@@ -18,16 +18,49 @@ so low.
18
18
  Airflows resolves Python packages from public PyPI by name and version only; there is no
19
19
  local-import, git or private-index route. Declare it in
20
20
  `models/pythonPackages/pythonPackages.airflows` by pinning **name and version in
21
- separate fields** — never as a single `aflib==0.0.6` string (that form produced
21
+ separate fields** — never as a single `aflib==0.0.7` string (that form produced
22
22
  malformed `name==ver==ver` entries in a real instance's package list):
23
23
 
24
24
  ```
25
25
  name: aflib
26
- version: 0.0.6
26
+ version: 0.0.7
27
27
  ```
28
28
 
29
29
  Then import it from a function body.
30
30
 
31
+ ## What 0.0.7 gives you
32
+
33
+ Everything in 0.0.6, plus three capabilities that ran on a real instance on
34
+ 2026-10-01 before they were extracted:
35
+
36
+ - **`Batch(plpy).run(...)`** — one committed chunk. The pending predicate is
37
+ placed in the outer `WHERE` and in the subquery, and the result carries
38
+ `done`, `remaining` and `seconds`.
39
+ - **`workflow.start` / `status` / `stuck`** — insert a `PENDING` workflow
40
+ instance and its variables, read the newest instance for a business key, and
41
+ list instances that are still `PENDING` or `IN_PROGRESS` after an interval.
42
+ - **`Outbox(plpy, outbox=..., delivery=..., subscription=...).emit` / `pump`** —
43
+ one event plus a pending delivery per active subscriber, then a chunk of
44
+ deliveries. Each subscriber runs inside `plpy.subtransaction()`. Failures
45
+ retry with `attempts`, `next_attempt_at` and `last_error`, and dead-letter
46
+ after 3 attempts.
47
+
48
+ ```python
49
+ from aflib import workflow
50
+ from aflib.events import Outbox
51
+
52
+ workflow.start(plpy, "WFP_cadena", "case-58", {
53
+ "SomeSchema.some_function.p_id": ("INTEGER", 42),
54
+ })
55
+
56
+ box = Outbox(plpy, outbox="app.outbox", delivery="app.delivery", subscription="app.sub")
57
+ box.emit("order.placed", {"id": 42}, 42)
58
+ box.pump(deliver, limit=50)
59
+ ```
60
+
61
+ `deliver(subscriber, outbox_id, attempt)` does the work and raises to fail the
62
+ attempt. Table names are identifiers you wrote, not request data.
63
+
31
64
  ## What 0.0.6 gives you
32
65
 
33
66
  0.0.1–0.0.3 (read/result, errors/logging, `transaction.atomic`) plus what
@@ -49,10 +82,10 @@ the 2026-09 platform probes showed, plus two verified fixes:
49
82
  resolve.
50
83
  - **`identity.current_user(plpy)`** — `SELECT current_user`. Not a User id.
51
84
 
52
- Pin **name and version in separate fields** (`aflib` / `0.0.6`), not
53
- `aflib==0.0.6` as one string.
85
+ Pin **name and version in separate fields** (`aflib` / `0.0.7`), not
86
+ `aflib==0.0.7` as one string.
54
87
 
55
- ## Core API (0.0.1–0.0.3, still in 0.0.6)
88
+ ## Core API (0.0.1–0.0.3, still in 0.0.7)
56
89
 
57
90
  ```python
58
91
  from aflib import Db, errors, fail, logging, ok, params
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aflib"
3
- version = "0.0.6"
3
+ version = "0.0.7"
4
4
  description = "Helper library for business logic inside Airflows plpython function bodies"
5
5
  readme = "README.md"
6
6
  # The platform runs Python 3.11.2 (observed, not assumed — see docs/platform-constraints.md).
@@ -33,9 +33,11 @@ Python exception.
33
33
 
34
34
  from aflib import (
35
35
  authz,
36
+ batch,
36
37
  codec,
37
38
  db,
38
39
  errors,
40
+ events,
39
41
  functions,
40
42
  identity,
41
43
  logging,
@@ -43,23 +45,30 @@ from aflib import (
43
45
  result,
44
46
  transaction,
45
47
  trigger,
48
+ workflow,
46
49
  )
50
+ from aflib.batch import Batch
47
51
  from aflib.db import Db
52
+ from aflib.events import Outbox
48
53
  from aflib.params import BadParameter
49
54
  from aflib.result import fail, ok
50
55
 
51
56
  # Kept in step with `pyproject.toml` by hand. A deployed body returns this so a run can
52
57
  # prove which aflib it actually imported — a stale package otherwise looks exactly like a
53
58
  # working one.
54
- __version__ = "0.0.6"
59
+ __version__ = "0.0.7"
55
60
 
56
61
  __all__ = [
57
62
  "BadParameter",
63
+ "Batch",
58
64
  "Db",
65
+ "Outbox",
59
66
  "authz",
67
+ "batch",
60
68
  "codec",
61
69
  "db",
62
70
  "errors",
71
+ "events",
63
72
  "fail",
64
73
  "functions",
65
74
  "identity",
@@ -69,5 +78,6 @@ __all__ = [
69
78
  "result",
70
79
  "transaction",
71
80
  "trigger",
81
+ "workflow",
72
82
  "__version__",
73
83
  ]
@@ -0,0 +1,141 @@
1
+ """Work through a backlog one committed chunk at a time.
2
+
3
+ from aflib.batch import Batch
4
+
5
+ batch = Batch(plpy)
6
+
7
+ progress = batch.run(
8
+ table="\\"Economic\\".\\"Receipt\\"",
9
+ set_clause='id_status = $1',
10
+ pending='id_status = $2 AND (period_from IS NULL OR period_from <= CURRENT_DATE + 5)',
11
+ args=[pending_id, planned_id],
12
+ types=["integer", "integer"],
13
+ limit=1000,
14
+ )
15
+
16
+ return ok({"done": progress.done, "remaining": progress.remaining})
17
+
18
+ One call is one chunk. The caller decides what drives the next one — a `pg_cron` job, the
19
+ next step of a workflow, a human pressing a button again — and each call is its own
20
+ transaction, so each chunk is **kept** whether or not the next one happens.
21
+
22
+ **Why a function and not a loop.** A plpgsql or plpython function cannot commit: it runs
23
+ inside a transaction it does not own, and `plpy.commit()` fails with *invalid transaction
24
+ termination*. There are no `PROCEDURE`s on the platform either (401 functions, zero
25
+ procedures, measured 2026-10-01), so the usual `LOOP … COMMIT` shape has nowhere to live.
26
+ A chunk per invocation is not a stylistic choice; it is the only way to commit progress.
27
+
28
+ **The one rule this exists to enforce: the pending predicate goes in two places.** It
29
+ selects the ids *and* it guards the update. Written the obvious way, with the predicate
30
+ only in the subquery, this is wrong:
31
+
32
+ UPDATE t SET … WHERE id IN (SELECT id FROM t WHERE pending LIMIT 1000) -- BROKEN
33
+
34
+ Under `READ COMMITTED` the subquery runs against the statement's snapshot, but when the
35
+ update reaches a row another transaction has since changed, Postgres re-reads the latest
36
+ committed version and applies the update to **that**. A row that was pending when selected
37
+ and is now cancelled gets updated anyway. On 2026-10-01 that aborted a production run on
38
+ its second chunk: a receipt was cancelled by a user mid-batch and the trigger refused the
39
+ `Cancelled → Pending` transition. Repeating the predicate in the outer `WHERE` makes
40
+ Postgres re-evaluate it against the new version and skip the row.
41
+
42
+ `run()` takes the predicate once and places it twice, so getting this wrong requires
43
+ bypassing the helper.
44
+
45
+ **`table`, `set_clause` and `pending` are SQL, and are interpolated.** They are code
46
+ written by the caller, not values — values go in `args` and are bound. Never build them
47
+ from anything a request supplied.
48
+
49
+ **Chunk size is measured, not chosen.** Cost per row depends on the triggers the write
50
+ fires, and the figure is not guessable: the same 1.000-row chunk took 19 s on
51
+ `Economic.Receipt`, whose triggers recalculate and audit, and 0.07 ms per row on a bare
52
+ table. Run one chunk, read `progress.seconds`, and size the rest so a chunk finishes well
53
+ inside the environment's `statement_timeout` — 180 s on PRO, no limit on PRE, so **PRE
54
+ cannot tell you whether a chunk fits**.
55
+ """
56
+
57
+ import time
58
+
59
+ from aflib.db import Db
60
+
61
+
62
+ class Progress(object):
63
+ """What one chunk did, and whether there is more.
64
+
65
+ `remaining` is counted **after** the update, in the same transaction, so it already
66
+ reflects this chunk. A caller that loops until `complete` therefore terminates without
67
+ needing to subtract anything itself.
68
+ """
69
+
70
+ __slots__ = ("done", "remaining", "seconds")
71
+
72
+ def __init__(self, done, remaining, seconds):
73
+ self.done = done
74
+ self.remaining = remaining
75
+ self.seconds = seconds
76
+
77
+ @property
78
+ def complete(self):
79
+ """True when nothing is left to do."""
80
+ return self.remaining == 0
81
+
82
+ def as_dict(self):
83
+ return {"done": self.done, "remaining": self.remaining,
84
+ "seconds": round(self.seconds, 3), "complete": self.complete}
85
+
86
+ def __repr__(self):
87
+ return "Progress(done=%r, remaining=%r, complete=%r)" % (
88
+ self.done, self.remaining, self.complete)
89
+
90
+
91
+ class Batch(object):
92
+ """One committed chunk per call. `plpy` is injected, as everywhere in aflib."""
93
+
94
+ def __init__(self, plpy):
95
+ self._db = Db(plpy)
96
+
97
+ ALIAS = "_t"
98
+
99
+ def run(self, table, set_clause, pending, args=None, types=None,
100
+ limit=1000, key="id", order=None):
101
+ """Update at most `limit` rows matching `pending`, and report what is left.
102
+
103
+ `pending` must be **unqualified** — write `id_status = $1`, not
104
+ `"Receipt".id_status = $1`. That is not a style rule: the same text is placed in
105
+ the outer `WHERE`, where Postgres resolves it against the update target, and
106
+ inside the subquery, where it resolves against the subquery's own `FROM`. Both
107
+ resolutions are what we want, and either qualification breaks one of them. A
108
+ predicate mentioning the alias is rejected rather than silently misplaced.
109
+
110
+ `order` defaults to `key`, which makes the work deterministic and the chunks
111
+ disjoint. Ordering by something non-unique would let two concurrent callers pick
112
+ overlapping sets; the row-level guard still keeps them correct, but they would
113
+ waste each other's work.
114
+ """
115
+ if self.ALIAS in pending:
116
+ raise ValueError(
117
+ "`pending` must be unqualified: it is placed both in the outer WHERE and "
118
+ "inside the subquery, and %r resolves in only one of them." % self.ALIAS)
119
+
120
+ args = list(args or [])
121
+ types = list(types or [])
122
+ order = order or key
123
+
124
+ started = time.time()
125
+ updated = self._db.query(
126
+ "UPDATE {table} AS {alias} SET {set_clause} "
127
+ "WHERE {pending} "
128
+ " AND {alias}.{key} IN (SELECT {key} FROM {table} WHERE {pending} "
129
+ " ORDER BY {order} LIMIT {limit}) "
130
+ "RETURNING {alias}.{key}".format(
131
+ table=table, alias=self.ALIAS, set_clause=set_clause,
132
+ pending=pending, key=key, order=order, limit=int(limit)),
133
+ args, types).rows()
134
+ elapsed = time.time() - started
135
+
136
+ remaining = self._db.query(
137
+ "SELECT count(*)::int AS n FROM {table} WHERE {pending}".format(
138
+ table=table, pending=pending),
139
+ args, types).scalar("n")
140
+
141
+ return Progress(len(updated), remaining, elapsed)
@@ -0,0 +1,176 @@
1
+ """A minimal outbox: emit one event, deliver it with retries.
2
+
3
+ from aflib.events import Outbox
4
+
5
+ box = Outbox(
6
+ plpy,
7
+ outbox="public.aflib_outbox",
8
+ delivery="public.aflib_delivery",
9
+ subscription="public.aflib_sub",
10
+ )
11
+ box.emit("matricula.finalizada", {"id_tuition": 321633}, 321633)
12
+
13
+ def deliver(subscriber, outbox_id, attempt):
14
+ functions.call(plpy, subscriber, [outbox_id, attempt], ["bigint", "integer"])
15
+
16
+ box.pump(deliver, limit=50)
17
+
18
+ The columns are the ones the probe created and ran on PRE, 2026-10-01. This module
19
+ does not create the tables.
20
+
21
+ - `outbox`: `id` bigint, `evento` text, `payload` jsonb, `aggregate_id` bigint
22
+ - `delivery`: `id` bigint, `id_outbox` bigint, `suscriptor` text,
23
+ `estado` text (`pending`, `done`, `dead`), `attempts` int,
24
+ `next_attempt_at` timestamptz, `last_error` text
25
+ - `subscription`: `evento` text, `suscriptor` text, `activo` boolean
26
+
27
+ `emit` inserts the event and one `pending` delivery per active subscriber, in the
28
+ caller's transaction. `pump` is one chunk: the caller invokes it again (`pg_cron`,
29
+ the next request) and each invocation is its own transaction.
30
+
31
+ A subscriber that raises must not take the others with it. The effect runs inside
32
+ `plpy.subtransaction()`. The attempt counter is written **after** that block, so a
33
+ failure rolls the effect back and still records the attempt. Writing the counter
34
+ inside the savepoint would roll the count back too, and the same delivery would
35
+ retry forever. A SQL `SAVEPOINT` is not the way to get that block:
36
+ `plpy.execute("savepoint s")` fails with `SPI_ERROR_TRANSACTION`.
37
+
38
+ `deliver` is called as `deliver(subscriber, outbox_id, attempt)`. The first attempt
39
+ is 1. A raise is a failed attempt. After `max_attempts` (3 on the run) the row
40
+ becomes `dead`. Before that, `next_attempt_at` moves forward by `2**attempt`
41
+ seconds, which is the backoff the probe wrote. `last_error` keeps the first 200
42
+ characters of the exception text.
43
+
44
+ Table names are caller-written SQL identifiers, quoted after a check. Event names,
45
+ payloads, ids and error text are bound.
46
+ """
47
+
48
+ import re
49
+
50
+ from aflib.codec import dumps
51
+ from aflib.db import Db
52
+
53
+
54
+ _IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
55
+ _ERROR_LIMIT = 200
56
+
57
+
58
+ def _quote_table(name):
59
+ """`"Schema"."table"` or `"table"`. Anything else is refused.
60
+
61
+ The name is interpolated, because an identifier cannot be a bound parameter.
62
+ Checking it first is what keeps a payload or an error string from being able
63
+ to ride along in that position.
64
+ """
65
+ parts = name.split(".")
66
+ if not 1 <= len(parts) <= 2 or not all(_IDENT.match(part) for part in parts):
67
+ raise ValueError(
68
+ "table name must be an identifier or schema.identifier, got {!r}".format(name)
69
+ )
70
+ return ".".join('"{}"'.format(part) for part in parts)
71
+
72
+
73
+ class Outbox(object):
74
+ """`emit` and `pump` against one trio of tables. `plpy` is injected."""
75
+
76
+ def __init__(self, plpy, outbox, delivery, subscription):
77
+ self._plpy = plpy
78
+ self._db = Db(plpy)
79
+ self._outbox = _quote_table(outbox)
80
+ self._delivery = _quote_table(delivery)
81
+ self._subscription = _quote_table(subscription)
82
+
83
+ def emit(self, event, payload, aggregate_id):
84
+ """One outbox row, plus a pending delivery for each active subscriber.
85
+
86
+ Returns `{"outbox": id, "deliveries": n}`. `payload` is encoded with
87
+ `codec.dumps` and bound as `jsonb`. `aggregate_id` is a bigint, the column
88
+ the probe declared.
89
+ """
90
+ if isinstance(aggregate_id, bool) or not isinstance(aggregate_id, int):
91
+ raise ValueError("aggregate_id must be an int, got {!r}".format(aggregate_id))
92
+
93
+ created = self._db.query(
94
+ "INSERT INTO {} (evento, payload, aggregate_id) "
95
+ "VALUES ($1, $2, $3) RETURNING id".format(self._outbox),
96
+ [event, dumps(payload), aggregate_id],
97
+ ["text", "jsonb", "bigint"],
98
+ ).one()
99
+ deliveries = self._db.query(
100
+ "INSERT INTO {} (id_outbox, suscriptor) "
101
+ "SELECT $1, suscriptor FROM {} "
102
+ "WHERE evento = $2 AND activo "
103
+ "RETURNING id".format(self._delivery, self._subscription),
104
+ [created["id"], event],
105
+ ["bigint", "text"],
106
+ ).rows()
107
+ return {"outbox": created["id"], "deliveries": len(deliveries)}
108
+
109
+ def pump(self, deliver, limit, max_attempts=3):
110
+ """Deliver up to `limit` due rows. One subscriber's failure stops at that row.
111
+
112
+ Returns `{"seen", "delivered", "retrying", "dead_letter"}`. `limit` is at
113
+ least 1. `max_attempts` defaults to 3, the count the probe dead-lettered on.
114
+ """
115
+ limit = int(limit)
116
+ max_attempts = int(max_attempts)
117
+ if limit < 1:
118
+ raise ValueError("limit must be at least 1, got {}".format(limit))
119
+ if max_attempts < 1:
120
+ raise ValueError("max_attempts must be at least 1, got {}".format(max_attempts))
121
+
122
+ due = self._db.query(
123
+ "SELECT id, id_outbox, suscriptor, attempts FROM {} "
124
+ "WHERE estado = 'pending' AND next_attempt_at <= now() "
125
+ "ORDER BY id LIMIT $1".format(self._delivery),
126
+ [limit],
127
+ ["integer"],
128
+ ).rows()
129
+
130
+ delivered = retrying = dead = 0
131
+ for row in due:
132
+ attempt = row["attempts"] + 1
133
+ try:
134
+ with self._plpy.subtransaction():
135
+ deliver(row["suscriptor"], row["id_outbox"], attempt)
136
+ self._mark_done(row["id"], attempt)
137
+ delivered += 1
138
+ except Exception as exc:
139
+ message = str(exc)[:_ERROR_LIMIT]
140
+ if attempt >= max_attempts:
141
+ self._mark_dead(row["id"], attempt, message)
142
+ dead += 1
143
+ else:
144
+ self._mark_retry(row["id"], attempt, message)
145
+ retrying += 1
146
+ return {
147
+ "seen": len(due),
148
+ "delivered": delivered,
149
+ "retrying": retrying,
150
+ "dead_letter": dead,
151
+ }
152
+
153
+ def _mark_done(self, delivery_id, attempt):
154
+ self._db.query(
155
+ "UPDATE {} SET estado = 'done', attempts = $1, last_error = NULL "
156
+ "WHERE id = $2".format(self._delivery),
157
+ [attempt, delivery_id],
158
+ ["integer", "bigint"],
159
+ )
160
+
161
+ def _mark_dead(self, delivery_id, attempt, message):
162
+ self._db.query(
163
+ "UPDATE {} SET estado = 'dead', attempts = $1, last_error = $2 "
164
+ "WHERE id = $3".format(self._delivery),
165
+ [attempt, message, delivery_id],
166
+ ["integer", "text", "bigint"],
167
+ )
168
+
169
+ def _mark_retry(self, delivery_id, attempt, message):
170
+ self._db.query(
171
+ "UPDATE {} SET attempts = $1, last_error = $2, "
172
+ "next_attempt_at = now() + (interval '1 second' * $3) "
173
+ "WHERE id = $4".format(self._delivery),
174
+ [attempt, message, 2**attempt, delivery_id],
175
+ ["integer", "text", "integer", "bigint"],
176
+ )
@@ -0,0 +1,161 @@
1
+ """Start a workflow instance and see whether it is moving.
2
+
3
+ from aflib import workflow
4
+
5
+ started = workflow.start(plpy, "WFP_cadena", "case-58", {
6
+ "wf_probe_p1.p_entrada": ("STRING", "desde-aflib"),
7
+ "saltar": ("BOOLEAN", False),
8
+ })
9
+ state = workflow.status(plpy, "case-58")
10
+ wedged = workflow.stuck(plpy, "1 hour")
11
+
12
+ `start` inserts one row into `Workflows.ProcessInstance` and one `Variable` row per
13
+ entry, in the caller's transaction. The engine picks the instance up on its own poll
14
+ and runs it outside that transaction. Observed on PRE, 2026-10-01: an insert with
15
+ status `PENDING` was `IN_PROGRESS` within seconds and `FINISHED` without this
16
+ function doing anything further.
17
+
18
+ Three things about that insert are easy to get wrong, and all three were paid for
19
+ on that run:
20
+
21
+ - The engine reads a function argument from a variable named `<function>.<parameter>`.
22
+ This module stores the name it is given. `saltar` is a real name from that run: a
23
+ transition condition reads whatever name is written on it, prefix or none.
24
+ - A function that needs its own instance id has to receive it as one of these
25
+ variables. The engine does not pass the id.
26
+ - An activity with one outgoing transition that has no condition takes that
27
+ transition and leaves the conditional ones `PENDING` forever. An instance that
28
+ cannot move stays `PENDING` or `IN_PROGRESS`, with no error and no timeout.
29
+ `stuck` exists to list those.
30
+
31
+ Values are bound. The only SQL literals are the enum labels the probe inserted
32
+ (`PENDING`, `STRING`, `INTEGER`, `BOOLEAN`), which a bound `text` parameter does
33
+ not cast to the engine's enum.
34
+ """
35
+
36
+ from aflib.db import Db
37
+
38
+
39
+ # Column and bind type for the three variable kinds the probe body wrote.
40
+ # LONG, DOUBLE and DATE exist on the engine and were not in that body.
41
+ _VALUE = {
42
+ "STRING": ("valueString", "text"),
43
+ "INTEGER": ("valueNumber", "integer"),
44
+ "BOOLEAN": ("valueBoolean", "boolean"),
45
+ }
46
+
47
+ _PROCESS = 'SELECT id FROM "Workflows"."Process" WHERE name = $1'
48
+
49
+ _INSTANCE = (
50
+ 'INSERT INTO "Workflows"."ProcessInstance" '
51
+ '(process, "businessKey", "creationDate", status) '
52
+ "VALUES ($1, $2, now(), 'PENDING') "
53
+ "RETURNING id"
54
+ )
55
+
56
+ _STATUS = (
57
+ "SELECT pi.id, pi.status::text AS status, "
58
+ '(SELECT a.name FROM "Workflows"."ActivityInstance" ai '
59
+ ' JOIN "Workflows"."Activity" a ON a.id = ai.activity '
60
+ ' WHERE ai."processInstance" = pi.id '
61
+ " AND ai.status::text = 'IN_PROGRESS' "
62
+ " LIMIT 1) AS current, "
63
+ '(SELECT ai.result FROM "Workflows"."ActivityInstance" ai '
64
+ ' WHERE ai."processInstance" = pi.id '
65
+ " AND ai.status::text = 'ERROR' "
66
+ " LIMIT 1) AS last_error "
67
+ 'FROM "Workflows"."ProcessInstance" pi '
68
+ 'WHERE pi."businessKey" = $1 '
69
+ "ORDER BY pi.id DESC "
70
+ "LIMIT 1"
71
+ )
72
+
73
+ _STUCK = (
74
+ "SELECT pi.id, pi.\"businessKey\" AS business_key, pi.status::text AS status, "
75
+ 'round(extract(epoch FROM (now() - pi."creationDate")))::int AS seconds, '
76
+ '(SELECT a.name FROM "Workflows"."ActivityInstance" ai '
77
+ ' JOIN "Workflows"."Activity" a ON a.id = ai.activity '
78
+ ' WHERE ai."processInstance" = pi.id '
79
+ " AND ai.status::text = 'IN_PROGRESS' "
80
+ " LIMIT 1) AS stuck_at "
81
+ 'FROM "Workflows"."ProcessInstance" pi '
82
+ "WHERE pi.status::text IN ('IN_PROGRESS', 'PENDING') "
83
+ ' AND pi."creationDate" < now() - $1::interval '
84
+ "ORDER BY pi.id"
85
+ )
86
+
87
+
88
+ def _checked(variables):
89
+ """Every variable is well-formed before the first insert.
90
+
91
+ A bad type raised halfway through the probe's loop. A body that catches the
92
+ exception and returns a failure envelope would then commit the instance with
93
+ only some of its variables, because a returned failure does not roll back.
94
+ Checking first means a bad call writes nothing.
95
+ """
96
+ if variables is None:
97
+ return []
98
+ if not isinstance(variables, dict):
99
+ raise ValueError("variables must be a dict of name to (type, value)")
100
+ checked = []
101
+ for name, spec in variables.items():
102
+ if not isinstance(spec, tuple) or len(spec) != 2:
103
+ raise ValueError("variable {!r} must be (type, value)".format(name))
104
+ kind, value = spec
105
+ if kind not in _VALUE:
106
+ raise ValueError(
107
+ "variable {!r} has type {!r}; this module writes STRING, INTEGER "
108
+ "and BOOLEAN".format(name, kind)
109
+ )
110
+ checked.append((name, kind, value))
111
+ return checked
112
+
113
+
114
+ def start(plpy, process, business_key, variables=None):
115
+ """Insert a `PENDING` instance and its variables. Return its id.
116
+
117
+ `variables` maps a name to `(type, value)`. `type` is `STRING`, `INTEGER` or
118
+ `BOOLEAN`. Returns `{"instance": id, "variables": n}`. Raises `ValueError` when
119
+ `process` is not a row in `Workflows.Process`, and writes nothing in that case.
120
+ """
121
+ checked = _checked(variables)
122
+ db = Db(plpy)
123
+ found = db.query(_PROCESS, [process], ["text"]).one()
124
+ if found is None:
125
+ raise ValueError("no workflow process named {!r}".format(process))
126
+
127
+ created = db.query(
128
+ _INSTANCE, [found["id"], business_key], ["integer", "text"]
129
+ ).one()
130
+ instance_id = created["id"]
131
+ for name, kind, value in checked:
132
+ column, argtype = _VALUE[kind]
133
+ db.query(
134
+ 'INSERT INTO "Workflows"."Variable" '
135
+ '(name, "processInstance", type, "{}") '
136
+ "VALUES ($1, $2, '{}', $3)".format(column, kind),
137
+ [name, instance_id, value],
138
+ ["text", "integer", argtype],
139
+ )
140
+ return {"instance": instance_id, "variables": len(checked)}
141
+
142
+
143
+ def status(plpy, business_key):
144
+ """The newest instance with this business key, or `None`.
145
+
146
+ Keys are `id`, `status`, `current` (the activity `IN_PROGRESS`, or `None`) and
147
+ `last_error` (the `result` of an `ERROR` activity, or `None`). Several instances
148
+ may share a key; the highest id is the one a later start created.
149
+ """
150
+ return Db(plpy).query(_STATUS, [business_key], ["text"]).one()
151
+
152
+
153
+ def stuck(plpy, older_than):
154
+ """Live instances older than `older_than`, a Postgres interval string.
155
+
156
+ `"1 hour"` is the value the probe passed. The result rows have `id`,
157
+ `business_key`, `status`, `seconds` and `stuck_at`.
158
+ """
159
+ if not isinstance(older_than, str) or not older_than:
160
+ raise ValueError("older_than must be an interval string, such as '1 hour'")
161
+ return Db(plpy).query(_STUCK, [older_than], ["text"]).rows()
@@ -70,9 +70,15 @@ class FakePlpy:
70
70
 
71
71
  Error = FakeError
72
72
 
73
- def __init__(self, rows=None, colnames=None):
73
+ def __init__(self, rows=None, colnames=None, results=None):
74
74
  self.rows = rows if rows is not None else []
75
75
  self.colnames = colnames
76
+ # A queue of result sets, consumed one per execute(), for the callers that run
77
+ # more than one statement. `batch.run` is the first: an UPDATE … RETURNING and a
78
+ # count, which cannot both be the same canned rows because `scalar` refuses more
79
+ # than one row. When the queue is empty or absent, every execute() gets `rows`,
80
+ # which is what every earlier test relies on.
81
+ self.results = list(results) if results is not None else None
76
82
  self.executed = []
77
83
  self.prepared = []
78
84
  # Every sink is recorded SEPARATELY, because which one a line went to is the
@@ -132,6 +138,9 @@ class FakePlpy:
132
138
  def execute(self, sql_or_plan, args=None):
133
139
  self.executed.append((sql_or_plan, args))
134
140
  self.events.append("execute")
141
+ if self.results:
142
+ rows, colnames = self.results.pop(0)
143
+ return FakeResult(rows, colnames)
135
144
  return FakeResult(self.rows, self.colnames)
136
145
 
137
146
 
@@ -166,7 +175,7 @@ def make_plpy():
166
175
  from conftest directly does not work — `tests/` is not a package.
167
176
  """
168
177
 
169
- def _make(rows=None, colnames=None):
170
- return FakePlpy(rows=rows, colnames=colnames)
178
+ def _make(rows=None, colnames=None, results=None):
179
+ return FakePlpy(rows=rows, colnames=colnames, results=results)
171
180
 
172
181
  return _make
@@ -0,0 +1,145 @@
1
+ """What `batch` must do, and the one mistake it exists to prevent.
2
+
3
+ Grounded in a production run on 2026-10-01: 16.207 receipts moved from `Planned` to
4
+ `Pending` in 15 chunks of 1.000, at ~19 s per chunk against a 180 s `statement_timeout`.
5
+ The second chunk of the first attempt **aborted**, because the predicate was only in the
6
+ subquery and a user cancelled a receipt mid-batch: the update reached the re-read row and
7
+ the trigger refused `Cancelled → Pending`. Repeating the predicate in the outer `WHERE`
8
+ fixed it and the remaining 15 chunks ran clean.
9
+
10
+ So the load-bearing assertion here is about the **shape of the SQL**, not about rows. The
11
+ `plpy` double cannot prove the concurrency behaviour — only a real database can, and it
12
+ did — but it can prove the predicate is placed twice, which is what makes the behaviour
13
+ reachable.
14
+
15
+ Probe: `ec-airflows` `Master.aflib_probe_batch`, case `carrera`: a competitor cancels three
16
+ rows the chunk believes pending, and `canceladas_pisadas` comes back 0.
17
+ """
18
+
19
+ import pytest
20
+
21
+ from aflib.batch import Batch, Progress
22
+
23
+ PENDING = "id_status = $1"
24
+ TABLE = '"Economic"."Receipt"'
25
+ SET = "id_status = $2"
26
+
27
+
28
+ def _sql(entry):
29
+ """The SQL of one recorded execute.
30
+
31
+ With `types`, `Db` goes through `plpy.prepare`, so what the double recorded is the
32
+ plan tuple `("PLAN", sql, argtypes)` rather than the string. Reaching through it here
33
+ keeps the assertions about the statement, and incidentally proves the values really are
34
+ bound: an interpolating implementation would never reach `prepare` at all.
35
+ """
36
+ target = entry[0]
37
+ return target[1] if isinstance(target, tuple) else target
38
+
39
+
40
+ def _plpy(make_plpy, updated, remaining):
41
+ """Two result sets: the UPDATE … RETURNING, then the count."""
42
+ return make_plpy(results=[
43
+ ([{"id": n} for n in range(updated)], ["id"]),
44
+ ([{"n": remaining}], ["n"]),
45
+ ])
46
+
47
+
48
+ class TestThePredicateIsPlacedTwice:
49
+ """The whole point. A single placement is the bug that aborted a production run."""
50
+
51
+ def test_predicate_appears_in_the_outer_where_and_in_the_subquery(self, make_plpy):
52
+ fake = _plpy(make_plpy, 2, 8)
53
+ Batch(fake).run(TABLE, SET, PENDING, args=[1, 2], types=["integer"] * 2, limit=10)
54
+
55
+ sql = _sql(fake.executed[0])
56
+ assert sql.count(PENDING) == 2, (
57
+ "the predicate must guard the update AND select the ids; one placement lets "
58
+ "Postgres re-read a changed row and update it anyway (observed in production)"
59
+ )
60
+
61
+ def test_the_outer_placement_guards_the_updated_row(self, make_plpy):
62
+ fake = _plpy(make_plpy, 1, 0)
63
+ Batch(fake).run(TABLE, SET, PENDING, args=[1, 2], types=["integer"] * 2)
64
+
65
+ sql = _sql(fake.executed[0])
66
+ where = sql.split("SET")[1].split("AND")[0]
67
+ assert PENDING in where, "the first predicate must sit in the UPDATE's own WHERE"
68
+
69
+ def test_a_qualified_predicate_is_refused_rather_than_misplaced(self, make_plpy):
70
+ # It would resolve in the outer WHERE and break inside the subquery, so the
71
+ # helper must not accept it and quietly produce a statement that fails at runtime.
72
+ with pytest.raises(ValueError) as exc:
73
+ Batch(make_plpy()).run(TABLE, SET, "_t.id_status = $1")
74
+ assert "unqualified" in str(exc.value)
75
+
76
+
77
+ class TestChunkBoundaries:
78
+ def test_limit_bounds_the_subquery_not_the_update(self, make_plpy):
79
+ fake = _plpy(make_plpy, 5, 0)
80
+ Batch(fake).run(TABLE, SET, PENDING, args=[1, 2], types=["integer"] * 2, limit=5)
81
+
82
+ sql = _sql(fake.executed[0])
83
+ assert "LIMIT 5" in sql
84
+ assert sql.index("LIMIT 5") > sql.index("SELECT"), "the LIMIT belongs to the subquery"
85
+
86
+ def test_ordering_defaults_to_the_key_so_chunks_are_disjoint(self, make_plpy):
87
+ fake = _plpy(make_plpy, 1, 0)
88
+ Batch(fake).run(TABLE, SET, PENDING, args=[1, 2], types=["integer"] * 2, key="id")
89
+ assert "ORDER BY id" in _sql(fake.executed[0])
90
+
91
+ def test_limit_is_coerced_so_it_cannot_carry_sql(self, make_plpy):
92
+ fake = _plpy(make_plpy, 0, 0)
93
+ Batch(fake).run(TABLE, SET, PENDING, args=[1, 2], types=["integer"] * 2, limit="10")
94
+ assert "LIMIT 10" in _sql(fake.executed[0])
95
+
96
+
97
+ class TestProgress:
98
+ def test_done_is_what_the_update_returned(self, make_plpy):
99
+ p = Batch(_plpy(make_plpy, 7, 3)).run(TABLE, SET, PENDING, args=[1, 2],
100
+ types=["integer"] * 2)
101
+ assert p.done == 7
102
+
103
+ def test_remaining_is_counted_after_the_update(self, make_plpy):
104
+ # Counted in the same transaction, so it already excludes this chunk. A caller
105
+ # looping until `complete` therefore terminates without subtracting anything.
106
+ p = Batch(_plpy(make_plpy, 7, 3)).run(TABLE, SET, PENDING, args=[1, 2],
107
+ types=["integer"] * 2)
108
+ assert p.remaining == 3
109
+ assert p.complete is False
110
+
111
+ def test_complete_when_nothing_is_left(self, make_plpy):
112
+ p = Batch(_plpy(make_plpy, 2, 0)).run(TABLE, SET, PENDING, args=[1, 2],
113
+ types=["integer"] * 2)
114
+ assert p.complete is True
115
+
116
+ def test_reports_its_own_duration_because_chunk_size_is_measured(self, make_plpy):
117
+ # Cost per row is not guessable: the same 1.000-row chunk took 19 s on
118
+ # Economic.Receipt and 0.07 ms per row on a bare table. The caller needs the
119
+ # number to size the next chunk against the environment's statement_timeout.
120
+ p = Batch(_plpy(make_plpy, 1, 0)).run(TABLE, SET, PENDING, args=[1, 2],
121
+ types=["integer"] * 2)
122
+ assert p.seconds >= 0
123
+ assert "seconds" in p.as_dict()
124
+
125
+ def test_as_dict_is_serialisable_for_a_result_envelope(self, make_plpy):
126
+ d = Batch(_plpy(make_plpy, 4, 1)).run(TABLE, SET, PENDING, args=[1, 2],
127
+ types=["integer"] * 2).as_dict()
128
+ assert d == {"done": 4, "remaining": 1, "seconds": d["seconds"], "complete": False}
129
+
130
+
131
+ class TestBindings:
132
+ def test_values_are_bound_not_interpolated(self, make_plpy):
133
+ fake = _plpy(make_plpy, 1, 0)
134
+ Batch(fake).run(TABLE, SET, PENDING, args=[29, 49], types=["integer", "integer"])
135
+ for entry in fake.executed:
136
+ sql = _sql(entry)
137
+ assert "29" not in sql and "49" not in sql, "values belong in args, not in the SQL"
138
+ assert entry[1] is not None
139
+
140
+ def test_the_count_query_gets_the_same_bindings(self, make_plpy):
141
+ # The predicate is the same text, so it needs the same parameters — a count that
142
+ # dropped them would fail with "there is no parameter $1".
143
+ fake = _plpy(make_plpy, 1, 0)
144
+ Batch(fake).run(TABLE, SET, PENDING, args=[29, 49], types=["integer", "integer"])
145
+ assert fake.executed[0][1] == fake.executed[1][1]
@@ -0,0 +1,184 @@
1
+ """events.emit / pump, as the PRE probe ran them.
2
+
3
+ Probe: `Master.aflib_probe_events`, marker `aflib-events-r3`. One subscriber
4
+ delivered on attempt 1, one failed twice and delivered on attempt 3, one
5
+ dead-lettered at attempt 3, and each effect landed once. The first body failed
6
+ before that: `plpy.execute("savepoint s")` raised `SPI_ERROR_TRANSACTION`.
7
+ """
8
+
9
+ import pytest
10
+
11
+ from aflib.codec import dumps
12
+ from aflib.events import Outbox
13
+
14
+
15
+ OUTBOX = "public.aflib_outbox"
16
+ DELIVERY = "public.aflib_delivery"
17
+ SUBSCRIPTION = "public.aflib_sub"
18
+
19
+
20
+ def _box(fake):
21
+ return Outbox(fake, OUTBOX, DELIVERY, SUBSCRIPTION)
22
+
23
+
24
+ def _sql(entry):
25
+ target = entry[0]
26
+ return target[1] if isinstance(target, tuple) else target
27
+
28
+
29
+ def _args(entry):
30
+ return entry[1]
31
+
32
+
33
+ class TestEmit:
34
+ def test_inserts_the_event_and_one_delivery_per_active_subscriber(self, make_plpy):
35
+ fake = make_plpy(results=[
36
+ ([{"id": 1}], ["id"]),
37
+ ([{"id": 10}, {"id": 11}, {"id": 12}], ["id"]),
38
+ ])
39
+ out = _box(fake).emit("matricula.finalizada", {"id_tuition": 321633}, 321633)
40
+
41
+ assert out == {"outbox": 1, "deliveries": 3}
42
+ event_sql, delivery_sql = (_sql(e) for e in fake.executed)
43
+ assert '"public"."aflib_outbox"' in event_sql
44
+ assert '"public"."aflib_delivery"' in delivery_sql
45
+ assert '"public"."aflib_sub"' in delivery_sql
46
+ assert "activo" in delivery_sql
47
+ assert _args(fake.executed[0]) == [
48
+ "matricula.finalizada", dumps({"id_tuition": 321633}), 321633,
49
+ ]
50
+ assert fake.prepared[0][2] == ("text", "jsonb", "bigint")
51
+ assert _args(fake.executed[1]) == [1, "matricula.finalizada"]
52
+
53
+ def test_a_quote_in_the_event_name_is_bound(self, make_plpy):
54
+ fake = make_plpy(results=[
55
+ ([{"id": 1}], ["id"]),
56
+ ([], ["id"]),
57
+ ])
58
+ _box(fake).emit("it's", {"a": 1}, 1)
59
+ for entry in fake.executed:
60
+ assert "it's" not in _sql(entry)
61
+
62
+ def test_aggregate_id_must_be_an_int(self, make_plpy):
63
+ fake = make_plpy()
64
+ with pytest.raises(ValueError, match="aggregate_id"):
65
+ _box(fake).emit("e", {}, True)
66
+ assert fake.executed == []
67
+
68
+ def test_a_table_name_that_is_not_an_identifier_is_refused(self, make_plpy):
69
+ with pytest.raises(ValueError, match="identifier"):
70
+ Outbox(make_plpy(), "public.aflib_outbox", "bad name", "public.aflib_sub")
71
+
72
+
73
+ class TestPump:
74
+ def _due(self, make_plpy, rows):
75
+ return make_plpy(results=[(rows, ["id", "id_outbox", "suscriptor", "attempts"])])
76
+
77
+ def test_a_success_commits_the_effect_then_marks_done(self, make_plpy):
78
+ fake = self._due(make_plpy, [
79
+ {"id": 10, "id_outbox": 1, "suscriptor": "bien", "attempts": 0},
80
+ ])
81
+ seen = []
82
+
83
+ def deliver(subscriber, outbox_id, attempt):
84
+ seen.append((subscriber, outbox_id, attempt))
85
+
86
+ out = _box(fake).pump(deliver, limit=50)
87
+
88
+ assert out == {"seen": 1, "delivered": 1, "retrying": 0, "dead_letter": 0}
89
+ assert seen == [("bien", 1, 1)]
90
+ assert fake.events == ["execute", "subtx_enter", "subtx_exit_commit", "execute"]
91
+ update = _sql(fake.executed[1])
92
+ assert "estado = 'done'" in update
93
+ assert _args(fake.executed[1]) == [1, 10]
94
+
95
+ def test_a_failure_rolls_back_and_still_records_the_attempt(self, make_plpy):
96
+ fake = self._due(make_plpy, [
97
+ {"id": 10, "id_outbox": 1, "suscriptor": "falla", "attempts": 0},
98
+ {"id": 11, "id_outbox": 1, "suscriptor": "bien", "attempts": 0},
99
+ ])
100
+ called = []
101
+
102
+ def deliver(subscriber, outbox_id, attempt):
103
+ called.append(subscriber)
104
+ if subscriber == "falla":
105
+ raise RuntimeError("it's broken")
106
+
107
+ out = _box(fake).pump(deliver, limit=50)
108
+
109
+ assert called == ["falla", "bien"]
110
+ assert out["delivered"] == 1
111
+ assert out["retrying"] == 1
112
+ assert fake.events[1:4] == ["subtx_enter", "subtx_exit_rollback", "execute"]
113
+ retry = _sql(fake.executed[1])
114
+ assert "interval '1 second'" in retry
115
+ assert "it's broken" not in retry
116
+ # attempt 1 waits 2**1 seconds. The message is bound, quotes included.
117
+ assert _args(fake.executed[1]) == [1, "it's broken", 2, 10]
118
+
119
+ def test_the_third_failure_dead_letters_without_a_backoff(self, make_plpy):
120
+ fake = self._due(make_plpy, [
121
+ {"id": 12, "id_outbox": 1, "suscriptor": "siempre_falla", "attempts": 2},
122
+ ])
123
+
124
+ def deliver(subscriber, outbox_id, attempt):
125
+ raise RuntimeError("fallo permanente")
126
+
127
+ out = _box(fake).pump(deliver, limit=50)
128
+
129
+ assert out["dead_letter"] == 1
130
+ assert out["retrying"] == 0
131
+ update = _sql(fake.executed[1])
132
+ assert "estado = 'dead'" in update
133
+ assert "interval" not in update
134
+ assert _args(fake.executed[1]) == [3, "fallo permanente", 12]
135
+
136
+ def test_the_second_failure_waits_four_seconds(self, make_plpy):
137
+ fake = self._due(make_plpy, [
138
+ {"id": 11, "id_outbox": 1, "suscriptor": "falla_dos_veces", "attempts": 1},
139
+ ])
140
+
141
+ def deliver(subscriber, outbox_id, attempt):
142
+ raise RuntimeError("transitorio")
143
+
144
+ _box(fake).pump(deliver, limit=50)
145
+ assert _args(fake.executed[1])[2] == 4
146
+
147
+ def test_plpy_error_is_a_failed_attempt_not_an_escaping_exception(self, make_plpy):
148
+ fake = self._due(make_plpy, [
149
+ {"id": 10, "id_outbox": 1, "suscriptor": "siempre_falla", "attempts": 0},
150
+ ])
151
+
152
+ def deliver(subscriber, outbox_id, attempt):
153
+ fake.error("fallo permanente")
154
+
155
+ out = _box(fake).pump(deliver, limit=50)
156
+ assert out["retrying"] == 1
157
+ assert _args(fake.executed[1])[1] == "fallo permanente"
158
+
159
+ def test_last_error_keeps_the_first_200_characters(self, make_plpy):
160
+ fake = self._due(make_plpy, [
161
+ {"id": 10, "id_outbox": 1, "suscriptor": "falla", "attempts": 0},
162
+ ])
163
+ text = "x" * 250
164
+
165
+ def deliver(subscriber, outbox_id, attempt):
166
+ raise RuntimeError(text)
167
+
168
+ _box(fake).pump(deliver, limit=50)
169
+ assert _args(fake.executed[1])[1] == "x" * 200
170
+
171
+ def test_limit_is_bound(self, make_plpy):
172
+ fake = self._due(make_plpy, [])
173
+ out = _box(fake).pump(lambda *a: None, limit=50)
174
+ assert out == {"seen": 0, "delivered": 0, "retrying": 0, "dead_letter": 0}
175
+ assert "LIMIT $1" in _sql(fake.executed[0])
176
+ assert _args(fake.executed[0]) == [50]
177
+ assert "savepoint" not in _sql(fake.executed[0]).lower()
178
+
179
+ def test_due_rows_are_the_pending_ones_whose_time_has_come(self, make_plpy):
180
+ fake = self._due(make_plpy, [])
181
+ _box(fake).pump(lambda *a: None, limit=1)
182
+ sql = _sql(fake.executed[0])
183
+ assert "estado = 'pending'" in sql
184
+ assert "next_attempt_at <= now()" in sql
@@ -73,6 +73,18 @@ def test_exports_trigger_functions_and_identity():
73
73
  assert "trigger" in aflib.__all__
74
74
 
75
75
 
76
+ def test_exports_workflow_and_events():
77
+ from aflib import events, workflow
78
+
79
+ assert workflow.start is not None
80
+ assert workflow.status is not None
81
+ assert workflow.stuck is not None
82
+ assert events.Outbox is aflib.Outbox
83
+ assert "workflow" in aflib.__all__
84
+ assert "events" in aflib.__all__
85
+ assert "Outbox" in aflib.__all__
86
+
87
+
76
88
  def test_exports_bad_parameter_directly():
77
89
  # A body catches this by name. Re-exported so `from aflib import BadParameter` works
78
90
  # without knowing which module defines it — it moves to an `errors` module in slice 2,
@@ -0,0 +1,133 @@
1
+ """workflow.start / status / stuck, as the PRE probe ran them.
2
+
3
+ Probe: `Master.aflib_probe_wf`, marker `aflib-wf-r1`. It started instance 58 of
4
+ `WFP_cadena`, followed it to FINISHED, and `stuck` returned an empty list. The
5
+ variable rows on that call were three STRING values and one BOOLEAN.
6
+ """
7
+
8
+ import pytest
9
+
10
+ from aflib.workflow import start, status, stuck
11
+
12
+
13
+ def _sql(entry):
14
+ target = entry[0]
15
+ return target[1] if isinstance(target, tuple) else target
16
+
17
+
18
+ def _args(entry):
19
+ return entry[1]
20
+
21
+
22
+ class TestStart:
23
+ def test_inserts_a_pending_instance_and_its_variables(self, make_plpy):
24
+ fake = make_plpy(results=[
25
+ ([{"id": 6}], ["id"]),
26
+ ([{"id": 58}], ["id"]),
27
+ ])
28
+ out = start(fake, "WFP_cadena", "case-58", {
29
+ "wf_probe_p1.p_entrada": ("STRING", "desde-aflib"),
30
+ "saltar": ("BOOLEAN", False),
31
+ })
32
+
33
+ assert out == {"instance": 58, "variables": 2}
34
+ process, instance, first, second = (_sql(e) for e in fake.executed)
35
+ assert '"Workflows"."Process"' in process
36
+ assert "PENDING" in instance
37
+ assert '"businessKey"' in instance
38
+ assert "valueString" in first
39
+ assert "'STRING'" in first
40
+ assert "valueBoolean" in second
41
+ assert "'BOOLEAN'" in second
42
+
43
+ def test_values_are_bound_and_a_boolean_stays_a_boolean(self, make_plpy):
44
+ fake = make_plpy(results=[
45
+ ([{"id": 6}], ["id"]),
46
+ ([{"id": 58}], ["id"]),
47
+ ])
48
+ start(fake, "WFP_cadena", "o'hara", {
49
+ "wf_probe_p1.p_entrada": ("STRING", "desde-aflib"),
50
+ "saltar": ("BOOLEAN", False),
51
+ })
52
+
53
+ assert _args(fake.executed[0]) == ["WFP_cadena"]
54
+ assert _args(fake.executed[1]) == [6, "o'hara"]
55
+ assert _args(fake.executed[3]) == ["saltar", 58, False]
56
+ assert fake.prepared[3][2][-1] == "boolean"
57
+ for entry in fake.executed:
58
+ assert "o'hara" not in _sql(entry)
59
+ assert "desde-aflib" not in _sql(entry)
60
+
61
+ def test_an_integer_variable_uses_the_number_column(self, make_plpy):
62
+ fake = make_plpy(results=[
63
+ ([{"id": 6}], ["id"]),
64
+ ([{"id": 58}], ["id"]),
65
+ ])
66
+ start(fake, "WFP_cadena", "case-58", {"paso": ("INTEGER", 3)})
67
+
68
+ sql = _sql(fake.executed[2])
69
+ assert "valueNumber" in sql
70
+ assert "'INTEGER'" in sql
71
+ assert _args(fake.executed[2]) == ["paso", 58, 3]
72
+ assert fake.prepared[2][2][-1] == "integer"
73
+
74
+ def test_a_missing_process_writes_nothing_else(self, make_plpy):
75
+ fake = make_plpy(results=[([], ["id"])])
76
+ with pytest.raises(ValueError, match="no workflow process"):
77
+ start(fake, "missing", "case-58", {"saltar": ("BOOLEAN", False)})
78
+ assert len(fake.executed) == 1
79
+
80
+ def test_a_bad_type_is_refused_before_any_insert(self, make_plpy):
81
+ fake = make_plpy()
82
+ with pytest.raises(ValueError, match="STRING, INTEGER"):
83
+ start(fake, "WFP_cadena", "case-58", {"cuando": ("DATE", "2026-10-01")})
84
+ assert fake.executed == []
85
+
86
+ def test_no_variables_still_inserts_the_instance(self, make_plpy):
87
+ fake = make_plpy(results=[
88
+ ([{"id": 6}], ["id"]),
89
+ ([{"id": 9}], ["id"]),
90
+ ])
91
+ assert start(fake, "WFP_cadena", "case-9") == {"instance": 9, "variables": 0}
92
+ assert len(fake.executed) == 2
93
+
94
+
95
+ class TestStatus:
96
+ def test_returns_the_row_the_query_names(self, make_plpy):
97
+ row = {"id": 58, "status": "FINISHED", "current": None, "last_error": None}
98
+ fake = make_plpy(results=[([row], list(row))])
99
+ assert status(fake, "case-58") == row
100
+
101
+ def test_none_when_the_key_has_no_instance(self, make_plpy):
102
+ fake = make_plpy(results=[([], ["id", "status", "current", "last_error"])])
103
+ assert status(fake, "missing") is None
104
+
105
+ def test_the_newest_instance_wins_when_a_key_is_reused(self, make_plpy):
106
+ fake = make_plpy(results=[([], ["id", "status", "current", "last_error"])])
107
+ status(fake, "case-58")
108
+ sql = _sql(fake.executed[0])
109
+ assert "ORDER BY pi.id DESC" in sql
110
+ assert "LIMIT 1" in sql
111
+ assert _args(fake.executed[0]) == ["case-58"]
112
+
113
+
114
+ class TestStuck:
115
+ def test_selects_live_instances_older_than_a_bound_interval(self, make_plpy):
116
+ row = {
117
+ "id": 7, "business_key": "left-behind", "status": "IN_PROGRESS",
118
+ "seconds": 7200, "stuck_at": "Generate Class",
119
+ }
120
+ fake = make_plpy(results=[([row], list(row))])
121
+ assert stuck(fake, "1 hour") == [row]
122
+
123
+ sql = _sql(fake.executed[0])
124
+ assert "'IN_PROGRESS'" in sql and "'PENDING'" in sql
125
+ assert "$1::interval" in sql
126
+ assert "1 hour" not in sql
127
+ assert _args(fake.executed[0]) == ["1 hour"]
128
+
129
+ def test_an_empty_interval_is_refused(self, make_plpy):
130
+ fake = make_plpy()
131
+ with pytest.raises(ValueError, match="interval"):
132
+ stuck(fake, "")
133
+ assert fake.executed == []
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