aflib 0.0.6__tar.gz → 0.0.8__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.8}/.gitignore +1 -0
- {aflib-0.0.6 → aflib-0.0.8}/PKG-INFO +47 -6
- {aflib-0.0.6 → aflib-0.0.8}/README.md +46 -5
- {aflib-0.0.6 → aflib-0.0.8}/pyproject.toml +1 -1
- {aflib-0.0.6 → aflib-0.0.8}/src/aflib/__init__.py +11 -1
- aflib-0.0.8/src/aflib/batch.py +156 -0
- aflib-0.0.8/src/aflib/events.py +176 -0
- aflib-0.0.8/src/aflib/workflow.py +161 -0
- {aflib-0.0.6 → aflib-0.0.8}/tests/conftest.py +12 -3
- aflib-0.0.8/tests/test_batch.py +163 -0
- aflib-0.0.8/tests/test_events.py +184 -0
- {aflib-0.0.6 → aflib-0.0.8}/tests/test_public_api.py +12 -0
- aflib-0.0.8/tests/test_workflow.py +133 -0
- {aflib-0.0.6 → aflib-0.0.8}/LICENSE +0 -0
- {aflib-0.0.6 → aflib-0.0.8}/src/aflib/authz.py +0 -0
- {aflib-0.0.6 → aflib-0.0.8}/src/aflib/codec.py +0 -0
- {aflib-0.0.6 → aflib-0.0.8}/src/aflib/db.py +0 -0
- {aflib-0.0.6 → aflib-0.0.8}/src/aflib/errors.py +0 -0
- {aflib-0.0.6 → aflib-0.0.8}/src/aflib/functions.py +0 -0
- {aflib-0.0.6 → aflib-0.0.8}/src/aflib/identity.py +0 -0
- {aflib-0.0.6 → aflib-0.0.8}/src/aflib/logging.py +0 -0
- {aflib-0.0.6 → aflib-0.0.8}/src/aflib/params.py +0 -0
- {aflib-0.0.6 → aflib-0.0.8}/src/aflib/result.py +0 -0
- {aflib-0.0.6 → aflib-0.0.8}/src/aflib/transaction.py +0 -0
- {aflib-0.0.6 → aflib-0.0.8}/src/aflib/trigger.py +0 -0
- {aflib-0.0.6 → aflib-0.0.8}/tests/test_authz.py +0 -0
- {aflib-0.0.6 → aflib-0.0.8}/tests/test_codec.py +0 -0
- {aflib-0.0.6 → aflib-0.0.8}/tests/test_db.py +0 -0
- {aflib-0.0.6 → aflib-0.0.8}/tests/test_errors.py +0 -0
- {aflib-0.0.6 → aflib-0.0.8}/tests/test_functions.py +0 -0
- {aflib-0.0.6 → aflib-0.0.8}/tests/test_identity.py +0 -0
- {aflib-0.0.6 → aflib-0.0.8}/tests/test_logging.py +0 -0
- {aflib-0.0.6 → aflib-0.0.8}/tests/test_params.py +0 -0
- {aflib-0.0.6 → aflib-0.0.8}/tests/test_result.py +0 -0
- {aflib-0.0.6 → aflib-0.0.8}/tests/test_transaction.py +0 -0
- {aflib-0.0.6 → aflib-0.0.8}/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.8
|
|
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,57 @@ 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.8` 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.8
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
Then import it from a function body.
|
|
39
39
|
|
|
40
|
+
## What 0.0.8 fixes
|
|
41
|
+
|
|
42
|
+
`Batch.run` accepts a predicate whose column name contains the letters `_t`.
|
|
43
|
+
`id_tuition IS NOT NULL`, `start_time < now()` and `is_test = false` are
|
|
44
|
+
predicates. 0.0.7 refused them, because the guard searched for `_t` as a
|
|
45
|
+
substring. `_t.col` and `"_t".col` are still refused: that alias exists only on
|
|
46
|
+
the outer `UPDATE`.
|
|
47
|
+
|
|
48
|
+
## What 0.0.7 gives you
|
|
49
|
+
|
|
50
|
+
Everything in 0.0.6, plus three capabilities that ran on a real instance on
|
|
51
|
+
2026-10-01 before they were extracted:
|
|
52
|
+
|
|
53
|
+
- **`Batch(plpy).run(...)`** — one committed chunk. The pending predicate is
|
|
54
|
+
placed in the outer `WHERE` and in the subquery, and the result carries
|
|
55
|
+
`done`, `remaining` and `seconds`.
|
|
56
|
+
- **`workflow.start` / `status` / `stuck`** — insert a `PENDING` workflow
|
|
57
|
+
instance and its variables, read the newest instance for a business key, and
|
|
58
|
+
list instances that are still `PENDING` or `IN_PROGRESS` after an interval.
|
|
59
|
+
- **`Outbox(plpy, outbox=..., delivery=..., subscription=...).emit` / `pump`** —
|
|
60
|
+
one event plus a pending delivery per active subscriber, then a chunk of
|
|
61
|
+
deliveries. Each subscriber runs inside `plpy.subtransaction()`. Failures
|
|
62
|
+
retry with `attempts`, `next_attempt_at` and `last_error`, and dead-letter
|
|
63
|
+
after 3 attempts.
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from aflib import workflow
|
|
67
|
+
from aflib.events import Outbox
|
|
68
|
+
|
|
69
|
+
workflow.start(plpy, "WFP_cadena", "case-58", {
|
|
70
|
+
"SomeSchema.some_function.p_id": ("INTEGER", 42),
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
box = Outbox(plpy, outbox="app.outbox", delivery="app.delivery", subscription="app.sub")
|
|
74
|
+
box.emit("order.placed", {"id": 42}, 42)
|
|
75
|
+
box.pump(deliver, limit=50)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`deliver(subscriber, outbox_id, attempt)` does the work and raises to fail the
|
|
79
|
+
attempt. Table names are identifiers you wrote, not request data.
|
|
80
|
+
|
|
40
81
|
## What 0.0.6 gives you
|
|
41
82
|
|
|
42
83
|
0.0.1–0.0.3 (read/result, errors/logging, `transaction.atomic`) plus what
|
|
@@ -58,10 +99,10 @@ the 2026-09 platform probes showed, plus two verified fixes:
|
|
|
58
99
|
resolve.
|
|
59
100
|
- **`identity.current_user(plpy)`** — `SELECT current_user`. Not a User id.
|
|
60
101
|
|
|
61
|
-
Pin **name and version in separate fields** (`aflib` / `0.0.
|
|
62
|
-
`aflib==0.0.
|
|
102
|
+
Pin **name and version in separate fields** (`aflib` / `0.0.8`), not
|
|
103
|
+
`aflib==0.0.8` as one string.
|
|
63
104
|
|
|
64
|
-
## Core API (0.0.1–0.0.3, still in 0.0.
|
|
105
|
+
## Core API (0.0.1–0.0.3, still in 0.0.8)
|
|
65
106
|
|
|
66
107
|
```python
|
|
67
108
|
from aflib import Db, errors, fail, logging, ok, params
|
|
@@ -18,16 +18,57 @@ 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.8` 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.8
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
Then import it from a function body.
|
|
30
30
|
|
|
31
|
+
## What 0.0.8 fixes
|
|
32
|
+
|
|
33
|
+
`Batch.run` accepts a predicate whose column name contains the letters `_t`.
|
|
34
|
+
`id_tuition IS NOT NULL`, `start_time < now()` and `is_test = false` are
|
|
35
|
+
predicates. 0.0.7 refused them, because the guard searched for `_t` as a
|
|
36
|
+
substring. `_t.col` and `"_t".col` are still refused: that alias exists only on
|
|
37
|
+
the outer `UPDATE`.
|
|
38
|
+
|
|
39
|
+
## What 0.0.7 gives you
|
|
40
|
+
|
|
41
|
+
Everything in 0.0.6, plus three capabilities that ran on a real instance on
|
|
42
|
+
2026-10-01 before they were extracted:
|
|
43
|
+
|
|
44
|
+
- **`Batch(plpy).run(...)`** — one committed chunk. The pending predicate is
|
|
45
|
+
placed in the outer `WHERE` and in the subquery, and the result carries
|
|
46
|
+
`done`, `remaining` and `seconds`.
|
|
47
|
+
- **`workflow.start` / `status` / `stuck`** — insert a `PENDING` workflow
|
|
48
|
+
instance and its variables, read the newest instance for a business key, and
|
|
49
|
+
list instances that are still `PENDING` or `IN_PROGRESS` after an interval.
|
|
50
|
+
- **`Outbox(plpy, outbox=..., delivery=..., subscription=...).emit` / `pump`** —
|
|
51
|
+
one event plus a pending delivery per active subscriber, then a chunk of
|
|
52
|
+
deliveries. Each subscriber runs inside `plpy.subtransaction()`. Failures
|
|
53
|
+
retry with `attempts`, `next_attempt_at` and `last_error`, and dead-letter
|
|
54
|
+
after 3 attempts.
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from aflib import workflow
|
|
58
|
+
from aflib.events import Outbox
|
|
59
|
+
|
|
60
|
+
workflow.start(plpy, "WFP_cadena", "case-58", {
|
|
61
|
+
"SomeSchema.some_function.p_id": ("INTEGER", 42),
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
box = Outbox(plpy, outbox="app.outbox", delivery="app.delivery", subscription="app.sub")
|
|
65
|
+
box.emit("order.placed", {"id": 42}, 42)
|
|
66
|
+
box.pump(deliver, limit=50)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`deliver(subscriber, outbox_id, attempt)` does the work and raises to fail the
|
|
70
|
+
attempt. Table names are identifiers you wrote, not request data.
|
|
71
|
+
|
|
31
72
|
## What 0.0.6 gives you
|
|
32
73
|
|
|
33
74
|
0.0.1–0.0.3 (read/result, errors/logging, `transaction.atomic`) plus what
|
|
@@ -49,10 +90,10 @@ the 2026-09 platform probes showed, plus two verified fixes:
|
|
|
49
90
|
resolve.
|
|
50
91
|
- **`identity.current_user(plpy)`** — `SELECT current_user`. Not a User id.
|
|
51
92
|
|
|
52
|
-
Pin **name and version in separate fields** (`aflib` / `0.0.
|
|
53
|
-
`aflib==0.0.
|
|
93
|
+
Pin **name and version in separate fields** (`aflib` / `0.0.8`), not
|
|
94
|
+
`aflib==0.0.8` as one string.
|
|
54
95
|
|
|
55
|
-
## Core API (0.0.1–0.0.3, still in 0.0.
|
|
96
|
+
## Core API (0.0.1–0.0.3, still in 0.0.8)
|
|
56
97
|
|
|
57
98
|
```python
|
|
58
99
|
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.8"
|
|
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.8"
|
|
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,156 @@
|
|
|
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 re
|
|
58
|
+
import time
|
|
59
|
+
|
|
60
|
+
from aflib.db import Db
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Progress(object):
|
|
64
|
+
"""What one chunk did, and whether there is more.
|
|
65
|
+
|
|
66
|
+
`remaining` is counted **after** the update, in the same transaction, so it already
|
|
67
|
+
reflects this chunk. A caller that loops until `complete` therefore terminates without
|
|
68
|
+
needing to subtract anything itself.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
__slots__ = ("done", "remaining", "seconds")
|
|
72
|
+
|
|
73
|
+
def __init__(self, done, remaining, seconds):
|
|
74
|
+
self.done = done
|
|
75
|
+
self.remaining = remaining
|
|
76
|
+
self.seconds = seconds
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def complete(self):
|
|
80
|
+
"""True when nothing is left to do."""
|
|
81
|
+
return self.remaining == 0
|
|
82
|
+
|
|
83
|
+
def as_dict(self):
|
|
84
|
+
return {"done": self.done, "remaining": self.remaining,
|
|
85
|
+
"seconds": round(self.seconds, 3), "complete": self.complete}
|
|
86
|
+
|
|
87
|
+
def __repr__(self):
|
|
88
|
+
return "Progress(done=%r, remaining=%r, complete=%r)" % (
|
|
89
|
+
self.done, self.remaining, self.complete)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _alias_as_qualifier(alias, pending):
|
|
93
|
+
"""True when `pending` uses `alias` as a qualifier, not as letters inside a name.
|
|
94
|
+
|
|
95
|
+
Searching for the substring `_t` refuses `id_tuition IS NOT NULL`,
|
|
96
|
+
`start_time < now()` and `is_test = false`. Observed on PRE 2026-10-02,
|
|
97
|
+
acceptance probe `aflib-007-accept-r1`, case `batch`. The alias is a problem
|
|
98
|
+
only as `_t.col` or `"_t".col`, with optional space before the dot.
|
|
99
|
+
"""
|
|
100
|
+
pattern = r'(?<![\w"])"?{0}"?\s*\.'.format(re.escape(alias))
|
|
101
|
+
return re.search(pattern, pending) is not None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Batch(object):
|
|
105
|
+
"""One committed chunk per call. `plpy` is injected, as everywhere in aflib."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, plpy):
|
|
108
|
+
self._db = Db(plpy)
|
|
109
|
+
|
|
110
|
+
ALIAS = "_t"
|
|
111
|
+
|
|
112
|
+
def run(self, table, set_clause, pending, args=None, types=None,
|
|
113
|
+
limit=1000, key="id", order=None):
|
|
114
|
+
"""Update at most `limit` rows matching `pending`, and report what is left.
|
|
115
|
+
|
|
116
|
+
`pending` must be **unqualified** — write `id_status = $1`, not
|
|
117
|
+
`"Receipt".id_status = $1`. That is not a style rule: the same text is placed in
|
|
118
|
+
the outer `WHERE`, where Postgres resolves it against the update target, and
|
|
119
|
+
inside the subquery, where it resolves against the subquery's own `FROM`. Both
|
|
120
|
+
resolutions are what we want, and either qualification breaks one of them. A
|
|
121
|
+
predicate that qualifies a column with the alias (`_t.col` or `"_t".col`) is
|
|
122
|
+
rejected rather than silently misplaced. A column whose name merely contains
|
|
123
|
+
those letters — `id_tuition`, `start_time`, `is_test` — is not a qualification.
|
|
124
|
+
|
|
125
|
+
`order` defaults to `key`, which makes the work deterministic and the chunks
|
|
126
|
+
disjoint. Ordering by something non-unique would let two concurrent callers pick
|
|
127
|
+
overlapping sets; the row-level guard still keeps them correct, but they would
|
|
128
|
+
waste each other's work.
|
|
129
|
+
"""
|
|
130
|
+
if _alias_as_qualifier(self.ALIAS, pending):
|
|
131
|
+
raise ValueError(
|
|
132
|
+
"`pending` must be unqualified: it is placed both in the outer WHERE and "
|
|
133
|
+
"inside the subquery, and %r resolves in only one of them." % self.ALIAS)
|
|
134
|
+
|
|
135
|
+
args = list(args or [])
|
|
136
|
+
types = list(types or [])
|
|
137
|
+
order = order or key
|
|
138
|
+
|
|
139
|
+
started = time.time()
|
|
140
|
+
updated = self._db.query(
|
|
141
|
+
"UPDATE {table} AS {alias} SET {set_clause} "
|
|
142
|
+
"WHERE {pending} "
|
|
143
|
+
" AND {alias}.{key} IN (SELECT {key} FROM {table} WHERE {pending} "
|
|
144
|
+
" ORDER BY {order} LIMIT {limit}) "
|
|
145
|
+
"RETURNING {alias}.{key}".format(
|
|
146
|
+
table=table, alias=self.ALIAS, set_clause=set_clause,
|
|
147
|
+
pending=pending, key=key, order=order, limit=int(limit)),
|
|
148
|
+
args, types).rows()
|
|
149
|
+
elapsed = time.time() - started
|
|
150
|
+
|
|
151
|
+
remaining = self._db.query(
|
|
152
|
+
"SELECT count(*)::int AS n FROM {table} WHERE {pending}".format(
|
|
153
|
+
table=table, pending=pending),
|
|
154
|
+
args, types).scalar("n")
|
|
155
|
+
|
|
156
|
+
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
|
+
)
|