aflib 0.0.2__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.2 → aflib-0.0.7}/.gitignore +1 -0
- {aflib-0.0.2 → aflib-0.0.7}/PKG-INFO +116 -7
- {aflib-0.0.2 → aflib-0.0.7}/README.md +114 -5
- {aflib-0.0.2 → aflib-0.0.7}/pyproject.toml +8 -5
- {aflib-0.0.2 → aflib-0.0.7}/src/aflib/__init__.py +31 -4
- aflib-0.0.7/src/aflib/authz.py +86 -0
- aflib-0.0.7/src/aflib/batch.py +141 -0
- {aflib-0.0.2 → aflib-0.0.7}/src/aflib/errors.py +4 -6
- aflib-0.0.7/src/aflib/events.py +176 -0
- aflib-0.0.7/src/aflib/functions.py +72 -0
- aflib-0.0.7/src/aflib/identity.py +20 -0
- {aflib-0.0.2 → aflib-0.0.7}/src/aflib/logging.py +5 -3
- {aflib-0.0.2 → aflib-0.0.7}/src/aflib/params.py +13 -1
- aflib-0.0.7/src/aflib/transaction.py +141 -0
- aflib-0.0.7/src/aflib/trigger.py +51 -0
- aflib-0.0.7/src/aflib/workflow.py +161 -0
- {aflib-0.0.2 → aflib-0.0.7}/tests/conftest.py +61 -11
- aflib-0.0.7/tests/test_authz.py +41 -0
- aflib-0.0.7/tests/test_batch.py +145 -0
- {aflib-0.0.2 → aflib-0.0.7}/tests/test_db.py +6 -6
- {aflib-0.0.2 → aflib-0.0.7}/tests/test_errors.py +8 -9
- aflib-0.0.7/tests/test_events.py +184 -0
- aflib-0.0.7/tests/test_functions.py +50 -0
- aflib-0.0.7/tests/test_identity.py +11 -0
- {aflib-0.0.2 → aflib-0.0.7}/tests/test_logging.py +24 -5
- {aflib-0.0.2 → aflib-0.0.7}/tests/test_params.py +20 -0
- {aflib-0.0.2 → aflib-0.0.7}/tests/test_public_api.py +39 -1
- {aflib-0.0.2 → aflib-0.0.7}/tests/test_result.py +3 -3
- aflib-0.0.7/tests/test_transaction.py +193 -0
- aflib-0.0.7/tests/test_trigger.py +42 -0
- aflib-0.0.7/tests/test_workflow.py +133 -0
- {aflib-0.0.2 → aflib-0.0.7}/LICENSE +0 -0
- {aflib-0.0.2 → aflib-0.0.7}/src/aflib/codec.py +0 -0
- {aflib-0.0.2 → aflib-0.0.7}/src/aflib/db.py +0 -0
- {aflib-0.0.2 → aflib-0.0.7}/src/aflib/result.py +0 -0
- {aflib-0.0.2 → aflib-0.0.7}/tests/test_codec.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
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
|
|
@@ -26,15 +26,75 @@ so low.
|
|
|
26
26
|
|
|
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
|
-
`models/pythonPackages/pythonPackages.airflows
|
|
29
|
+
`models/pythonPackages/pythonPackages.airflows` by pinning **name and version in
|
|
30
|
+
separate fields** — never as a single `aflib==0.0.7` string (that form produced
|
|
31
|
+
malformed `name==ver==ver` entries in a real instance's package list):
|
|
30
32
|
|
|
31
33
|
```
|
|
32
|
-
|
|
34
|
+
name: aflib
|
|
35
|
+
version: 0.0.7
|
|
33
36
|
```
|
|
34
37
|
|
|
35
38
|
Then import it from a function body.
|
|
36
39
|
|
|
37
|
-
## What 0.0.
|
|
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
|
+
|
|
73
|
+
## What 0.0.6 gives you
|
|
74
|
+
|
|
75
|
+
0.0.1–0.0.3 (read/result, errors/logging, `transaction.atomic`) plus what
|
|
76
|
+
the 2026-09 platform probes showed, plus two verified fixes:
|
|
77
|
+
|
|
78
|
+
- **`params.as_bool`** accepts a genuine Python `bool` as well as the
|
|
79
|
+
`true`/`false`/`1`/`0`/`t`/`f` text vocabulary. A `boolean` customParameter
|
|
80
|
+
on the non-HTTP path binds as `True`/`False`; converting that through
|
|
81
|
+
`as_bool` no longer raises.
|
|
82
|
+
- **`logging`** treats `source` as reserved, the same way it already treats
|
|
83
|
+
`level` and `event`. Passing `source=` as a field raises rather than
|
|
84
|
+
silently re-labelling the emitting function.
|
|
85
|
+
- **`authz.can(plpy, "Schema.Entity", "UPDATE")`** and **`authz.snapshot(plpy)`** —
|
|
86
|
+
table GRANTs via `has_table_privilege`. Not RLS. Snapshot before `SET ROLE`.
|
|
87
|
+
- **`trigger.changed(TD)` / `event` / `when`** — a plpython trigger can
|
|
88
|
+
write `TD["new"]` and return `"MODIFY"`. Compare OLD/NEW; ignore `search`.
|
|
89
|
+
- **`functions.call(plpy, "Schema.fn", args, argtypes)`** — compose through
|
|
90
|
+
SQL with **Postgres** bind types. Text binds to a typed signature do not
|
|
91
|
+
resolve.
|
|
92
|
+
- **`identity.current_user(plpy)`** — `SELECT current_user`. Not a User id.
|
|
93
|
+
|
|
94
|
+
Pin **name and version in separate fields** (`aflib` / `0.0.7`), not
|
|
95
|
+
`aflib==0.0.7` as one string.
|
|
96
|
+
|
|
97
|
+
## Core API (0.0.1–0.0.3, still in 0.0.7)
|
|
38
98
|
|
|
39
99
|
```python
|
|
40
100
|
from aflib import Db, errors, fail, logging, ok, params
|
|
@@ -85,6 +145,9 @@ except Exception as exc:
|
|
|
85
145
|
present, written through `plpy.log`. It never raises, even when handed a value that cannot
|
|
86
146
|
be encoded: the line is most needed in the error path, so a single exotic field must not be
|
|
87
147
|
able to silence it.
|
|
148
|
+
- **`transaction.atomic(plpy)`** — a savepoint that also rolls back a *returned* failure. See
|
|
149
|
+
[A returned failure does not roll back](#a-returned-failure-does-not-roll-back) — this is a
|
|
150
|
+
narrow helper for one specific trap, not a unit of work.
|
|
88
151
|
|
|
89
152
|
## Platform constraints worth knowing before you build on it
|
|
90
153
|
|
|
@@ -93,8 +156,9 @@ the library has the shape it does.
|
|
|
93
156
|
|
|
94
157
|
- **Every HTTP-facing parameter must be declared `text`.** Declare `integer` and the platform
|
|
95
158
|
binds `varchar` at call time, no candidate signature matches, and the endpoint fails with
|
|
96
|
-
*function does not exist
|
|
97
|
-
|
|
159
|
+
*function does not exist*. A `boolean` parameter binds `true`/`false`, but `?p_flag=maybe`
|
|
160
|
+
is HTTP 500 `Cannot cast to boolean` before the body runs. Convert with `params` inside
|
|
161
|
+
the body.
|
|
98
162
|
- **Function bodies compose only through SQL** — `SELECT "Schema"."fn"(…)`. There is no
|
|
99
163
|
shared module and no package-local import, so a helper another body needs is a database
|
|
100
164
|
function, not a Python one.
|
|
@@ -137,6 +201,51 @@ Two related facts worth knowing before you design around logging:
|
|
|
137
201
|
*type* does not. So a business outcome has to travel as a returned value — `ok`/`fail` — and
|
|
138
202
|
never as an exception, and nothing sensitive may ever go into an exception message.
|
|
139
203
|
|
|
204
|
+
### A returned failure does not roll back
|
|
205
|
+
|
|
206
|
+
A function body is **one transaction it does not control.** `plpy.commit()` fails with *invalid
|
|
207
|
+
transaction termination*, because a function invoked inside a query cannot terminate the
|
|
208
|
+
transaction it is running in. What a body *can* do is subdivide that transaction with savepoints
|
|
209
|
+
via `plpy.subtransaction()`, and those work exactly as you would hope: they roll back on any
|
|
210
|
+
exception, Python or SQL alike, the original exception continues unchanged, nesting rolls back
|
|
211
|
+
only the inner block, and a trigger's writes roll back together with the row that fired them.
|
|
212
|
+
|
|
213
|
+
Two further rules complete the picture: **an uncaught exception discards the whole call**, and
|
|
214
|
+
**a normal return commits the whole call.**
|
|
215
|
+
|
|
216
|
+
That last one is the trap, because it applies to a returned *failure* too:
|
|
217
|
+
|
|
218
|
+
```python
|
|
219
|
+
with plpy.subtransaction():
|
|
220
|
+
db.query("INSERT …")
|
|
221
|
+
return fail("not_allowed", "…") # the INSERT is COMMITTED
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
`plpy.subtransaction()` reacts to exceptions, and a business failure is not an exception — it
|
|
225
|
+
cannot be, since an exception's type does not survive crossing a function boundary. So the most
|
|
226
|
+
natural code silently half-applies the change. `transaction.atomic` exists for exactly this:
|
|
227
|
+
|
|
228
|
+
```python
|
|
229
|
+
from aflib import errors, transaction
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
with transaction.atomic(plpy) as tx:
|
|
233
|
+
db.query("INSERT …")
|
|
234
|
+
if not allowed:
|
|
235
|
+
tx.reject("not_allowed", "You may not do that") # rolls back
|
|
236
|
+
tx.succeed({"id": 7})
|
|
237
|
+
return tx.outcome
|
|
238
|
+
except Exception as exc:
|
|
239
|
+
return errors.to_fail(exc, log=log)
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
`reject()` ends the block and rolls back; `outcome` is read afterwards. A scope that ends
|
|
243
|
+
without `succeed()` or `reject()` raises rather than returning an empty success for work that
|
|
244
|
+
may have half happened.
|
|
245
|
+
|
|
246
|
+
It is deliberately **not** a unit of work and cannot commit anything — the name describes the
|
|
247
|
+
guarantee it provides (the block is all-or-nothing), not machinery it does not have.
|
|
248
|
+
|
|
140
249
|
## Tests
|
|
141
250
|
|
|
142
251
|
```
|
|
@@ -17,15 +17,75 @@ so low.
|
|
|
17
17
|
|
|
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
|
-
`models/pythonPackages/pythonPackages.airflows
|
|
20
|
+
`models/pythonPackages/pythonPackages.airflows` by pinning **name and version in
|
|
21
|
+
separate fields** — never as a single `aflib==0.0.7` string (that form produced
|
|
22
|
+
malformed `name==ver==ver` entries in a real instance's package list):
|
|
21
23
|
|
|
22
24
|
```
|
|
23
|
-
|
|
25
|
+
name: aflib
|
|
26
|
+
version: 0.0.7
|
|
24
27
|
```
|
|
25
28
|
|
|
26
29
|
Then import it from a function body.
|
|
27
30
|
|
|
28
|
-
## What 0.0.
|
|
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
|
+
|
|
64
|
+
## What 0.0.6 gives you
|
|
65
|
+
|
|
66
|
+
0.0.1–0.0.3 (read/result, errors/logging, `transaction.atomic`) plus what
|
|
67
|
+
the 2026-09 platform probes showed, plus two verified fixes:
|
|
68
|
+
|
|
69
|
+
- **`params.as_bool`** accepts a genuine Python `bool` as well as the
|
|
70
|
+
`true`/`false`/`1`/`0`/`t`/`f` text vocabulary. A `boolean` customParameter
|
|
71
|
+
on the non-HTTP path binds as `True`/`False`; converting that through
|
|
72
|
+
`as_bool` no longer raises.
|
|
73
|
+
- **`logging`** treats `source` as reserved, the same way it already treats
|
|
74
|
+
`level` and `event`. Passing `source=` as a field raises rather than
|
|
75
|
+
silently re-labelling the emitting function.
|
|
76
|
+
- **`authz.can(plpy, "Schema.Entity", "UPDATE")`** and **`authz.snapshot(plpy)`** —
|
|
77
|
+
table GRANTs via `has_table_privilege`. Not RLS. Snapshot before `SET ROLE`.
|
|
78
|
+
- **`trigger.changed(TD)` / `event` / `when`** — a plpython trigger can
|
|
79
|
+
write `TD["new"]` and return `"MODIFY"`. Compare OLD/NEW; ignore `search`.
|
|
80
|
+
- **`functions.call(plpy, "Schema.fn", args, argtypes)`** — compose through
|
|
81
|
+
SQL with **Postgres** bind types. Text binds to a typed signature do not
|
|
82
|
+
resolve.
|
|
83
|
+
- **`identity.current_user(plpy)`** — `SELECT current_user`. Not a User id.
|
|
84
|
+
|
|
85
|
+
Pin **name and version in separate fields** (`aflib` / `0.0.7`), not
|
|
86
|
+
`aflib==0.0.7` as one string.
|
|
87
|
+
|
|
88
|
+
## Core API (0.0.1–0.0.3, still in 0.0.7)
|
|
29
89
|
|
|
30
90
|
```python
|
|
31
91
|
from aflib import Db, errors, fail, logging, ok, params
|
|
@@ -76,6 +136,9 @@ except Exception as exc:
|
|
|
76
136
|
present, written through `plpy.log`. It never raises, even when handed a value that cannot
|
|
77
137
|
be encoded: the line is most needed in the error path, so a single exotic field must not be
|
|
78
138
|
able to silence it.
|
|
139
|
+
- **`transaction.atomic(plpy)`** — a savepoint that also rolls back a *returned* failure. See
|
|
140
|
+
[A returned failure does not roll back](#a-returned-failure-does-not-roll-back) — this is a
|
|
141
|
+
narrow helper for one specific trap, not a unit of work.
|
|
79
142
|
|
|
80
143
|
## Platform constraints worth knowing before you build on it
|
|
81
144
|
|
|
@@ -84,8 +147,9 @@ the library has the shape it does.
|
|
|
84
147
|
|
|
85
148
|
- **Every HTTP-facing parameter must be declared `text`.** Declare `integer` and the platform
|
|
86
149
|
binds `varchar` at call time, no candidate signature matches, and the endpoint fails with
|
|
87
|
-
*function does not exist
|
|
88
|
-
|
|
150
|
+
*function does not exist*. A `boolean` parameter binds `true`/`false`, but `?p_flag=maybe`
|
|
151
|
+
is HTTP 500 `Cannot cast to boolean` before the body runs. Convert with `params` inside
|
|
152
|
+
the body.
|
|
89
153
|
- **Function bodies compose only through SQL** — `SELECT "Schema"."fn"(…)`. There is no
|
|
90
154
|
shared module and no package-local import, so a helper another body needs is a database
|
|
91
155
|
function, not a Python one.
|
|
@@ -128,6 +192,51 @@ Two related facts worth knowing before you design around logging:
|
|
|
128
192
|
*type* does not. So a business outcome has to travel as a returned value — `ok`/`fail` — and
|
|
129
193
|
never as an exception, and nothing sensitive may ever go into an exception message.
|
|
130
194
|
|
|
195
|
+
### A returned failure does not roll back
|
|
196
|
+
|
|
197
|
+
A function body is **one transaction it does not control.** `plpy.commit()` fails with *invalid
|
|
198
|
+
transaction termination*, because a function invoked inside a query cannot terminate the
|
|
199
|
+
transaction it is running in. What a body *can* do is subdivide that transaction with savepoints
|
|
200
|
+
via `plpy.subtransaction()`, and those work exactly as you would hope: they roll back on any
|
|
201
|
+
exception, Python or SQL alike, the original exception continues unchanged, nesting rolls back
|
|
202
|
+
only the inner block, and a trigger's writes roll back together with the row that fired them.
|
|
203
|
+
|
|
204
|
+
Two further rules complete the picture: **an uncaught exception discards the whole call**, and
|
|
205
|
+
**a normal return commits the whole call.**
|
|
206
|
+
|
|
207
|
+
That last one is the trap, because it applies to a returned *failure* too:
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
with plpy.subtransaction():
|
|
211
|
+
db.query("INSERT …")
|
|
212
|
+
return fail("not_allowed", "…") # the INSERT is COMMITTED
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
`plpy.subtransaction()` reacts to exceptions, and a business failure is not an exception — it
|
|
216
|
+
cannot be, since an exception's type does not survive crossing a function boundary. So the most
|
|
217
|
+
natural code silently half-applies the change. `transaction.atomic` exists for exactly this:
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
from aflib import errors, transaction
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
with transaction.atomic(plpy) as tx:
|
|
224
|
+
db.query("INSERT …")
|
|
225
|
+
if not allowed:
|
|
226
|
+
tx.reject("not_allowed", "You may not do that") # rolls back
|
|
227
|
+
tx.succeed({"id": 7})
|
|
228
|
+
return tx.outcome
|
|
229
|
+
except Exception as exc:
|
|
230
|
+
return errors.to_fail(exc, log=log)
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
`reject()` ends the block and rolls back; `outcome` is read afterwards. A scope that ends
|
|
234
|
+
without `succeed()` or `reject()` raises rather than returning an empty success for work that
|
|
235
|
+
may have half happened.
|
|
236
|
+
|
|
237
|
+
It is deliberately **not** a unit of work and cannot commit anything — the name describes the
|
|
238
|
+
guarantee it provides (the block is all-or-nothing), not machinery it does not have.
|
|
239
|
+
|
|
131
240
|
## Tests
|
|
132
241
|
|
|
133
242
|
```
|
|
@@ -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).
|
|
@@ -26,11 +26,14 @@ packages = ["src/aflib"]
|
|
|
26
26
|
#
|
|
27
27
|
# Note this file is itself published: nothing in these comments should be anything you
|
|
28
28
|
# would not put on a public page.
|
|
29
|
+
# Patterns are gitignore-style: unanchored names match at ANY depth (a bare
|
|
30
|
+
# "README.md" also swept in docs/README.md — observed in the 0.0.6 build). The
|
|
31
|
+
# leading "/" anchors each entry to the repo root so only the intended files ship.
|
|
29
32
|
[tool.hatch.build.targets.sdist]
|
|
30
33
|
include = [
|
|
31
|
-
"src/aflib",
|
|
32
|
-
"tests",
|
|
33
|
-
"README.md",
|
|
34
|
-
"LICENSE",
|
|
34
|
+
"/src/aflib",
|
|
35
|
+
"/tests",
|
|
36
|
+
"/README.md",
|
|
37
|
+
"/LICENSE",
|
|
35
38
|
]
|
|
36
39
|
|
|
@@ -24,33 +24,60 @@ A function body's whole import line, and the shape of a body:
|
|
|
24
24
|
return errors.to_fail(exc, log=log)
|
|
25
25
|
|
|
26
26
|
**The `try` is not optional.** An exception that escapes a function body is returned to the
|
|
27
|
-
HTTP caller
|
|
28
|
-
|
|
27
|
+
HTTP caller carrying internal detail the caller should not see, and a body cannot influence
|
|
28
|
+
that response — so catching everything is a security control, not a matter of taste. `except
|
|
29
29
|
Exception` is also the only clause that works: `plpy.Error` does not inherit from
|
|
30
30
|
`plpy.SPIError`, so the narrower version catches neither `plpy.error()` nor an ordinary
|
|
31
31
|
Python exception.
|
|
32
32
|
"""
|
|
33
33
|
|
|
34
|
-
from aflib import
|
|
34
|
+
from aflib import (
|
|
35
|
+
authz,
|
|
36
|
+
batch,
|
|
37
|
+
codec,
|
|
38
|
+
db,
|
|
39
|
+
errors,
|
|
40
|
+
events,
|
|
41
|
+
functions,
|
|
42
|
+
identity,
|
|
43
|
+
logging,
|
|
44
|
+
params,
|
|
45
|
+
result,
|
|
46
|
+
transaction,
|
|
47
|
+
trigger,
|
|
48
|
+
workflow,
|
|
49
|
+
)
|
|
50
|
+
from aflib.batch import Batch
|
|
35
51
|
from aflib.db import Db
|
|
52
|
+
from aflib.events import Outbox
|
|
36
53
|
from aflib.params import BadParameter
|
|
37
54
|
from aflib.result import fail, ok
|
|
38
55
|
|
|
39
56
|
# Kept in step with `pyproject.toml` by hand. A deployed body returns this so a run can
|
|
40
57
|
# prove which aflib it actually imported — a stale package otherwise looks exactly like a
|
|
41
58
|
# working one.
|
|
42
|
-
__version__ = "0.0.
|
|
59
|
+
__version__ = "0.0.7"
|
|
43
60
|
|
|
44
61
|
__all__ = [
|
|
45
62
|
"BadParameter",
|
|
63
|
+
"Batch",
|
|
46
64
|
"Db",
|
|
65
|
+
"Outbox",
|
|
66
|
+
"authz",
|
|
67
|
+
"batch",
|
|
47
68
|
"codec",
|
|
48
69
|
"db",
|
|
49
70
|
"errors",
|
|
71
|
+
"events",
|
|
50
72
|
"fail",
|
|
73
|
+
"functions",
|
|
74
|
+
"identity",
|
|
51
75
|
"logging",
|
|
52
76
|
"ok",
|
|
53
77
|
"params",
|
|
54
78
|
"result",
|
|
79
|
+
"transaction",
|
|
80
|
+
"trigger",
|
|
81
|
+
"workflow",
|
|
55
82
|
"__version__",
|
|
56
83
|
]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Table GRANTs for the current caller (`entityPermission`).
|
|
2
|
+
|
|
3
|
+
Not RLS and not business rules. `can("AdminCentre.Delivery", "UPDATE")` is
|
|
4
|
+
`has_table_privilege` on this connection. A `SELECT` of a missing or
|
|
5
|
+
RLS-hidden id returns **zero rows**, not an error — do not turn that into
|
|
6
|
+
403.
|
|
7
|
+
|
|
8
|
+
Take a `snapshot` **before** `SET ROLE modelsadmin`. After elevation,
|
|
9
|
+
`has_table_privilege(current_user, …)` is the elevated role. The snapshot
|
|
10
|
+
keeps the caller's answers in a local dict (never `GD`/`SD`).
|
|
11
|
+
|
|
12
|
+
from aflib import authz
|
|
13
|
+
|
|
14
|
+
az = authz.snapshot(plpy)
|
|
15
|
+
if not az.can("AdminCentre.Delivery", "INSERT"):
|
|
16
|
+
return fail("forbidden", "Cannot create a delivery")
|
|
17
|
+
|
|
18
|
+
A single check with no later SET ROLE can call `authz.can(plpy, entity, verb)`
|
|
19
|
+
and skip the snapshot.
|
|
20
|
+
|
|
21
|
+
Observed 2026-09-18: `has_table_privilege` is callable from a plpython
|
|
22
|
+
body; SET ROLE modelsadmin changes `current_user` (when the caller is
|
|
23
|
+
allowed to elevate). HTTP `boolean` params are unrelated (G55).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import re
|
|
29
|
+
|
|
30
|
+
from aflib import identity
|
|
31
|
+
|
|
32
|
+
_IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
33
|
+
_VERBS = frozenset({"SELECT", "INSERT", "UPDATE", "DELETE"})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _regclass(entity: str) -> str:
|
|
37
|
+
parts = entity.split(".")
|
|
38
|
+
if len(parts) != 2:
|
|
39
|
+
raise ValueError("expected Schema.Entity, got {!r}".format(entity))
|
|
40
|
+
for part in parts:
|
|
41
|
+
if not _IDENT.match(part):
|
|
42
|
+
raise ValueError("invalid identifier {!r} in {!r}".format(part, entity))
|
|
43
|
+
return '"{}"."{}"'.format(parts[0], parts[1])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _verb(verb: str) -> str:
|
|
47
|
+
v = (verb or "").upper()
|
|
48
|
+
if v not in _VERBS:
|
|
49
|
+
raise ValueError(
|
|
50
|
+
"verb must be SELECT, INSERT, UPDATE or DELETE, got {!r}".format(verb)
|
|
51
|
+
)
|
|
52
|
+
return v
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def can(plpy, entity, verb, cache=None):
|
|
56
|
+
"""Whether *this connection* has GRANT `verb` on `entity`."""
|
|
57
|
+
table = _regclass(entity)
|
|
58
|
+
v = _verb(verb)
|
|
59
|
+
key = (table, v)
|
|
60
|
+
if cache is not None and key in cache:
|
|
61
|
+
return cache[key]
|
|
62
|
+
plan = plpy.prepare(
|
|
63
|
+
"SELECT has_table_privilege(current_user, $1, $2) AS ok",
|
|
64
|
+
["text", "text"],
|
|
65
|
+
)
|
|
66
|
+
row = plpy.execute(plan, [table, v])[0]
|
|
67
|
+
ok = bool(row["ok"])
|
|
68
|
+
if cache is not None:
|
|
69
|
+
cache[key] = ok
|
|
70
|
+
return ok
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Snapshot:
|
|
74
|
+
"""Caller's GRANTs, frozen at construction time."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, plpy):
|
|
77
|
+
self.username = identity.current_user(plpy)
|
|
78
|
+
self._plpy = plpy
|
|
79
|
+
self._cache = {}
|
|
80
|
+
|
|
81
|
+
def can(self, entity, verb):
|
|
82
|
+
return can(self._plpy, entity, verb, cache=self._cache)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def snapshot(plpy) -> Snapshot:
|
|
86
|
+
return Snapshot(plpy)
|
|
@@ -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)
|
|
@@ -10,11 +10,9 @@
|
|
|
10
10
|
return errors.to_fail(exc, log=log)
|
|
11
11
|
|
|
12
12
|
**Why a body must catch everything.** An exception that escapes is returned to the HTTP
|
|
13
|
-
caller
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
platform produces that response in its own Java handler from the JDBC driver's message —
|
|
17
|
-
there is no setting that suppresses it, and nothing a body can do except not raise.
|
|
13
|
+
caller carrying internal detail that the caller should not see. A body cannot influence that
|
|
14
|
+
response — it is assembled outside the database from the driver's error message, and no
|
|
15
|
+
setting trims it — so the only way to control what a caller receives is to not raise.
|
|
18
16
|
|
|
19
17
|
**`except Exception` is not laziness here, it is the only sufficient clause.** `plpy.Error`
|
|
20
18
|
does not inherit from `plpy.SPIError`, so the narrower `except plpy.SPIError` catches
|
|
@@ -79,7 +77,7 @@ def to_fail(exc, log=None):
|
|
|
79
77
|
"""Log what must stay hidden, and return the failure envelope for the caller.
|
|
80
78
|
|
|
81
79
|
The envelope is returned even if logging fails. A broken sink must not turn a handled
|
|
82
|
-
failure back into an escaping exception — that would
|
|
80
|
+
failure back into an escaping exception — that would expose internal detail at the very moment
|
|
83
81
|
the code was trying to prevent it.
|
|
84
82
|
"""
|
|
85
83
|
failure = classify(exc)
|