pipekit/reconcile.py
Paul Trowbridge 002de48bba Make reconcile.py work against SQL Server / linked-server sources
reconcile.py was written against the DB2 LGDAT modules and broke four
different ways on the first GP module (sop30200):

- detect_source_from's regex handled at most a two-part name, so
  CHG.dbo.SOP30200 truncated to CHG.dbo -> "Invalid object name".
  Now matches 1-4 parts, including [bracketed] and "quoted" forms.

- Even fully qualified, that table only exists behind the GPSERVER
  linked server. Detect the module's OPENQUERY wrapper and push the
  aggregate through it, so the scan runs remotely and one row comes
  back instead of 1.6M rows crossing the link.

- OPENQUERY caps its passthrough string at 8000 chars and 291 metrics
  overran it. Split the metric list into chunks that fit and CROSS JOIN
  them back into one row; alias columns c0..cN since OPENQUERY rejects
  unnamed result columns (msg 8155). EXEC(@sql) AT has no such cap but
  needs RPC Out, which GPSERVER has disabled.

- Two dialect bugs that would hit any SQL Server source: T-SQL SUM(int)
  stays int and overflowed at 2^31 where Postgres promotes to bigint,
  and T-SQL has no LENGTH. Added Driver.sum_expression /
  length_expression with mssql overrides -- integer types cast to
  BIGINT (decimals left alone so scale isn't lost), LENGTH -> LEN.

--super-quick and --quick now run clean end to end. Full depth is
syntax-checked only; it wasn't run against the server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 02:25:24 -04:00

343 lines
14 KiB
Python
Executable File

#!/usr/bin/env python3
"""Cross-database reconciliation for a pipekit module.
Compares the live source table against the synced dest table using column-wise
aggregates (COUNT / SUM / MIN / MAX / SUM(LENGTH)). These rely only on
arithmetic and ordering — which DB2 for i and Postgres compute identically — so
no shared hash function or byte-identical row serialization is required.
The source side re-applies the module's per-column transform via the source
driver's ``default_expression`` (RTRIM on char, junk-date -> NULL on dates,
raw on numerics), matching exactly what the sync wrote into the dest, so the
aggregates line up when the row sets agree.
Usage:
PIPEKIT_SECRETS=/etc/pipekit/secrets.env \\
.venv/bin/python reconcile.py <module_name> \\
[--quick | --super-quick] [--source-from EXPR]
Depth: default compares every column; ``--quick`` drops min/max/len_sum;
``--super-quick`` compares only the row counts (COUNT(*) and COUNT(DISTINCT
merge_key)) — seconds even on multi-million-row tables, and enough to catch
missing or duplicated rows, but blind to changed column values.
Exit code is non-zero when any metric diverges.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from decimal import Decimal, InvalidOperation
from pipekit import repo, jrunner, drivers
def classify(dest_type: str) -> str:
"""Bucket a dest column type into numeric / temporal / text."""
t = (dest_type or "").strip().lower()
if t.startswith(("numeric", "decimal", "int", "bigint", "smallint",
"double", "real", "float")):
return "numeric"
if t.startswith(("date", "time", "timestamp")):
return "temporal"
return "text"
def is_expression(source_name: str) -> bool:
"""True when a columns_json source_name is a SQL expression, not a column.
Derived columns (e.g. qtnote's merge key ``SUBSTR(GGKEY,1,9)``) are stored
in columns_json with the expression in ``source_name``. Those must be
emitted verbatim — quoting them as an identifier yields SQL0206, and
wrapping them in ``default_expression`` would re-apply a transform the
module's own SELECT already applied.
"""
return "(" in (source_name or "")
def source_expression(drv, column: dict) -> str:
"""Source-side SQL for a column: verbatim if derived, else transformed."""
name = column["source_name"]
if is_expression(name):
return name
return drv.default_expression(column["source_type"], name)
# One name part: "quoted", [bracketed], or bare. A table reference is 1-4 of
# them dot-joined — SQL Server's linked-server form (server.db.schema.table) is
# the reason for 4, GP's CHG.dbo.SOP30200 the reason 2 was never enough.
_PART = r'(?:"[^"]+"|\[[^\]]+\]|[\w#$]+)'
_FROM_RE = re.compile(rf'FROM\s+({_PART}(?:\.{_PART}){{0,3}})', re.IGNORECASE)
_OPENQUERY_RE = re.compile(r'OPENQUERY\s*\(\s*([\w#$]+)\s*,', re.IGNORECASE)
def _base_name(ref: str) -> str:
"""Last part of a dotted table reference, unquoted and lowercased."""
return ref.split(".")[-1].strip('"').strip("[]").lower()
def detect_source_from(source_query: str, dest_table: str) -> str | None:
"""Best-effort: find the base source table in the module's SELECT.
Picks the ``FROM schema.table`` whose table name matches the dest table's
base name (the modules here name the dest after the source); falls back to
the last FROM match. Returns None if nothing looks like a table.
"""
matches = [m.strip() for m in _FROM_RE.findall(source_query) if "." in m]
if not matches:
return None
dest_base = _base_name(dest_table)
for m in matches:
if _base_name(m) == dest_base:
return m
return matches[-1]
def detect_openquery_server(source_query: str) -> str | None:
"""Linked-server name if the module reads through ``OPENQUERY(SRV, '...')``.
The GP modules wrap their whole SELECT in OPENQUERY so the work runs on the
remote server. The table named inside that string (``CHG.dbo.SOP30200``)
does not resolve on the local connection, so reconcile has to push its
aggregate through the same wrapper rather than querying the table directly.
"""
m = _OPENQUERY_RE.search(source_query)
return m.group(1) if m else None
def build_openquery_sql(server: str, table: str, exprs: list[str], *,
limit: int = 7000) -> str:
"""Aggregate ``exprs`` over ``table`` on linked server ``server``.
Columns are aliased ``c0..cN`` because OPENQUERY rejects a passthrough
result set with unnamed columns (msg 8155); the aliases are positional and
results are still zipped by position, never by name.
OPENQUERY's passthrough string is capped at 8000 characters and a wide
module overruns it (sop30200: 291 metrics), so the metric list is split
into chunks that each fit and CROSS JOINed back into the single row the
caller expects. `SELECT *` over the join preserves left-to-right order, so
the columns still line up with the labels. Each chunk costs one extra
remote scan — hence chunks as large as the cap allows.
(The uncapped alternative, ``EXEC(@sql) AT server``, needs RPC Out enabled
on the linked server; GPSERVER has it off.)
"""
overhead = len("SELECT FROM ") + len(table)
chunks: list[list[str]] = []
cur: list[str] = []
cur_len = overhead
for i, e in enumerate(exprs):
piece = f"{e} AS c{i}"
add = len(piece) + (2 if cur else 0)
if cur and cur_len + add > limit:
chunks.append(cur)
cur, cur_len = [], overhead
add = len(piece)
cur.append(piece)
cur_len += add
if cur:
chunks.append(cur)
parts = []
for n, pieces in enumerate(chunks):
inner = f"SELECT {', '.join(pieces)} FROM {table}"
escaped = inner.replace("'", "''")
parts.append(f"OPENQUERY({server}, '{escaped}') q{n}")
return "SELECT * FROM " + "\n CROSS JOIN ".join(parts)
def build_metrics(columns, key_names, drv, *, source: bool, quick: bool,
super_quick: bool = False):
"""Return (list of (label, kind) metric descriptors, list of SQL exprs).
Descriptors and exprs are positionally aligned so the two sides zip up.
``source`` selects which column name + transform to use.
Three depths: full (every column, incl. min/max/len_sum), ``quick``
(per-column counts + numeric sums), and ``super_quick`` (the headline row
counts only — no per-column work, so the DB can often answer from an index).
"""
labels: list[tuple[str, str]] = []
exprs: list[str] = []
labels.append(("COUNT(*)", "int"))
exprs.append("COUNT(*)")
for kn in key_names:
col = kn["source"] if source else kn["dest"]
q = col if (source and is_expression(col)) else drv.quote_identifier(col)
labels.append((f"COUNT(DISTINCT {kn['dest']})", "int"))
exprs.append(f"COUNT(DISTINCT {q})")
if super_quick:
return labels, exprs
for c in columns:
kind = classify(c["dest_type"])
if source:
e = source_expression(drv, c)
else:
e = drv.quote_identifier(c["dest_name"])
name = c["dest_name"]
col_type = c["source_type"] if source else c["dest_type"]
if kind == "numeric":
labels.append((f"{name}: count", "int"))
exprs.append(f"COUNT({e})")
labels.append((f"{name}: sum", "num"))
exprs.append(drv.sum_expression(col_type, e))
if not quick:
labels.append((f"{name}: min", "str"))
exprs.append(f"MIN({e})")
labels.append((f"{name}: max", "str"))
exprs.append(f"MAX({e})")
elif kind == "temporal":
labels.append((f"{name}: count", "int"))
exprs.append(f"COUNT({e})")
if not quick:
labels.append((f"{name}: min", "str"))
exprs.append(f"MIN({e})")
labels.append((f"{name}: max", "str"))
exprs.append(f"MAX({e})")
else: # text — avoid MIN/MAX (EBCDIC vs ASCII collation differs)
labels.append((f"{name}: count", "int"))
exprs.append(f"COUNT({e})")
if not quick:
labels.append((f"{name}: len_sum", "int"))
exprs.append(drv.sum_expression("int", drv.length_expression(e)))
return labels, exprs
def values_equal(kind: str, a: str, b: str) -> bool:
a = "" if a is None else str(a).strip()
b = "" if b is None else str(b).strip()
if kind in ("num", "int"):
try:
da = Decimal(a) if a != "" else Decimal(0)
db = Decimal(b) if b != "" else Decimal(0)
return da == db
except (InvalidOperation, ValueError):
return a == b
return a == b
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("module", help="module name (e.g. ocri)")
ap.add_argument("--quick", action="store_true",
help="counts + numeric sums only (skip min/max/len_sum)")
ap.add_argument("--super-quick", action="store_true",
help="row counts only: COUNT(*) + COUNT(DISTINCT merge_key). "
"Cheapest check — catches missing/duplicated rows, not "
"changed column values")
ap.add_argument("--source-from",
help="override the source FROM target "
"(e.g. a schema.table or OPENQUERY(...) t)")
ap.add_argument("--timeout", type=int, default=1800,
help="per-side jrunner timeout seconds (default 1800)")
ap.add_argument("--show-matches", action="store_true",
help="print matching metrics too, not just mismatches")
args = ap.parse_args()
m = repo.get_module_by_name(args.module)
if not m:
print(f"module not found: {args.module}", file=sys.stderr)
return 2
src_conn = repo.get_connection(m["source_connection_id"])
dst_conn = repo.get_connection(m["dest_connection_id"])
src_drv = drivers.get_driver(repo.get_driver_row(src_conn["driver_id"])["kind"])
dst_drv = drivers.get_driver(repo.get_driver_row(dst_conn["driver_id"])["kind"])
columns = json.loads(m["columns_json"] or "[]")
if not columns:
print("module has no columns_json", file=sys.stderr)
return 2
# map merge_key (dest names) -> source names via columns_json
key_names = []
if m["merge_key"]:
by_dest = {c["dest_name"]: c for c in columns}
for k in (x.strip().strip('"') for x in m["merge_key"].split(",") if x.strip()):
c = by_dest.get(k)
if c:
key_names.append({"dest": c["dest_name"], "source": c["source_name"]})
source_from = args.source_from or detect_source_from(m["source_query"], m["dest_table"])
if not source_from:
print("could not detect source table; pass --source-from", file=sys.stderr)
return 2
# --super-quick subsumes --quick; pass both so the depth is unambiguous.
quick = args.quick or args.super_quick
labels, src_exprs = build_metrics(columns, key_names, src_drv, source=True,
quick=quick, super_quick=args.super_quick)
_, dst_exprs = build_metrics(columns, key_names, dst_drv, source=False,
quick=quick, super_quick=args.super_quick)
src_sql = "SELECT\n " + "\n , ".join(src_exprs) + f"\nFROM {source_from}"
dst_sql = "SELECT\n " + "\n , ".join(dst_exprs) + f"\nFROM {m['dest_table']}"
# An explicit --source-from is taken verbatim (the caller may already have
# written their own OPENQUERY). Otherwise, if the module reads through a
# linked server, push the aggregate down the same way: computing it here
# would drag every row across the link, and the inner table name does not
# resolve locally at all.
oq_server = None if args.source_from else detect_openquery_server(m["source_query"])
if oq_server:
src_sql = build_openquery_sql(oq_server, source_from, src_exprs)
print(f"module {m['name']} (id {m['id']})")
src_label = (f"OPENQUERY({oq_server}) -> {source_from}" if oq_server
else source_from)
print(f" source {src_conn['name']} FROM {src_label}")
print(f" dest {dst_conn['name']} FROM {m['dest_table']}")
mode = (" [super-quick]" if args.super_quick
else " [quick]" if args.quick else "")
print(f" {len(labels)} metrics{mode}\n"
f" running source aggregate ...", flush=True)
src_res = jrunner.query(src_conn["jdbc_url"], src_conn.get("username"),
src_conn.get("password"), src_sql, timeout=args.timeout)
print(" running dest aggregate ...", flush=True)
dst_res = jrunner.query(dst_conn["jdbc_url"], dst_conn.get("username"),
dst_conn.get("password"), dst_sql, timeout=args.timeout)
src_row = src_res.rows[0] if src_res.rows else []
dst_row = dst_res.rows[0] if dst_res.rows else []
if len(src_row) != len(labels) or len(dst_row) != len(labels):
print(f"\nunexpected column count "
f"(labels={len(labels)} src={len(src_row)} dst={len(dst_row)})",
file=sys.stderr)
return 2
# COUNT(*) and the COUNT(DISTINCT key) metrics are the headline row counts;
# always show them even when they match (they're the primary sanity check).
n_headline = 1 + len(key_names)
mismatches = []
for i, ((label, kind), sv, dv) in enumerate(zip(labels, src_row, dst_row)):
ok = values_equal(kind, sv, dv)
if not ok:
mismatches.append((label, sv, dv))
if i < n_headline or args.show_matches or not ok:
mark = "OK " if ok else "XX "
print(f" {mark} {label:32} src={sv!s:>22} dst={dv!s:>22}")
if i == n_headline - 1:
print() # blank line separating row counts from column drift
print()
if mismatches:
print(f"DIVERGED: {len(mismatches)} of {len(labels)} metrics differ")
return 1
print(f"IN SYNC: all {len(labels)} metrics match")
return 0
if __name__ == "__main__":
sys.exit(main())