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.
- {aflib-0.0.6 → aflib-0.0.7}/.gitignore +1 -0
- {aflib-0.0.6 → aflib-0.0.7}/PKG-INFO +39 -6
- {aflib-0.0.6 → aflib-0.0.7}/README.md +38 -5
- {aflib-0.0.6 → aflib-0.0.7}/pyproject.toml +1 -1
- {aflib-0.0.6 → aflib-0.0.7}/src/aflib/__init__.py +11 -1
- aflib-0.0.7/src/aflib/batch.py +141 -0
- aflib-0.0.7/src/aflib/events.py +176 -0
- aflib-0.0.7/src/aflib/workflow.py +161 -0
- {aflib-0.0.6 → aflib-0.0.7}/tests/conftest.py +12 -3
- aflib-0.0.7/tests/test_batch.py +145 -0
- aflib-0.0.7/tests/test_events.py +184 -0
- {aflib-0.0.6 → aflib-0.0.7}/tests/test_public_api.py +12 -0
- aflib-0.0.7/tests/test_workflow.py +133 -0
- {aflib-0.0.6 → aflib-0.0.7}/LICENSE +0 -0
- {aflib-0.0.6 → aflib-0.0.7}/src/aflib/authz.py +0 -0
- {aflib-0.0.6 → aflib-0.0.7}/src/aflib/codec.py +0 -0
- {aflib-0.0.6 → aflib-0.0.7}/src/aflib/db.py +0 -0
- {aflib-0.0.6 → aflib-0.0.7}/src/aflib/errors.py +0 -0
- {aflib-0.0.6 → aflib-0.0.7}/src/aflib/functions.py +0 -0
- {aflib-0.0.6 → aflib-0.0.7}/src/aflib/identity.py +0 -0
- {aflib-0.0.6 → aflib-0.0.7}/src/aflib/logging.py +0 -0
- {aflib-0.0.6 → aflib-0.0.7}/src/aflib/params.py +0 -0
- {aflib-0.0.6 → aflib-0.0.7}/src/aflib/result.py +0 -0
- {aflib-0.0.6 → aflib-0.0.7}/src/aflib/transaction.py +0 -0
- {aflib-0.0.6 → aflib-0.0.7}/src/aflib/trigger.py +0 -0
- {aflib-0.0.6 → aflib-0.0.7}/tests/test_authz.py +0 -0
- {aflib-0.0.6 → aflib-0.0.7}/tests/test_codec.py +0 -0
- {aflib-0.0.6 → aflib-0.0.7}/tests/test_db.py +0 -0
- {aflib-0.0.6 → aflib-0.0.7}/tests/test_errors.py +0 -0
- {aflib-0.0.6 → aflib-0.0.7}/tests/test_functions.py +0 -0
- {aflib-0.0.6 → aflib-0.0.7}/tests/test_identity.py +0 -0
- {aflib-0.0.6 → aflib-0.0.7}/tests/test_logging.py +0 -0
- {aflib-0.0.6 → aflib-0.0.7}/tests/test_params.py +0 -0
- {aflib-0.0.6 → aflib-0.0.7}/tests/test_result.py +0 -0
- {aflib-0.0.6 → aflib-0.0.7}/tests/test_transaction.py +0 -0
- {aflib-0.0.6 → aflib-0.0.7}/tests/test_trigger.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: aflib
|
|
3
|
-
Version: 0.0.
|
|
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.
|
|
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.
|
|
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.
|
|
62
|
-
`aflib==0.0.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
53
|
-
`aflib==0.0.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|