| """harness/store.py — the tenant data store (OM-1, 2026-07-11). |
| |
| Incremental Odoo → DuckDB mirror: the local analytical store that saved views/dashboards (OM-3) |
| and the AI Analyst's tools (OM-4) query at warehouse speed, instead of hammering live XML-RPC |
| per question. |
| Plan: .claude/wiki/research/omni-adoption.md Part IV (OM-1) + [[odoo-open-source]] BUILD-NOW #1. |
| |
| Design rules: |
| - The store is a RAW mirror (unscoped). Business scope (wholesale teams, GIFTWARE exclusion, |
| confirmed-only) is applied by the SEMANTIC layer at query time (OM-2) — one source of truth. |
| - Sync is CHECKPOINTED + BOUNDED + RESUMABLE (loop-library discipline): backfill paginates by |
| id; live mode advances a write_date cursor; every run is bounded by max_batches and safe to |
| kill/re-run (upserts are idempotent). |
| - validate() compares the store against LIVE Odoo (raw fidelity: counts + monthly sums to the |
| cent) — the store never validates itself. Odoo hard-deletes (unlink) don't move write_date; |
| the count checks are the drift detector for those. |
| - Client data: the .duckdb file lives under data/store/ (git-ignored). READ-ONLY on Odoo. |
| """ |
| import datetime as dt |
| import json |
| import os |
| import re |
| import sys |
| import time |
| from pathlib import Path |
|
|
| import duckdb |
|
|
| import core.odoo as O |
|
|
| _STORE_DIR = Path(__file__).resolve().parents[1] / "data" / "store" |
|
|
| |
| |
| |
| |
| |
| |
| DB_PATH = Path(os.environ.get("AIOS_DUCKDB_PATH") or (_STORE_DIR / "royal.duckdb")) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _paused_probe = None |
|
|
|
|
| def set_paused_probe(fn): |
| """Install the callable that answers 'is the connector paused for the tenant whose store this |
| process has open?'. `harness.runtime` installs the real one; pass None to remove it.""" |
| global _paused_probe |
| _paused_probe = fn |
|
|
|
|
| def source_paused(): |
| """True when this store's tenant has its Odoo connector paused. Never raises.""" |
| try: |
| return bool(_paused_probe and _paused_probe()) |
| except Exception: |
| return False |
|
|
|
|
| |
| |
| |
| |
| PAUSED_PHASE = "paused" |
|
|
|
|
| def _paused_notice(what, log): |
| |
| |
| |
| msg = (f"[datastore] {what} skipped - the tenant's Odoo connector is PAUSED; the mirror is " |
| f"serving its pause-time state. Resume it under Settings > Connectors.") |
| try: |
| log(msg) |
| except Exception: |
| pass |
| print(msg) |
|
|
|
|
| def path_for(tenant_key): |
| """The canonical DuckDB file for a tenant slug. `royal-imports` keeps the historical |
| `royal.duckdb` name so tenant #0's existing 175 MB file is not orphaned by a rename. |
| |
| File-per-tenant IS the isolation model (C1c): DuckDB has no row-level security, so the only |
| boundary that holds is the operating system's — one file, one tenant. |
| """ |
| key = str(tenant_key or "").strip().lower() |
| if key in ("", "royal-imports"): |
| return DB_PATH if key == "" else Path( |
| os.environ.get("AIOS_DUCKDB_PATH") or (_STORE_DIR / "royal.duckdb")) |
| safe = "".join(c if (c.isalnum() or c in "-_") else "_" for c in key)[:60] |
| return _STORE_DIR / f"{safe}.duckdb" |
|
|
|
|
| def use_path(path): |
| """Repoint the store at `path`, CLOSING the process singleton first. |
| |
| ⚠ A bare `datastore.DB_PATH = …` is not enough and is the more dangerous half of this |
| operation. `_instance()` caches ONE open connection for the life of the process, so after a |
| plain reassignment every read keeps being served from the PREVIOUSLY opened file — a silent |
| cross-tenant read, returning real rows that belong to somebody else. Closing the connection |
| and clearing the readiness memo (which is also per-file) is what makes the switch honest. |
| |
| ⚠ AND CLOSING THE CONNECTION IS STILL NOT ENOUGH ON ITS OWN, because `ro_con()` caches a |
| cursor in a `threading.local`: this function can only clear the CALLING thread's, and |
| `ro_con`'s liveness probe (`SELECT 1`) SUCCEEDS on a cursor whose underlying connection was |
| closed out from under it in some cases — so another thread would keep answering from the old |
| file with no error to notice. The generation counter below is what closes that: `ro_con` |
| compares the generation its cursor was opened under and reopens when it has moved, which is |
| a check no individual thread can forget to do. |
| |
| Returns the new path. Callers: `provision_tenant.py`, and any single-tenant worker process. |
| NOT a per-request operation — one process serves one analytical store at a time, which is why |
| `harness.runtime` hands out the PATH rather than switching the global underneath a request. |
| """ |
| global DB_PATH |
| with _INSTANCE_LOCK: |
| con = _INSTANCE.get("con") |
| if con is not None: |
| try: |
| con.close() |
| except Exception: |
| pass |
| _INSTANCE["con"] = None |
| _RO_TLS.__dict__.pop("cur", None) |
| _READY["ok"] = False |
| _GENERATION[0] += 1 |
| DB_PATH = Path(path) |
| return DB_PATH |
|
|
| |
| |
| ENTITIES = { |
| "sale_order": { |
| "model": "sale.order", "archivable": False, |
| "fields": ["name", "date_order", "partner_id", "team_id", "user_id", "state", |
| "amount_untaxed", "invoice_status", "write_date"], |
| }, |
| "sale_order_line": { |
| "model": "sale.order.line", "archivable": False, |
| "fields": ["order_id", "order_partner_id", "product_id", "product_uom_qty", |
| "price_subtotal", "margin", "purchase_price", "write_date"], |
| }, |
| "res_partner": { |
| "model": "res.partner", "archivable": True, |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| "fields": ["name", "team_id", "city", "state_id", "country_id", "active", |
| "agent", "salesman_as_agent", "customer_rank", |
| "street", "street2", "zip", "write_date"], |
| |
| |
| "backfill_fields": ["agent", "salesman_as_agent", |
| "customer_rank", |
| "street", "street2", "zip"], |
| |
| |
| |
| |
| "m2m": ["agent_ids"], |
| }, |
| "product_product": { |
| "model": "product.product", "archivable": True, |
| "fields": ["default_code", "name", "categ_id", "type", "standard_price", "active", |
| "write_date"], |
| }, |
| "account_move": { |
| "model": "account.move", "archivable": False, |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| "fields": ["name", "move_type", "state", "invoice_date", "invoice_date_due", |
| "partner_id", "amount_untaxed_signed", "amount_residual_signed", |
| "payment_state", "invoice_origin", "write_date"], |
| |
| |
| |
| "backfill_fields": ["invoice_origin"], |
| }, |
| "account_move_line": { |
| "model": "account.move.line", "archivable": False, |
| |
| |
| |
| |
| |
| |
| |
| |
| "fields": ["move_id", "account_id", "partner_id", "date", "debit", "credit", |
| "balance", "display_type", "move_type", "parent_state", "price_subtotal", |
| "product_id", "write_date"], |
| "backfill_fields": ["display_type", "move_type", "parent_state", "price_subtotal", |
| "product_id"], |
| |
| |
| |
| |
| "m2m": ["sale_line_ids"], |
| }, |
| "account_invoice_line_agent": { |
| |
| |
| |
| |
| |
| |
| "model": "account.invoice.line.agent", "archivable": False, |
| "fields": ["agent_id", "commission_id", "amount", "invoice_id", "object_id", |
| "invoice_date", "settled", "write_date"], |
| }, |
| "account_account": { |
| |
| |
| "model": "account.account", "archivable": False, |
| "fields": ["code", "name", "account_type", "write_date"], |
| }, |
| } |
|
|
| BATCH = 2000 |
|
|
| |
| |
| |
| |
| |
| |
| BOOL_FIELDS = ("active", "agent", "salesman_as_agent", "settled", "commission_free") |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| INT_FIELDS = ("customer_rank", "supplier_rank") |
|
|
|
|
| import threading as _thr |
|
|
| _RO_TLS = _thr.local() |
| _READY = {"ok": False} |
| _INSTANCE = {"con": None} |
| _INSTANCE_LOCK = _thr.Lock() |
| |
| |
| |
| |
| _GENERATION = [0] |
|
|
|
|
| def mark_ready(): |
| """In-process signal that the store is fully synced (every entity live). The sync writer calls |
| this after an all-live pass so a reader never has to (re)discover usability by touching the |
| file. Belt-and-suspenders alongside the shared-instance model below.""" |
| _READY["ok"] = True |
|
|
|
|
| def _instance(): |
| """THE one process-wide read-write DuckDB connection. Readers take .cursor() off it and the |
| sync writer uses it too — DuckDB serves concurrent reads + a single writer from ONE instance |
| via MVCC, so a read NEVER contends with a sync pass for the file lock. |
| |
| This is the fix for the 'connection error unless I refresh' bug (2026-07-16): an independent |
| read-only `duckdb.connect()` FAILS while the writer holds the lock ('Can't open a connection |
| to same database file with a different configuration than existing connections'); a cursor on |
| the shared instance does not. Openers must ensure the file is fully present first — ensure_seed |
| writes atomically (os.replace) and every read path guards on DB_PATH.exists() — so the instance |
| never binds a partial or absent file. (Trade-off: the app now holds the write lock for its whole |
| life, so a SEPARATE process — e.g. mcp_server.py — cannot open the same store file concurrently; |
| in production only the app runs, and that was already true during any sync pass.)""" |
| con = _INSTANCE["con"] |
| if con is not None: |
| return con |
| with _INSTANCE_LOCK: |
| if _INSTANCE["con"] is None: |
| DB_PATH.parent.mkdir(parents=True, exist_ok=True) |
| _INSTANCE["con"] = duckdb.connect(str(DB_PATH)) |
| return _INSTANCE["con"] |
|
|
|
|
| def ro_cursor(): |
| """A read cursor on the shared instance — the contention-free way to read the store.""" |
| return _instance().cursor() |
|
|
|
|
| def ready(): |
| """True once EVERY entity has completed its backfill (phase 'live'). Gate all store READS on |
| this: a mid-backfill store would return PARTIAL totals silently — worse than an error. Positive |
| results cache for the process (backfill never regresses). Reads via a shared-instance cursor, |
| so it is never mis-reported as 'not ready' just because a background sync pass is mid-flight.""" |
| if _READY["ok"]: |
| return True |
| if not DB_PATH.exists(): |
| return False |
| try: |
| cur = ro_cursor() |
| try: |
| phases = {r[0]: r[1] for r in |
| cur.execute("SELECT entity, phase FROM _sync_state").fetchall()} |
| finally: |
| cur.close() |
| except Exception: |
| return False |
| if all(phases.get(k) == "live" for k in ENTITIES): |
| _READY["ok"] = True |
| return True |
| return False |
|
|
|
|
| def ro_con(): |
| """Per-thread cached read CURSOR on the shared instance for page/module store reads (the |
| open cost dominates repeated small queries — the Expenses retrofit measured 7.7s→1.0s). No |
| lock contention with the sync writer; a cursor that has gone bad is reopened once. REFUSES |
| until ready() — callers either fall back to live reads (modules) or relay a readable message |
| (Analyst). |
| |
| ⚠ THE GENERATION CHECK IS A CORRECTNESS GUARD, NOT AN OPTIMISATION (EXIT-4a). The liveness |
| probe below asks "does this cursor still work?", which is a different question from "is this |
| cursor still pointing at the file we are supposed to be reading?". After `use_path()` switches |
| tenants, a cursor cached in ANOTHER thread's `threading.local` can still answer `SELECT 1` — |
| and would then serve that thread real, plausible rows from the PREVIOUS tenant's file, with no |
| exception anywhere. `use_path` bumps the generation; a cursor opened under an older one is |
| discarded here, which is the only place every thread is guaranteed to pass through.""" |
| cur = getattr(_RO_TLS, "cur", None) |
| if cur is not None and getattr(_RO_TLS, "gen", None) != _GENERATION[0]: |
| try: |
| cur.close() |
| except Exception: |
| pass |
| cur, _RO_TLS.cur = None, None |
| if cur is not None: |
| try: |
| cur.execute("SELECT 1") |
| return cur |
| except Exception: |
| _RO_TLS.cur = None |
| if not ready(): |
| raise RuntimeError("the tenant data store is completing its FIRST sync on this " |
| "deployment — dashboards work meanwhile (live reads); retry " |
| "store queries in a few minutes") |
| cur = ro_cursor() |
| _RO_TLS.cur = cur |
| _RO_TLS.gen = _GENERATION[0] |
| return cur |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| WINDOW_MAX = 5_000 |
|
|
| |
| |
| WINDOW_DEFAULT = 200 |
|
|
| _IDENT_OK = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") |
|
|
|
|
| def window(table=None, select=None, where=None, params=(), order_by=None, |
| offset=0, limit=WINDOW_DEFAULT, cur=None, from_sql=None): |
| """One page of a mirror table PLUS the true count of everything the same predicate matches. |
| |
| Returns `{"rows": [tuple, ...], "columns": [name, ...], "total": int, "offset": int, |
| "limit": int, "clamped": bool}` — `rows` is the slice, `total` is the population. |
| |
| `select` is the projection expression list ("id, name, amount_untaxed"); `where` is a WHERE |
| body with `?` placeholders; `params` binds them, and is used by BOTH statements. |
| |
| ⚠ `table` and `from_sql` are the SAME argument wearing two trust levels, and they are separate |
| names on purpose. `table` is an identifier and is SHAPE-CHECKED; `from_sql` is a whole FROM |
| body — a parenthesised subquery with an alias — which cannot be checked at all. Two of the |
| eight connected grids need one (`read_customers` and `read_agents` are single statements whose |
| population is a SQL `UNION`), so the capability has to exist; naming it distinctly means a |
| reviewer can grep for every caller that hands over raw FROM SQL instead of inferring it. |
| """ |
| if bool(table) == bool(from_sql): |
| raise ValueError("datastore.window: pass exactly one of `table` or `from_sql`") |
| if from_sql: |
| name = str(from_sql).strip() |
| else: |
| name = str(table or "").strip() |
| if not _IDENT_OK.match(name): |
| raise ValueError(f"datastore.window: {table!r} is not a table identifier") |
| proj = str(select or "").strip() |
| if not proj: |
| raise ValueError("datastore.window: a projection is required — there is no implicit *") |
| try: |
| offset = max(0, int(offset or 0)) |
| except (TypeError, ValueError): |
| offset = 0 |
| try: |
| limit = int(limit if limit is not None else WINDOW_DEFAULT) |
| except (TypeError, ValueError): |
| limit = WINDOW_DEFAULT |
| clamped = limit > WINDOW_MAX or limit < 1 |
| limit = min(max(limit, 1), WINDOW_MAX) |
|
|
| params = tuple(params or ()) |
| pred = str(where or "").strip() |
| tail = f" WHERE {pred}" if pred else "" |
| order = str(order_by or "").strip() or "1" |
| cur = cur if cur is not None else ro_con() |
|
|
| |
| |
| |
| total = int(cur.execute(f"SELECT count(*) FROM {name}{tail}", params).fetchone()[0] or 0) |
| got = cur.execute( |
| f"SELECT {proj} FROM {name}{tail} ORDER BY {order} LIMIT {int(limit)} OFFSET {int(offset)}", |
| params).fetchall() |
| cols = [d[0] for d in (cur.description or [])] |
| return {"rows": [tuple(r) for r in got], "columns": cols, |
| "total": total, "offset": offset, "limit": limit, "clamped": clamped} |
|
|
|
|
| def columns_of(table, cur=None): |
| """The column names a mirror table actually has, lowercased. |
| |
| ⚠ A mirror can be `ready()` and still be MISSING COLUMNS: `ready()` reads entity PHASES, while |
| `_ensure_columns`/`_backfill_columns` checkpoint separately. Asking before projecting is what |
| stops a DuckDB `BinderException: Referenced column … not found` reaching a user as a bare 500 |
| (it already cost one live, which is why `odoo_relational.columns` exists on the app side). |
| """ |
| name = str(table or "").strip() |
| if not _IDENT_OK.match(name): |
| raise ValueError(f"datastore.columns_of: {table!r} is not a table identifier") |
| cur = cur if cur is not None else ro_con() |
| try: |
| rows = cur.execute(f"PRAGMA table_info('{name}')").fetchall() |
| except Exception: |
| return set() |
| return {str(r[1]).lower() for r in rows} |
|
|
|
|
| SEED_DATASET = os.environ.get("STORE_SEED_DATASET", "royal-imports/cfo-os-data") |
|
|
|
|
| def ensure_seed(): |
| """Fresh/ephemeral deployment (HF Space disks reset on every rebuild): hydrate the store |
| from the PRIVATE dataset's seed snapshot — Analyst-ready in ~a minute instead of a 20-40 min |
| XML-RPC backfill; the auto-sync then closes the gap via write_date cursors. Also restores |
| the pilot views/routines when absent. Fail-quiet: no seed/token → normal backfill.""" |
| import shutil |
| if DB_PATH.exists(): |
| return False |
| tok = os.environ.get("HF_TOKEN") |
| if not tok: |
| return False |
| try: |
| from huggingface_hub import hf_hub_download |
| DB_PATH.parent.mkdir(parents=True, exist_ok=True) |
| p = hf_hub_download(SEED_DATASET, "store_seed/royal.duckdb", |
| repo_type="dataset", token=tok) |
| |
| |
| tmp = DB_PATH.with_name(DB_PATH.name + ".tmp") |
| shutil.copyfile(p, tmp) |
| os.replace(tmp, DB_PATH) |
| for fn in ("views.json", "routines.json"): |
| tgt = DB_PATH.parent / fn |
| if not tgt.exists(): |
| try: |
| q = hf_hub_download(SEED_DATASET, f"store_seed/{fn}", |
| repo_type="dataset", token=tok) |
| shutil.copyfile(q, tgt) |
| except Exception: |
| pass |
| return True |
| except Exception: |
| return False |
|
|
|
|
| def connect(): |
| """A cursor on the shared instance for the SYNC WRITER (and status/validate). Returned as a |
| cursor so callers' con.close() frees the cursor without closing the process-wide instance; |
| one writer cursor is active at a time (the sync thread is sequential), readers use others.""" |
| cur = _instance().cursor() |
| cur.execute("""CREATE TABLE IF NOT EXISTS _sync_state ( |
| entity VARCHAR PRIMARY KEY, phase VARCHAR, last_id BIGINT, |
| cursor_wd VARCHAR, rows BIGINT, updated_at VARCHAR)""") |
| return cur |
|
|
|
|
| def _flatten(rec, fields, m2m=()): |
| out = {"id": rec["id"]} |
| for f in m2m: |
| v = rec.get(f) or [] |
| base = f.removesuffix("_ids") |
| out[f"{base}_id"] = v[0] if v else None |
| out[f] = json.dumps(list(v)) if v else None |
| for f in fields: |
| v = rec.get(f) |
| if isinstance(v, (list, tuple)) and len(v) == 2 and isinstance(v[0], int): |
| out[f"{f.removesuffix('_id')}_id"] = v[0] |
| out[f"{f.removesuffix('_id')}_name"] = str(v[1]) |
| elif isinstance(v, (list, tuple)): |
| out[f] = json.dumps(v) |
| elif v is False and f not in BOOL_FIELDS: |
| out[f] = None |
| else: |
| out[f] = v |
| return out |
|
|
|
|
| def _cols(spec): |
| cols = ["id BIGINT PRIMARY KEY"] |
| for f in spec["fields"]: |
| base = f.removesuffix("_id") |
| if f.endswith("_id"): |
| cols += [f"{base}_id BIGINT", f"{base}_name VARCHAR"] |
| elif f in BOOL_FIELDS: |
| cols.append(f"{f} BOOLEAN") |
| elif f in INT_FIELDS: |
| cols.append(f"{f} BIGINT") |
| elif f in ("amount_untaxed", "amount_untaxed_signed", "amount_residual_signed", |
| "price_subtotal", "margin", "purchase_price", "product_uom_qty", |
| "standard_price", "debit", "credit", "balance", "amount"): |
| cols.append(f"{f} DOUBLE") |
| else: |
| cols.append(f"{f} VARCHAR") |
| for f in spec.get("m2m") or []: |
| base = f.removesuffix("_ids") |
| cols += [f"{base}_id BIGINT", f"{f} VARCHAR"] |
| return cols |
|
|
|
|
| def _ensure_table(con, key, spec): |
| con.execute(f"CREATE TABLE IF NOT EXISTS {key} ({', '.join(_cols(spec))})") |
|
|
|
|
| def _ensure_columns(con, key, spec): |
| """Schema evolution: existing stores/seeds predate columns a newer spec introduces — |
| CREATE TABLE IF NOT EXISTS won't add them. Returns the newly added column names.""" |
| have = {r[1] for r in con.execute(f"PRAGMA table_info('{key}')").fetchall()} |
| added = [] |
| for c in _cols(spec): |
| name = c.split()[0] |
| if name not in have: |
| con.execute(f"ALTER TABLE {key} ADD COLUMN {c}") |
| added.append(name) |
| return added |
|
|
|
|
| def _field_cols(f): |
| """The store column(s) one spec field materialises into (m2o fields become _id + _name).""" |
| if f.endswith("_id"): |
| base = f.removesuffix("_id") |
| return [f"{base}_id", f"{base}_name"] |
| return [f] |
|
|
|
|
| def _backfill_columns(con, key, spec, log=print): |
| """Scalar/m2o schema evolution — the twin of _backfill_m2m, and for the same reason. |
| |
| _ensure_columns ALTERs a new column in, but existing rows NEVER re-sync (WE moved the schema; |
| their write_date didn't move), so a newly added field stays NULL forever — silently, and NULL |
| is indistinguishable from a legitimately empty value. That is how a store starts answering |
| 'no rows match' instead of erroring. Re-pull just the affected fields for every row, |
| paginated by id, marked durably so a crash retries rather than declaring completion. |
| |
| The fields are DECLARED in the spec (`backfill_fields`), never inferred from "which columns |
| did _ensure_columns just add" — that inference is wrong on the second run, when the ALTER has |
| already happened and the list comes back empty while the data is still missing. Declaring it |
| makes the migration re-entrant and reviewable. Leave the entry in place after it completes; |
| the durable marker, not the absence of the declaration, is what stops it re-running. |
| """ |
| fields = [f for f in (spec.get("backfill_fields") or []) if f != "write_date"] |
| if not fields: |
| return |
| marker = f"{key}.cols.{','.join(sorted(fields))}" |
| st = con.execute("SELECT phase, last_id, rows FROM _sync_state WHERE entity=?", |
| [marker]).fetchone() |
| if st and st[0] == "done": |
| return |
| |
| |
| |
| |
| |
| last, n = (st[1] or 0, st[2] or 0) if st else (0, 0) |
| if last: |
| log(f" {key}: column backfill resuming at id {last:,} ({n:,} rows already done)") |
| cols = [c for f in fields for c in _field_cols(f)] |
| setter = ", ".join(f"{c}=?" for c in cols) |
| while True: |
| recs = O.search_read(spec["model"], _base_domain(spec) + [("id", ">", last)], |
| fields, limit=5000, order="id asc") |
| if not recs: |
| break |
| rows = [_flatten(r, fields) for r in recs] |
| con.executemany(f"UPDATE {key} SET {setter} WHERE id=?", |
| [[r.get(c) for c in cols] + [r["id"]] for r in rows]) |
| last, n = recs[-1]["id"], n + len(recs) |
| con.execute("INSERT OR REPLACE INTO _sync_state VALUES (?,?,?,?,?,?)", |
| [marker, "running", last, None, n, time.strftime("%Y-%m-%d %H:%M:%S")]) |
| log(f" {key}: column backfill {'+'.join(fields)} -> id {last:,} ({n:,} rows)") |
| con.execute("INSERT OR REPLACE INTO _sync_state VALUES (?,?,?,?,?,?)", |
| [marker, "done", last, None, n, time.strftime("%Y-%m-%d %H:%M:%S")]) |
| log(f" {key}: column backfill complete -> {n:,} rows") |
|
|
|
|
| def _backfill_m2m(con, key, spec, log=print): |
| """One-time targeted backfill after a schema migration: existing rows never re-sync (their |
| write_date didn't move when WE added a column), so pull every record where the m2m field is |
| SET and update in place. Rows with the field empty keep NULL — correct and free. Completion |
| is a durable _sync_state marker ('<key>.m2m.<field>') — NOT 'column just added': a crash |
| between the ALTER and this backfill (seen live: the SSL-failed first run) must retry.""" |
| for f in spec.get("m2m") or []: |
| marker = f"{key}.m2m.{f}" |
| if con.execute("SELECT 1 FROM _sync_state WHERE entity=?", [marker]).fetchone(): |
| continue |
| base = f.removesuffix("_ids") |
| |
| |
| |
| |
| rows, last, page = [], 0, 20000 |
| while True: |
| recs = O.search_read(spec["model"], |
| _base_domain(spec) + [(f, "!=", False), ("id", ">", last)], |
| [f], limit=page, order="id asc") |
| if not recs: |
| break |
| rows += [(r[f][0], json.dumps(list(r[f])), r["id"]) for r in recs if r.get(f)] |
| last = recs[-1]["id"] |
| log(f" {key}: m2m {f} scanned to id {last:,} ({len(rows):,} rows)") |
| if rows: |
| con.executemany(f"UPDATE {key} SET {base}_id=?, {f}=? WHERE id=?", rows) |
| con.execute("INSERT OR REPLACE INTO _sync_state VALUES (?,?,?,?,?,?)", |
| [marker, "done", 0, None, len(rows), time.strftime("%Y-%m-%d %H:%M:%S")]) |
| log(f" {key}: m2m backfill {f} -> {len(rows):,} rows updated") |
|
|
|
|
| def _upsert(con, key, spec, recs): |
| if not recs: |
| return |
| rows = [_flatten(r, spec["fields"], spec.get("m2m") or ()) for r in recs] |
| colnames = [c.split()[0] for c in _cols(spec)] |
| ids = [r["id"] for r in rows] |
| con.execute(f"DELETE FROM {key} WHERE id IN ({','.join(map(str, ids))})") |
| con.executemany( |
| f"INSERT INTO {key} ({', '.join(colnames)}) VALUES ({', '.join('?' for _ in colnames)})", |
| [[r.get(c) for c in colnames] for r in rows]) |
|
|
|
|
| def _state(con, key): |
| row = con.execute("SELECT phase, last_id, cursor_wd, rows FROM _sync_state WHERE entity=?", |
| [key]).fetchone() |
| return {"phase": row[0], "last_id": row[1], "cursor_wd": row[2], "rows": row[3]} if row else None |
|
|
|
|
| def _save_state(con, key, phase, last_id, cursor_wd): |
| n = con.execute(f"SELECT count(*) FROM {key}").fetchone()[0] |
| con.execute("""INSERT OR REPLACE INTO _sync_state VALUES (?,?,?,?,?,?)""", |
| [key, phase, last_id, cursor_wd, n, time.strftime("%Y-%m-%d %H:%M:%S")]) |
|
|
|
|
| def _base_domain(spec): |
| return [("active", "in", [True, False])] if spec["archivable"] else [] |
|
|
|
|
| def sync_entity(key, max_batches=200, batch=BATCH, log=print): |
| """One bounded, resumable sync pass for an entity. Backfill (by id) → live (by write_date). |
| Returns (phase, pulled_rows). |
| |
| ⭐ D-10: refuses BEFORE opening a cursor or touching Odoo while the connector is paused. The |
| guard is here — the innermost function that reads from `O` — rather than only in `sync_all`, |
| so a caller that syncs ONE entity is covered by construction instead of by remembering to |
| add the same check. `sync_all` short-circuits too, but only to avoid eight identical notices. |
| """ |
| if source_paused(): |
| _paused_notice(f"sync of {key}", log) |
| return PAUSED_PHASE, 0 |
| spec = ENTITIES[key] |
| con = connect() |
| _ensure_table(con, key, spec) |
| _ensure_columns(con, key, spec) |
| _backfill_columns(con, key, spec, log=log) |
| _backfill_m2m(con, key, spec, log=log) |
| st = _state(con, key) or {"phase": "backfill", "last_id": 0, "cursor_wd": None, "rows": 0} |
| fields = spec["fields"] + list(spec.get("m2m") or []) |
| pulled = 0 |
| try: |
| if st["phase"] == "backfill": |
| last_id = st["last_id"] or 0 |
| for i in range(max_batches): |
| dom = _base_domain(spec) + [("id", ">", last_id)] |
| recs = O.search_read(spec["model"], dom, ["id"] + fields, |
| limit=batch, order="id asc") |
| if not recs: |
| |
| wd = con.execute(f"SELECT max(write_date) FROM {key}").fetchone()[0] |
| _save_state(con, key, "live", last_id, wd or "1970-01-01 00:00:00") |
| log(f" {key}: BACKFILL COMPLETE ({st['rows'] + pulled:,} rows) -> live mode") |
| return "live", pulled |
| _upsert(con, key, spec, recs) |
| last_id = recs[-1]["id"] |
| pulled += len(recs) |
| _save_state(con, key, "backfill", last_id, None) |
| if (i + 1) % 10 == 0: |
| log(f" {key}: backfill …{pulled:,} rows (id≤{last_id})") |
| log(f" {key}: backfill PAUSED at {pulled:,} rows this run (bounded; resumes)") |
| return "backfill", pulled |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| raw = st["cursor_wd"] or "1970-01-01 00:00:00" |
| cursor, _pipe, _tie = raw.partition("|") |
| walking, tie_id = bool(_pipe), int(_tie or 0) |
| walk_max = cursor |
| for _ in range(max_batches): |
| if walking: |
| dom = _base_domain(spec) + [("write_date", ">=", cursor), ("id", ">", tie_id)] |
| recs = O.search_read(spec["model"], dom, ["id"] + fields, |
| limit=batch, order="id asc") |
| if not recs: |
| try: |
| wm = dt.datetime.strptime(walk_max, "%Y-%m-%d %H:%M:%S") |
| if wm < dt.datetime.utcnow() - dt.timedelta(minutes=2): |
| cursor = (wm + dt.timedelta(seconds=1)).strftime("%Y-%m-%d %H:%M:%S") |
| else: |
| cursor = walk_max |
| except ValueError: |
| cursor = walk_max |
| walking = False |
| log(f" {key}: id-walk complete -> cursor {cursor}") |
| break |
| _upsert(con, key, spec, recs) |
| pulled += len(recs) |
| tie_id = recs[-1]["id"] |
| walk_max = max(walk_max, max(str(r["write_date"]) for r in recs)) |
| _save_state(con, key, "live", st["last_id"], f"{cursor}|{tie_id}") |
| continue |
| dom = _base_domain(spec) + [("write_date", ">=", cursor)] |
| recs = O.search_read(spec["model"], dom, ["id"] + fields, |
| limit=batch, order="write_date asc, id asc") |
| if not recs or (len(recs) == 1 and str(recs[0].get("write_date")) == cursor |
| and pulled == 0 and _already(con, key, recs[0])): |
| break |
| new_cursor = str(recs[-1]["write_date"]) |
| if new_cursor == cursor and len(recs) == batch: |
| |
| |
| |
| walking, tie_id, walk_max = True, 0, cursor |
| _save_state(con, key, "live", st["last_id"], f"{cursor}|0") |
| log(f" {key}: mass-stamp second at {cursor} -> switching to id-walk") |
| continue |
| _upsert(con, key, spec, recs) |
| pulled += len(recs) |
| if new_cursor == cursor and len(recs) < batch: |
| cursor = new_cursor |
| break |
| cursor = new_cursor |
| |
| |
| |
| _save_state(con, key, "live", st["last_id"], cursor) |
| _save_state(con, key, "live", st["last_id"], |
| f"{cursor}|{tie_id}" if walking else cursor) |
| if pulled: |
| log(f" {key}: live sync +{pulled:,} rows (cursor {cursor}" |
| + (f" walking id>{tie_id}" if walking else "") + ")") |
| return "live", pulled |
| finally: |
| con.close() |
|
|
|
|
| def _already(con, key, rec): |
| return bool(con.execute(f"SELECT 1 FROM {key} WHERE id=?", [rec["id"]]).fetchone()) |
|
|
|
|
| def sync_all(entities=None, max_batches=200, log=print): |
| |
| |
| |
| |
| |
| if source_paused(): |
| _paused_notice("sync", log) |
| return {} |
| out = {} |
| for key in (entities or list(ENTITIES)): |
| t0 = time.time() |
| phase, pulled = sync_entity(key, max_batches=max_batches, log=log) |
| out[key] = {"phase": phase, "pulled": pulled, "secs": round(time.time() - t0, 1)} |
| |
| |
| if entities is None and out and all(v["phase"] == "live" for v in out.values()): |
| mark_ready() |
| return out |
|
|
|
|
| def reconcile_deletes(entities=None, log=print): |
| """Remove store rows hard-deleted in Odoo. unlink() doesn't move write_date, so the cursor |
| sync can never see a deletion — the count parity DETECTS the drift (by design); this repairs |
| it. Pulls the full live id set per entity in bounded id-ascending pages (id-only reads are |
| cheap) and deletes store ids not present live. Run from sync_runner / maintenance, not the |
| app's frequent passes. |
| |
| ⭐ D-10: it pulls the FULL live id set per entity, so it is the single largest Odoo read in |
| this module and it runs unattended at boot and every fourth resync pass. Paused means paused. |
| """ |
| if source_paused(): |
| _paused_notice("delete reconcile", log) |
| return {} |
| con = connect() |
| try: |
| out = {} |
| for key in (entities or list(ENTITIES)): |
| spec = ENTITIES[key] |
| live_ids, last = set(), 0 |
| while True: |
| recs = O.search_read(spec["model"], _base_domain(spec) + [("id", ">", last)], |
| ["id"], limit=50000, order="id asc") |
| if not recs: |
| break |
| live_ids.update(r["id"] for r in recs) |
| last = recs[-1]["id"] |
| store_ids = {r[0] for r in con.execute(f"SELECT id FROM {key}").fetchall()} |
| dead = store_ids - live_ids |
| if dead: |
| con.execute("CREATE TEMP TABLE IF NOT EXISTS _dead(id BIGINT)") |
| con.execute("DELETE FROM _dead") |
| con.executemany("INSERT INTO _dead VALUES (?)", [[i] for i in dead]) |
| con.execute(f"DELETE FROM {key} WHERE id IN (SELECT id FROM _dead)") |
| con.execute("DELETE FROM _dead") |
| n = con.execute(f"SELECT count(*) FROM {key}").fetchone()[0] |
| con.execute("UPDATE _sync_state SET rows=? WHERE entity=?", [n, key]) |
| out[key] = len(dead) |
| log(f" {key}: reconciled {len(dead):,} hard-deleted rows") |
| return out |
| finally: |
| con.close() |
|
|
|
|
| def status(): |
| con = connect() |
| try: |
| return {r[0]: {"phase": r[1], "cursor": r[3], "rows": r[4], "updated": r[5]} |
| for r in con.execute("SELECT * FROM _sync_state").fetchall()} |
| finally: |
| con.close() |
|
|
|
|
| |
|
|
| def _check(name, ours, live, tol=0.01): |
| gap = abs((ours or 0) - (live or 0)) |
| return {"check": name, "ok": gap <= tol, "gap": round(gap, 4), "ours": ours, "live": live} |
|
|
|
|
| def validate(pre=None, months=("2026-01-01", "2026-07-01")): |
| """Raw-fidelity parity: the store must mirror live Odoo — row counts per synced entity + |
| unscoped monetary sums over a window, to the cent. Only checks entities in LIVE phase. |
| |
| ⭐ D-10 REFUSES LOUDLY HERE rather than skipping quietly, and the difference matters. Every |
| other paused path has a correct silent answer — serve the mirror — because the mirror is the |
| thing being asked for. This function's ENTIRE contract is "compare me against live Odoo", so |
| a paused version of it has no honest result: skipping would return an empty pass, and |
| proceeding would make the live call the pause forbids. An operator running a reconciliation |
| against a paused connector has asked a question that cannot be answered, and being told so is |
| the only outcome that is not a lie. (Operator-invoked only — nothing in the serving path |
| calls it, so this raises for a person, never inside a request.) |
| """ |
| if source_paused(): |
| raise RuntimeError( |
| "the tenant's Odoo connector is PAUSED, so the store cannot be validated against " |
| "live Odoo - resume it under Settings > Connectors and run this again") |
| con = connect() |
| out = [] |
| try: |
| live_state = status() |
| d0, d1 = months |
| for key, spec in ENTITIES.items(): |
| if live_state.get(key, {}).get("phase") != "live": |
| continue |
| ours = con.execute(f"SELECT count(*) FROM {key}").fetchone()[0] |
| live = O.get_odoo().search_count(spec["model"], _base_domain(spec)) |
| out.append(_check(f"store.{key} row count == live search_count", ours, live, tol=0)) |
| if live_state.get("sale_order_line", {}).get("phase") == "live": |
| ours = con.execute( |
| "SELECT coalesce(sum(l.price_subtotal),0) FROM sale_order_line l " |
| "JOIN sale_order o ON o.id = l.order_id " |
| "WHERE o.date_order >= ? AND o.date_order < ?", [d0, d1]).fetchone()[0] |
| live = O.sum_field("sale.order.line", |
| [("order_id.date_order", ">=", f"{d0} 00:00:00"), |
| ("order_id.date_order", "<", f"{d1} 00:00:00")], |
| "price_subtotal") |
| out.append(_check(f"store Σ line revenue [{d0}..{d1}) == live (unscoped)", ours, live)) |
| if live_state.get("sale_order", {}).get("phase") == "live": |
| ours = con.execute( |
| "SELECT coalesce(sum(amount_untaxed),0) FROM sale_order " |
| "WHERE date_order >= ? AND date_order < ?", [d0, d1]).fetchone()[0] |
| live = O.sum_field("sale.order", |
| [("date_order", ">=", f"{d0} 00:00:00"), |
| ("date_order", "<", f"{d1} 00:00:00")], "amount_untaxed") |
| out.append(_check(f"store Σ order amount_untaxed [{d0}..{d1}) == live (unscoped)", |
| ours, live)) |
| finally: |
| con.close() |
| return out |
|
|
|
|
| if __name__ == "__main__": |
| args = sys.argv[1:] |
| if args and args[0] == "status": |
| for k, v in status().items(): |
| print(f" {k:18s} {v['phase']:9s} rows={v['rows']:>9,} cursor={v['cursor']} ({v['updated']})") |
| elif args and args[0] == "reconcile": |
| ents = args[1].split(",") if len(args) > 1 else None |
| for k, n in reconcile_deletes(ents).items(): |
| print(f" {k:18s} -{n:,} hard-deleted rows") |
| elif args and args[0] == "validate": |
| ok = True |
| for c in validate(): |
| mark = "OK " if c["ok"] else "XX " |
| ok &= c["ok"] |
| print(f" {mark}{c['check'][:80]} gap={c['gap']}") |
| print("PARITY:", "GREEN" if ok else "RED") |
| sys.exit(0 if ok else 1) |
| else: |
| ents = args[0].split(",") if args else None |
| mb = int(args[1]) if len(args) > 1 else 200 |
| for k, v in sync_all(ents, max_batches=mb).items(): |
| print(f" {k:18s} {v['phase']:9s} +{v['pulled']:,} rows in {v['secs']}s") |
|
|