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.
Files changed (36) hide show
  1. {aflib-0.0.6 → aflib-0.0.8}/.gitignore +1 -0
  2. {aflib-0.0.6 → aflib-0.0.8}/PKG-INFO +47 -6
  3. {aflib-0.0.6 → aflib-0.0.8}/README.md +46 -5
  4. {aflib-0.0.6 → aflib-0.0.8}/pyproject.toml +1 -1
  5. {aflib-0.0.6 → aflib-0.0.8}/src/aflib/__init__.py +11 -1
  6. aflib-0.0.8/src/aflib/batch.py +156 -0
  7. aflib-0.0.8/src/aflib/events.py +176 -0
  8. aflib-0.0.8/src/aflib/workflow.py +161 -0
  9. {aflib-0.0.6 → aflib-0.0.8}/tests/conftest.py +12 -3
  10. aflib-0.0.8/tests/test_batch.py +163 -0
  11. aflib-0.0.8/tests/test_events.py +184 -0
  12. {aflib-0.0.6 → aflib-0.0.8}/tests/test_public_api.py +12 -0
  13. aflib-0.0.8/tests/test_workflow.py +133 -0
  14. {aflib-0.0.6 → aflib-0.0.8}/LICENSE +0 -0
  15. {aflib-0.0.6 → aflib-0.0.8}/src/aflib/authz.py +0 -0
  16. {aflib-0.0.6 → aflib-0.0.8}/src/aflib/codec.py +0 -0
  17. {aflib-0.0.6 → aflib-0.0.8}/src/aflib/db.py +0 -0
  18. {aflib-0.0.6 → aflib-0.0.8}/src/aflib/errors.py +0 -0
  19. {aflib-0.0.6 → aflib-0.0.8}/src/aflib/functions.py +0 -0
  20. {aflib-0.0.6 → aflib-0.0.8}/src/aflib/identity.py +0 -0
  21. {aflib-0.0.6 → aflib-0.0.8}/src/aflib/logging.py +0 -0
  22. {aflib-0.0.6 → aflib-0.0.8}/src/aflib/params.py +0 -0
  23. {aflib-0.0.6 → aflib-0.0.8}/src/aflib/result.py +0 -0
  24. {aflib-0.0.6 → aflib-0.0.8}/src/aflib/transaction.py +0 -0
  25. {aflib-0.0.6 → aflib-0.0.8}/src/aflib/trigger.py +0 -0
  26. {aflib-0.0.6 → aflib-0.0.8}/tests/test_authz.py +0 -0
  27. {aflib-0.0.6 → aflib-0.0.8}/tests/test_codec.py +0 -0
  28. {aflib-0.0.6 → aflib-0.0.8}/tests/test_db.py +0 -0
  29. {aflib-0.0.6 → aflib-0.0.8}/tests/test_errors.py +0 -0
  30. {aflib-0.0.6 → aflib-0.0.8}/tests/test_functions.py +0 -0
  31. {aflib-0.0.6 → aflib-0.0.8}/tests/test_identity.py +0 -0
  32. {aflib-0.0.6 → aflib-0.0.8}/tests/test_logging.py +0 -0
  33. {aflib-0.0.6 → aflib-0.0.8}/tests/test_params.py +0 -0
  34. {aflib-0.0.6 → aflib-0.0.8}/tests/test_result.py +0 -0
  35. {aflib-0.0.6 → aflib-0.0.8}/tests/test_transaction.py +0 -0
  36. {aflib-0.0.6 → aflib-0.0.8}/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.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.6` string (that form produced
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.6
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.6`), not
62
- `aflib==0.0.6` as one string.
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.6)
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.6` string (that form produced
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.6
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.6`), not
53
- `aflib==0.0.6` as one string.
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.6)
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.6"
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.6"
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
+ )