Deploy AIOS web (React glide grid + FastAPI slice)
Browse files- RELEASES.json +7 -1
- VERSION +1 -1
- api/automation_engine.py +66 -27
- api/rollup_sql.py +13 -2
RELEASES.json
CHANGED
|
@@ -1,6 +1,12 @@
|
|
| 1 |
{
|
| 2 |
-
"current": "
|
| 3 |
"releases": [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
{
|
| 5 |
"version": "v36",
|
| 6 |
"sha": "51db3b7",
|
|
|
|
| 1 |
{
|
| 2 |
+
"current": "v37 (1ddd8cf)",
|
| 3 |
"releases": [
|
| 4 |
+
{
|
| 5 |
+
"version": "v37",
|
| 6 |
+
"sha": "1ddd8cf",
|
| 7 |
+
"date": "2026-08-21",
|
| 8 |
+
"subject": "W39: coalesce derived workspace reads"
|
| 9 |
+
},
|
| 10 |
{
|
| 11 |
"version": "v36",
|
| 12 |
"sha": "51db3b7",
|
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
v37 (1ddd8cf)
|
api/automation_engine.py
CHANGED
|
@@ -12626,11 +12626,13 @@ def _table_handle(row, url_field):
|
|
| 12626 |
or "").strip().lower()
|
| 12627 |
|
| 12628 |
|
| 12629 |
-
def compute_metric_cells(rt, table_key, today=None):
|
| 12630 |
"""Recompute every metric cell on ONE table from the master series. One coalesced write,
|
| 12631 |
only when something actually changed (the flush-ceiling law); zero reads when the table
|
| 12632 |
has no metric fields or the master is off. Returns the number of rows touched."""
|
| 12633 |
-
|
|
|
|
|
|
|
| 12634 |
mfields = metric_fields_of_table(t)
|
| 12635 |
if not mfields:
|
| 12636 |
return 0
|
|
@@ -12664,6 +12666,11 @@ def compute_metric_cells(rt, table_key, today=None):
|
|
| 12664 |
tt.setdefault("rows", {}).setdefault(rid, {}).update(vals)
|
| 12665 |
return cur
|
| 12666 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12667 |
rt.update(UT_STORE_KEY, _up, flush="sync")
|
| 12668 |
return len(changes)
|
| 12669 |
|
|
@@ -13204,13 +13211,30 @@ def _refresh_relations_inplace(tables, log=print):
|
|
| 13204 |
return touched
|
| 13205 |
|
| 13206 |
|
| 13207 |
-
def refresh_relations(rt, log=print):
|
| 13208 |
"""The tick half of the relational pass β the twin of `refresh_metrics`.
|
| 13209 |
|
| 13210 |
β Runs for EVERY table, because a link can point anywhere: a rollup on table A goes stale
|
| 13211 |
when table B gains a row, and A has no way to know that happened. Cheap by construction β a
|
| 13212 |
table declaring neither kind costs one dict scan.
|
| 13213 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13214 |
snapshot = {
|
| 13215 |
str(key): {**(table or {}),
|
| 13216 |
"rows": {str(rid): dict(row or {})
|
|
@@ -13244,15 +13268,17 @@ def refresh_relations(rt, log=print):
|
|
| 13244 |
return actual[0]
|
| 13245 |
|
| 13246 |
|
| 13247 |
-
def refresh_metrics(rt, today=None, log=print):
|
| 13248 |
"""The tick half of the C7 amendment: `today` advances at tick cadence, so a date-window
|
| 13249 |
metric can never go staler than one tick while a scheduler exists. Cheap by construction β
|
| 13250 |
a table without metric fields costs a dict scan and nothing else."""
|
|
|
|
| 13251 |
touched = 0
|
| 13252 |
-
for tk, t in
|
| 13253 |
if metric_fields_of_table(t):
|
| 13254 |
try:
|
| 13255 |
-
touched += compute_metric_cells(rt, tk, today=today
|
|
|
|
| 13256 |
except Exception as e: # noqa: BLE001
|
| 13257 |
log(f"[aios-auto] metric refresh {tk} failed: {type(e).__name__}: {e}")
|
| 13258 |
return touched
|
|
@@ -14531,25 +14557,37 @@ def tick(rt, tenant, now=None, log=print):
|
|
| 14531 |
# C7's amendment: date-window metrics advance with the tick, so `today` is never staler
|
| 14532 |
# than one tick while a scheduler exists. A tenant without metric fields pays a dict scan.
|
| 14533 |
try:
|
| 14534 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14535 |
except Exception as e: # noqa: BLE001
|
| 14536 |
_derived_ok = False
|
| 14537 |
-
|
| 14538 |
-
|
| 14539 |
-
|
| 14540 |
-
|
| 14541 |
-
|
| 14542 |
-
|
| 14543 |
-
|
| 14544 |
-
|
| 14545 |
-
|
| 14546 |
-
|
| 14547 |
-
|
| 14548 |
-
|
| 14549 |
-
|
| 14550 |
-
|
| 14551 |
-
|
| 14552 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14553 |
if _derived_ok and _derived_marker is not None:
|
| 14554 |
_DERIVED_TICK_STATE[str(tenant)] = _derived_marker
|
| 14555 |
if started:
|
|
@@ -14603,7 +14641,7 @@ def _derived_refresh_due(rt, tenant, now=None, state=None, backend=None):
|
|
| 14603 |
return memo.get(str(tenant)) != marker, marker
|
| 14604 |
|
| 14605 |
|
| 14606 |
-
def _refresh_source_rollups(rt, tenant, log=print):
|
| 14607 |
"""Refresh every source-backed rollup; callers provide the no-op policy.
|
| 14608 |
|
| 14609 |
The existing per-pass error boundary lives in :func:`tick`. Keeping this helper focused on
|
|
@@ -14611,10 +14649,11 @@ def _refresh_source_rollups(rt, tenant, log=print):
|
|
| 14611 |
schedule, vendor call, or a hidden duplicate of the loop.
|
| 14612 |
"""
|
| 14613 |
import rollup_sql
|
| 14614 |
-
|
| 14615 |
-
|
|
|
|
| 14616 |
# `today` is DEFAULTED INSIDE `compute`, matching `refresh_metrics` above.
|
| 14617 |
-
n = rollup_sql.compute(rt, _tk)
|
| 14618 |
if n:
|
| 14619 |
log(f"[aios-auto] source rollups {tenant}/{_tk}: {n}")
|
| 14620 |
|
|
|
|
| 12626 |
or "").strip().lower()
|
| 12627 |
|
| 12628 |
|
| 12629 |
+
def compute_metric_cells(rt, table_key, today=None, tables=None, persist=False):
|
| 12630 |
"""Recompute every metric cell on ONE table from the master series. One coalesced write,
|
| 12631 |
only when something actually changed (the flush-ceiling law); zero reads when the table
|
| 12632 |
has no metric fields or the master is off. Returns the number of rows touched."""
|
| 12633 |
+
owned = tables is not None
|
| 12634 |
+
blob = tables if owned else None
|
| 12635 |
+
t = ((blob or {}).get(str(table_key)) if owned else ut_get(rt, table_key))
|
| 12636 |
mfields = metric_fields_of_table(t)
|
| 12637 |
if not mfields:
|
| 12638 |
return 0
|
|
|
|
| 12666 |
tt.setdefault("rows", {}).setdefault(rid, {}).update(vals)
|
| 12667 |
return cur
|
| 12668 |
|
| 12669 |
+
if owned:
|
| 12670 |
+
_up(blob)
|
| 12671 |
+
if persist:
|
| 12672 |
+
rt.update(UT_STORE_KEY, _up, flush="sync")
|
| 12673 |
+
return len(changes)
|
| 12674 |
rt.update(UT_STORE_KEY, _up, flush="sync")
|
| 12675 |
return len(changes)
|
| 12676 |
|
|
|
|
| 13211 |
return touched
|
| 13212 |
|
| 13213 |
|
| 13214 |
+
def refresh_relations(rt, log=print, tables=None, persist=True):
|
| 13215 |
"""The tick half of the relational pass β the twin of `refresh_metrics`.
|
| 13216 |
|
| 13217 |
β Runs for EVERY table, because a link can point anywhere: a rollup on table A goes stale
|
| 13218 |
when table B gains a row, and A has no way to know that happened. Cheap by construction β a
|
| 13219 |
table declaring neither kind costs one dict scan.
|
| 13220 |
"""
|
| 13221 |
+
if tables is not None:
|
| 13222 |
+
# The scheduler owns this mutable snapshot for one derived pass. Compute locally first
|
| 13223 |
+
# so an unchanged relation does not pay a second full-document read; a changed relation is
|
| 13224 |
+
# still reapplied against current locked state below.
|
| 13225 |
+
local = _refresh_relations_inplace(tables, log=log)
|
| 13226 |
+
if not local or not persist:
|
| 13227 |
+
return local
|
| 13228 |
+
actual = [0]
|
| 13229 |
+
|
| 13230 |
+
def _up(cur):
|
| 13231 |
+
cur = cur if isinstance(cur, dict) else {}
|
| 13232 |
+
actual[0] = _refresh_relations_inplace(cur, log=log)
|
| 13233 |
+
return cur
|
| 13234 |
+
|
| 13235 |
+
rt.update(UT_STORE_KEY, _up, flush="async")
|
| 13236 |
+
return actual[0]
|
| 13237 |
+
|
| 13238 |
snapshot = {
|
| 13239 |
str(key): {**(table or {}),
|
| 13240 |
"rows": {str(rid): dict(row or {})
|
|
|
|
| 13268 |
return actual[0]
|
| 13269 |
|
| 13270 |
|
| 13271 |
+
def refresh_metrics(rt, today=None, log=print, tables=None, persist=True):
|
| 13272 |
"""The tick half of the C7 amendment: `today` advances at tick cadence, so a date-window
|
| 13273 |
metric can never go staler than one tick while a scheduler exists. Cheap by construction β
|
| 13274 |
a table without metric fields costs a dict scan and nothing else."""
|
| 13275 |
+
snapshot = tables if tables is not None else ut_all(rt)
|
| 13276 |
touched = 0
|
| 13277 |
+
for tk, t in snapshot.items():
|
| 13278 |
if metric_fields_of_table(t):
|
| 13279 |
try:
|
| 13280 |
+
touched += compute_metric_cells(rt, tk, today=today, tables=snapshot,
|
| 13281 |
+
persist=persist)
|
| 13282 |
except Exception as e: # noqa: BLE001
|
| 13283 |
log(f"[aios-auto] metric refresh {tk} failed: {type(e).__name__}: {e}")
|
| 13284 |
return touched
|
|
|
|
| 14557 |
# C7's amendment: date-window metrics advance with the tick, so `today` is never staler
|
| 14558 |
# than one tick while a scheduler exists. A tenant without metric fields pays a dict scan.
|
| 14559 |
try:
|
| 14560 |
+
# One full workspace snapshot is the budget for this whole derived pass. The
|
| 14561 |
+
# individual workers mutate only that local snapshot while they decide whether a
|
| 14562 |
+
# value changed, then use their established guarded RMW only when it did. A read
|
| 14563 |
+
# failure is not converted into an empty snapshot or a success memo.
|
| 14564 |
+
derived_tables = dict(rt.get(UT_STORE_KEY) or {})
|
| 14565 |
except Exception as e: # noqa: BLE001
|
| 14566 |
_derived_ok = False
|
| 14567 |
+
derived_tables = None
|
| 14568 |
+
log(f"[aios-auto] derived workspace snapshot {tenant} failed: "
|
| 14569 |
+
f"{type(e).__name__}: {e}")
|
| 14570 |
+
if derived_tables is not None:
|
| 14571 |
+
try:
|
| 14572 |
+
refresh_metrics(rt, log=log, tables=derived_tables)
|
| 14573 |
+
except Exception as e: # noqa: BLE001
|
| 14574 |
+
_derived_ok = False
|
| 14575 |
+
log(f"[aios-auto] metric refresh {tenant} failed: {type(e).__name__}: {e}")
|
| 14576 |
+
# β 2026-08-07 β the relational pass, on the same tick and for the same reason. A rollup
|
| 14577 |
+
# on table A goes stale when table B gains a row, and A cannot know that happened; the
|
| 14578 |
+
# tick is the only place that sees both. Separate failure handling is retained.
|
| 14579 |
+
try:
|
| 14580 |
+
refresh_relations(rt, log=log, tables=derived_tables)
|
| 14581 |
+
except Exception as e: # noqa: BLE001
|
| 14582 |
+
_derived_ok = False
|
| 14583 |
+
log(f"[aios-auto] relation refresh {tenant} failed: {type(e).__name__}: {e}")
|
| 14584 |
+
# Source-backed rollups move with Odoo and with their own date windows, so they share the
|
| 14585 |
+
# same durable-revision/day gate but retain their independent failure posture.
|
| 14586 |
+
try:
|
| 14587 |
+
_refresh_source_rollups(rt, tenant, log=log, tables=derived_tables)
|
| 14588 |
+
except Exception as e: # noqa: BLE001
|
| 14589 |
+
_derived_ok = False
|
| 14590 |
+
log(f"[aios-auto] source rollup {tenant} failed: {type(e).__name__}: {e}")
|
| 14591 |
if _derived_ok and _derived_marker is not None:
|
| 14592 |
_DERIVED_TICK_STATE[str(tenant)] = _derived_marker
|
| 14593 |
if started:
|
|
|
|
| 14641 |
return memo.get(str(tenant)) != marker, marker
|
| 14642 |
|
| 14643 |
|
| 14644 |
+
def _refresh_source_rollups(rt, tenant, log=print, tables=None):
|
| 14645 |
"""Refresh every source-backed rollup; callers provide the no-op policy.
|
| 14646 |
|
| 14647 |
The existing per-pass error boundary lives in :func:`tick`. Keeping this helper focused on
|
|
|
|
| 14649 |
schedule, vendor call, or a hidden duplicate of the loop.
|
| 14650 |
"""
|
| 14651 |
import rollup_sql
|
| 14652 |
+
snapshot = tables if tables is not None else (rt.get(UT_STORE_KEY) or {})
|
| 14653 |
+
for _tk, _table in list(snapshot.items()):
|
| 14654 |
+
if rollup_sql.source_fields(_table or {}):
|
| 14655 |
# `today` is DEFAULTED INSIDE `compute`, matching `refresh_metrics` above.
|
| 14656 |
+
n = rollup_sql.compute(rt, _tk, tables=snapshot, persist=True)
|
| 14657 |
if n:
|
| 14658 |
log(f"[aios-auto] source rollups {tenant}/{_tk}: {n}")
|
| 14659 |
|
api/rollup_sql.py
CHANGED
|
@@ -125,7 +125,7 @@ def _today():
|
|
| 125 |
return _dt.date.today().strftime("%Y-%m-%d")
|
| 126 |
|
| 127 |
|
| 128 |
-
def compute(rt, table_key, today=None, tables=None):
|
| 129 |
"""Write changed source-backed rollup cells on `table_key`.
|
| 130 |
|
| 131 |
Returns ``{field_key: cells_changed}``. A store-backed call first applies the already-resolved
|
|
@@ -137,7 +137,10 @@ def compute(rt, table_key, today=None, tables=None):
|
|
| 137 |
|
| 138 |
`tables` (a live `user_tables` dict) is the gate's injection point β the same shape
|
| 139 |
`automation_engine.compute_relation_cells` takes, so this can be proven against a fixture
|
| 140 |
-
without a store.
|
|
|
|
|
|
|
|
|
|
| 141 |
"""
|
| 142 |
ut = _ut()
|
| 143 |
owned = tables is not None
|
|
@@ -183,6 +186,14 @@ def compute(rt, table_key, today=None, tables=None):
|
|
| 183 |
|
| 184 |
if owned:
|
| 185 |
_apply(blob)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
return {k: n for k, n in written.items() if n}
|
| 187 |
|
| 188 |
# A snapshot preflight costs one full read only on a due source-rollup pass. It avoids the far
|
|
|
|
| 125 |
return _dt.date.today().strftime("%Y-%m-%d")
|
| 126 |
|
| 127 |
|
| 128 |
+
def compute(rt, table_key, today=None, tables=None, persist=False):
|
| 129 |
"""Write changed source-backed rollup cells on `table_key`.
|
| 130 |
|
| 131 |
Returns ``{field_key: cells_changed}``. A store-backed call first applies the already-resolved
|
|
|
|
| 137 |
|
| 138 |
`tables` (a live `user_tables` dict) is the gate's injection point β the same shape
|
| 139 |
`automation_engine.compute_relation_cells` takes, so this can be proven against a fixture
|
| 140 |
+
without a store. A scheduler that already paid for one full snapshot passes ``persist=True``:
|
| 141 |
+
the preflight then reuses that snapshot, while a changed result still takes the ordinary locked
|
| 142 |
+
update against fresh store state. That saves the otherwise N+1 document reads without ever
|
| 143 |
+
replacing a concurrent human edit with the stale snapshot.
|
| 144 |
"""
|
| 145 |
ut = _ut()
|
| 146 |
owned = tables is not None
|
|
|
|
| 186 |
|
| 187 |
if owned:
|
| 188 |
_apply(blob)
|
| 189 |
+
changed = {k: n for k, n in written.items() if n}
|
| 190 |
+
if not changed or not persist:
|
| 191 |
+
return changed
|
| 192 |
+
# The caller's snapshot remains useful to its sibling derived passes, but it is never
|
| 193 |
+
# uploaded wholesale. Reapply the already-resolved plan inside the normal FOR UPDATE
|
| 194 |
+
# transaction, exactly as the ordinary path below does.
|
| 195 |
+
written.clear()
|
| 196 |
+
rt.update(ut.STORE_KEY, _apply, flush="async")
|
| 197 |
return {k: n for k, n in written.items() if n}
|
| 198 |
|
| 199 |
# A snapshot preflight costs one full read only on a due source-rollup pass. It avoids the far
|