sqlmirror 0.2.0__py3-none-any.whl
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.
- sqlmirror/__init__.py +1 -0
- sqlmirror/apply.py +136 -0
- sqlmirror/backfill.py +65 -0
- sqlmirror/cli.py +335 -0
- sqlmirror/cron.py +103 -0
- sqlmirror/db.py +469 -0
- sqlmirror/db_mssql.py +530 -0
- sqlmirror/dev/__init__.py +1 -0
- sqlmirror/dev/cli.py +166 -0
- sqlmirror/dev/compare.py +87 -0
- sqlmirror/dev/e2e.py +261 -0
- sqlmirror/dev/golden.py +182 -0
- sqlmirror/dev/mock_jira.py +347 -0
- sqlmirror/dev/site.py +383 -0
- sqlmirror/dev/synth.py +769 -0
- sqlmirror/golden/01_issues_by_status.sql +8 -0
- sqlmirror/golden/02_issue_keys.sql +7 -0
- sqlmirror/golden/03_created_vs_resolved.sql +8 -0
- sqlmirror/golden/04_open_by_priority.sql +10 -0
- sqlmirror/golden/05_time_in_status.sql +16 -0
- sqlmirror/golden/06_cycle_time.sql +16 -0
- sqlmirror/golden/07_worklog_by_user.sql +11 -0
- sqlmirror/golden/08_fix_versions.sql +11 -0
- sqlmirror/golden/09_bugs_by_affected_version.sql +11 -0
- sqlmirror/golden/10_components.sql +10 -0
- sqlmirror/golden/11_top_labels.sql +9 -0
- sqlmirror/golden/12_select_field_distribution.sql +11 -0
- sqlmirror/golden/13_story_points_by_sprint.sql +11 -0
- sqlmirror/golden/14_sprint_completion.sql +14 -0
- sqlmirror/golden/15_epics.sql +12 -0
- sqlmirror/golden/16_subtasks.sql +10 -0
- sqlmirror/golden/17_blocked_issues.sql +11 -0
- sqlmirror/golden/18_most_discussed.sql +10 -0
- sqlmirror/golden/19_assignee_workload.sql +11 -0
- sqlmirror/golden/20_resolutions.sql +7 -0
- sqlmirror/golden/21_issue_history.sql +10 -0
- sqlmirror/golden/22_reopened.sql +11 -0
- sqlmirror/golden/23_overdue.sql +8 -0
- sqlmirror/golden/24_cascading_select.sql +13 -0
- sqlmirror/golden/25_user_picker.sql +12 -0
- sqlmirror/golden/26_attachments.sql +9 -0
- sqlmirror/golden/27_project_categories.sql +8 -0
- sqlmirror/golden/28_issue_types.sql +7 -0
- sqlmirror/golden/29_estimate_accuracy.sql +11 -0
- sqlmirror/golden/30_project_leads.sql +7 -0
- sqlmirror/golden/tsql/01_issues_by_status.sql +8 -0
- sqlmirror/golden/tsql/02_issue_keys.sql +6 -0
- sqlmirror/golden/tsql/03_created_vs_resolved.sql +8 -0
- sqlmirror/golden/tsql/04_open_by_priority.sql +10 -0
- sqlmirror/golden/tsql/05_time_in_status.sql +16 -0
- sqlmirror/golden/tsql/06_cycle_time.sql +16 -0
- sqlmirror/golden/tsql/07_worklog_by_user.sql +11 -0
- sqlmirror/golden/tsql/08_fix_versions.sql +11 -0
- sqlmirror/golden/tsql/09_bugs_by_affected_version.sql +11 -0
- sqlmirror/golden/tsql/10_components.sql +10 -0
- sqlmirror/golden/tsql/11_top_labels.sql +8 -0
- sqlmirror/golden/tsql/12_select_field_distribution.sql +11 -0
- sqlmirror/golden/tsql/13_story_points_by_sprint.sql +11 -0
- sqlmirror/golden/tsql/14_sprint_completion.sql +14 -0
- sqlmirror/golden/tsql/15_epics.sql +11 -0
- sqlmirror/golden/tsql/16_subtasks.sql +9 -0
- sqlmirror/golden/tsql/17_blocked_issues.sql +11 -0
- sqlmirror/golden/tsql/18_most_discussed.sql +9 -0
- sqlmirror/golden/tsql/19_assignee_workload.sql +11 -0
- sqlmirror/golden/tsql/20_resolutions.sql +7 -0
- sqlmirror/golden/tsql/21_issue_history.sql +10 -0
- sqlmirror/golden/tsql/22_reopened.sql +11 -0
- sqlmirror/golden/tsql/23_overdue.sql +8 -0
- sqlmirror/golden/tsql/24_cascading_select.sql +13 -0
- sqlmirror/golden/tsql/25_user_picker.sql +12 -0
- sqlmirror/golden/tsql/26_attachments.sql +9 -0
- sqlmirror/golden/tsql/27_project_categories.sql +8 -0
- sqlmirror/golden/tsql/28_issue_types.sql +7 -0
- sqlmirror/golden/tsql/29_estimate_accuracy.sql +11 -0
- sqlmirror/golden/tsql/30_project_leads.sql +7 -0
- sqlmirror/idmap.py +161 -0
- sqlmirror/jira.py +223 -0
- sqlmirror/mapper.py +774 -0
- sqlmirror/reconcile.py +73 -0
- sqlmirror/samples/dc_export_example.csv +19 -0
- sqlmirror/samples/dc_reports/mysql_sprint_report.sql +10 -0
- sqlmirror/samples/dc_reports/postgres_assigned_to_user.sql +6 -0
- sqlmirror/samples/dc_reports/postgres_customer_impact.sql +7 -0
- sqlmirror/samples/dc_reports/postgres_time_logged.sql +7 -0
- sqlmirror/samples/dc_reports/postgres_workflow_steps.sql +4 -0
- sqlmirror/samples/dc_reports/sqlserver_open_by_status.sql +9 -0
- sqlmirror/schema.py +518 -0
- sqlmirror/source.py +117 -0
- sqlmirror/sqlcheck.py +647 -0
- sqlmirror/util.py +80 -0
- sqlmirror-0.2.0.dist-info/METADATA +19 -0
- sqlmirror-0.2.0.dist-info/RECORD +95 -0
- sqlmirror-0.2.0.dist-info/WHEEL +5 -0
- sqlmirror-0.2.0.dist-info/entry_points.txt +3 -0
- sqlmirror-0.2.0.dist-info/top_level.txt +1 -0
sqlmirror/dev/cli.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""sqlmirror-dev: test tooling (synthetic tenants, mock Jira, live-site loaders, golden queries)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from dotenv import load_dotenv
|
|
10
|
+
|
|
11
|
+
from ..cli import _env, _jira
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.group()
|
|
15
|
+
@click.option("--env-file", type=click.Path(dir_okay=False))
|
|
16
|
+
def main(env_file: str | None) -> None:
|
|
17
|
+
"""Development and test tools for SQL Mirror."""
|
|
18
|
+
load_dotenv(env_file or Path.cwd() / ".env", override=False)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@main.command()
|
|
22
|
+
@click.option("--issues", default=2000, show_default=True)
|
|
23
|
+
@click.option("--projects", default=3, show_default=True)
|
|
24
|
+
@click.option("--seed", default=7, show_default=True)
|
|
25
|
+
@click.option("--out", default="data/tenant.json.gz", show_default=True)
|
|
26
|
+
def synth(issues: int, projects: int, seed: int, out: str) -> None:
|
|
27
|
+
"""Generate a synthetic Jira Cloud tenant (for the mock server)."""
|
|
28
|
+
from .synth import build_tenant
|
|
29
|
+
|
|
30
|
+
t = build_tenant(issues=issues, projects=projects, seed=seed)
|
|
31
|
+
t.save(out)
|
|
32
|
+
click.echo(f"Wrote {len(t.issues)} issues to {out}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@main.command("mock-jira")
|
|
36
|
+
@click.option("--data", help="Tenant file from 'synth'. Default: generate --issues on the fly.")
|
|
37
|
+
@click.option("--issues", default=2000, show_default=True)
|
|
38
|
+
@click.option("--port", default=8899, show_default=True)
|
|
39
|
+
@click.option("--flaky", default=0.0, help="Fraction of requests answered with 429.")
|
|
40
|
+
def mock_jira(data: str | None, issues: int, port: int, flaky: float) -> None:
|
|
41
|
+
"""Serve a synthetic tenant over the Jira REST API (for backfill and app tests)."""
|
|
42
|
+
from .mock_jira import serve
|
|
43
|
+
from .synth import Tenant, build_tenant
|
|
44
|
+
|
|
45
|
+
tenant = Tenant.load(data) if data else build_tenant(issues=issues)
|
|
46
|
+
click.echo(f"Mock Jira with {len(tenant.issues)} issues on http://127.0.0.1:{port} (Ctrl+C to stop)")
|
|
47
|
+
serve(tenant, port=port, flaky=flaky)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@main.command()
|
|
51
|
+
@click.option("--server-dsn", default=lambda: _env("SQLMIRROR_TEST_SERVER_DSN") or "postgresql://postgres@localhost:5433/postgres",
|
|
52
|
+
show_default="$SQLMIRROR_TEST_SERVER_DSN or postgresql://postgres@localhost:5433/postgres",
|
|
53
|
+
help="Postgres server to create the sqlm_e2e_* test databases on.")
|
|
54
|
+
@click.option("--forge-dir", default=None, help="Path to the Forge app (default: ../sql-mirror next to cli/).")
|
|
55
|
+
@click.option("--issues", default=600, show_default=True)
|
|
56
|
+
@click.option("--mssql-dsn", default=lambda: _env("SQLMIRROR_TEST_MSSQL_DSN"),
|
|
57
|
+
help="Also test a SQL Server mirror: server to create sqlm_e2e_* databases on, e.g. "
|
|
58
|
+
"sqlserver://sa:password@localhost:1433/master (default $SQLMIRROR_TEST_MSSQL_DSN).")
|
|
59
|
+
def e2e(server_dsn: str, forge_dir: str | None, issues: int, mssql_dsn: str | None) -> None:
|
|
60
|
+
"""Offline end-to-end test: mock Jira + Forge app code + local S3 + Postgres (+ SQL Server)."""
|
|
61
|
+
import logging
|
|
62
|
+
|
|
63
|
+
from .e2e import run_e2e
|
|
64
|
+
|
|
65
|
+
logging.getLogger("werkzeug").setLevel(logging.ERROR)
|
|
66
|
+
forge_dir = forge_dir or str(Path(__file__).resolve().parents[3] / "sql-mirror")
|
|
67
|
+
ok = run_e2e(server_dsn, forge_dir, issues=issues, mssql_server_dsn=mssql_dsn)
|
|
68
|
+
sys.exit(0 if ok else 1)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@main.command()
|
|
72
|
+
@click.option("--dsn", default=lambda: _env("DATABASE_URL"), help="Mirror database (default $DATABASE_URL).")
|
|
73
|
+
@click.option("--strict", is_flag=True, help="Treat 'no data of this kind' as a failure (for synthetic data).")
|
|
74
|
+
@click.option("--compare-with", help="Another mirror of the same data (e.g. Postgres for a SQL Server mirror): "
|
|
75
|
+
"check both give the same answers.")
|
|
76
|
+
def golden(dsn: str | None, strict: bool, compare_with: str | None) -> None:
|
|
77
|
+
"""Run the 30 golden Data Center report queries against a mirror (Postgres or T-SQL set to match)."""
|
|
78
|
+
from .golden import compare_golden, run_golden
|
|
79
|
+
|
|
80
|
+
if not dsn:
|
|
81
|
+
raise click.UsageError("Set DATABASE_URL or pass --dsn")
|
|
82
|
+
results = run_golden(dsn, strict=strict)
|
|
83
|
+
for r in results:
|
|
84
|
+
click.echo(f"{r['status'].upper():<5} {r['name']:<32} {r['rows']:>5} rows {r['ms']:>5} ms {r['error'] or ''}")
|
|
85
|
+
counts = {s: sum(1 for r in results if r["status"] == s) for s in ("pass", "empty", "fail")}
|
|
86
|
+
click.echo(f"{counts['pass']} passed, {counts['empty']} empty (site has no such data yet), {counts['fail']} failed")
|
|
87
|
+
differing = []
|
|
88
|
+
if compare_with:
|
|
89
|
+
verdicts = compare_golden(results, run_golden(compare_with, strict=strict))
|
|
90
|
+
differing = [v for v in verdicts if v["verdict"] != "same"]
|
|
91
|
+
click.echo(f"\nSame answers as {compare_with.split('@')[-1]}: {len(verdicts) - len(differing)}/{len(verdicts)}")
|
|
92
|
+
for v in differing:
|
|
93
|
+
click.echo(f" {v['name']}: {v['verdict']}: {v['detail']}")
|
|
94
|
+
sys.exit(1 if counts["fail"] or differing else 0)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@main.command()
|
|
98
|
+
@click.argument("dsn_a")
|
|
99
|
+
@click.argument("dsn_b")
|
|
100
|
+
def compare(dsn_a: str, dsn_b: str) -> None:
|
|
101
|
+
"""Compare two mirror databases table by table (Postgres and/or SQL Server)."""
|
|
102
|
+
from .compare import compare as run, format_report, identical
|
|
103
|
+
|
|
104
|
+
report = run(dsn_a, dsn_b)
|
|
105
|
+
click.echo(format_report(report))
|
|
106
|
+
sys.exit(0 if identical(report) else 1)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _demo_guard(yes: bool) -> None:
|
|
110
|
+
site = _env("JIRA_SITE") or ""
|
|
111
|
+
if not yes:
|
|
112
|
+
click.confirm(f"This creates and edits issues on {site}. Only use a demo/test site. Continue?", abort=True)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@main.command("load-site")
|
|
116
|
+
@click.option("--project", default="SQLM", show_default=True, help="Project key to create or fill.")
|
|
117
|
+
@click.option("--issues", default=500, show_default=True)
|
|
118
|
+
@click.option("--seed", default=1, show_default=True)
|
|
119
|
+
@click.option("--yes", is_flag=True, help="Don't ask for confirmation.")
|
|
120
|
+
def load_site_cmd(project: str, issues: int, seed: int, yes: bool) -> None:
|
|
121
|
+
"""Create realistic test data on a demo site (uses JIRA_* settings)."""
|
|
122
|
+
from .site import load_site
|
|
123
|
+
|
|
124
|
+
_demo_guard(yes)
|
|
125
|
+
load_site(_jira(), project=project, issues=issues, seed=seed)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@main.command("enrich-site")
|
|
129
|
+
@click.option("--project", default="SQLM", show_default=True)
|
|
130
|
+
@click.option("--yes", is_flag=True)
|
|
131
|
+
def enrich_site_cmd(project: str, yes: bool) -> None:
|
|
132
|
+
"""Add sprints, attachments, estimates, reopened issues etc. to existing demo issues."""
|
|
133
|
+
from .site import enrich_site
|
|
134
|
+
|
|
135
|
+
_demo_guard(yes)
|
|
136
|
+
enrich_site(_jira(), project=project)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@main.command("simulate-edits")
|
|
140
|
+
@click.option("--project", default="SQLM", show_default=True)
|
|
141
|
+
@click.option("--per-hour", default=300, show_default=True, help="Edits per hour (300 is a busy 250-user team).")
|
|
142
|
+
@click.option("--minutes", default=60, show_default=True)
|
|
143
|
+
@click.option("--yes", is_flag=True)
|
|
144
|
+
def simulate_edits_cmd(project: str, per_hour: int, minutes: int, yes: bool) -> None:
|
|
145
|
+
"""Make a steady stream of edits on a demo site (drives the app's events)."""
|
|
146
|
+
from .site import simulate_edits
|
|
147
|
+
|
|
148
|
+
_demo_guard(yes)
|
|
149
|
+
simulate_edits(_jira(), project=project, per_hour=per_hour, minutes=minutes)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@main.command("bulk-edit")
|
|
153
|
+
@click.option("--project", default="SQLM", show_default=True)
|
|
154
|
+
@click.option("--count", default=5000, show_default=True)
|
|
155
|
+
@click.option("--workers", default=8, show_default=True)
|
|
156
|
+
@click.option("--yes", is_flag=True)
|
|
157
|
+
def bulk_edit_cmd(project: str, count: int, workers: int, yes: bool) -> None:
|
|
158
|
+
"""Edit many issues at once (the bulk-edit rate-limit test)."""
|
|
159
|
+
from .site import bulk_edit
|
|
160
|
+
|
|
161
|
+
_demo_guard(yes)
|
|
162
|
+
bulk_edit(_jira(), project=project, count=count, workers=workers)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
if __name__ == "__main__":
|
|
166
|
+
main()
|
sqlmirror/dev/compare.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Compare two mirror databases table by table (used to prove two sync paths agree).
|
|
2
|
+
|
|
3
|
+
Works across databases too: a SQL Server mirror against a Postgres mirror of
|
|
4
|
+
the same data. Then timestamps are compared at SQL Server's datetime
|
|
5
|
+
resolution (1/300 s), since that is all datetime keeps.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ..schema import TABLES
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _open(target: Any) -> tuple[Any, bool]:
|
|
16
|
+
if isinstance(target, str):
|
|
17
|
+
from ..db import open_mirror
|
|
18
|
+
|
|
19
|
+
return open_mirror(target), True
|
|
20
|
+
return target, False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _norm_cross(db: Any, v: Any) -> Any:
|
|
24
|
+
if isinstance(v, datetime):
|
|
25
|
+
if v.tzinfo is None: # SQL Server: naive, in the mirror's zone
|
|
26
|
+
v = db.local_to_aware(v) if hasattr(db, "local_to_aware") else v.replace(tzinfo=timezone.utc)
|
|
27
|
+
v = v.astimezone(timezone.utc).replace(tzinfo=None)
|
|
28
|
+
return ("ts", round((v - datetime(1900, 1, 1)).total_seconds() * 300))
|
|
29
|
+
return v
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _rows(db: Any, table, cross: bool) -> dict[tuple, tuple]:
|
|
33
|
+
cols = table.column_names
|
|
34
|
+
pk_idx = [cols.index(c) for c in table.pk]
|
|
35
|
+
out = {}
|
|
36
|
+
for r in db.read_table(table):
|
|
37
|
+
r = tuple(_norm_cross(db, v) for v in r) if cross else tuple(r)
|
|
38
|
+
out[tuple(r[i] for i in pk_idx)] = r
|
|
39
|
+
return out
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def compare(a: Any, b: Any, sample: int = 3) -> list[dict[str, Any]]:
|
|
43
|
+
"""Return one entry per table with counts of rows only in A, only in B, and different.
|
|
44
|
+
|
|
45
|
+
``a`` and ``b`` are connection strings or open mirrors.
|
|
46
|
+
"""
|
|
47
|
+
report = []
|
|
48
|
+
ma, close_a = _open(a)
|
|
49
|
+
mb, close_b = _open(b)
|
|
50
|
+
cross = ma.dialect != mb.dialect
|
|
51
|
+
try:
|
|
52
|
+
for t in TABLES:
|
|
53
|
+
if t.mode == "state":
|
|
54
|
+
continue
|
|
55
|
+
ra, rb = _rows(ma, t, cross), _rows(mb, t, cross)
|
|
56
|
+
ma.rollback()
|
|
57
|
+
mb.rollback()
|
|
58
|
+
only_a = sorted(set(ra) - set(rb), key=str)
|
|
59
|
+
only_b = sorted(set(rb) - set(ra), key=str)
|
|
60
|
+
diff = sorted((k for k in set(ra) & set(rb) if ra[k] != rb[k]), key=str)
|
|
61
|
+
entry = {"table": t.name, "a": len(ra), "b": len(rb), "only_a": len(only_a), "only_b": len(only_b),
|
|
62
|
+
"different": len(diff)}
|
|
63
|
+
if only_a or only_b or diff:
|
|
64
|
+
entry["samples"] = {
|
|
65
|
+
"only_a": [ra[k] for k in only_a[:sample]],
|
|
66
|
+
"only_b": [rb[k] for k in only_b[:sample]],
|
|
67
|
+
"different": [(ra[k], rb[k]) for k in diff[:sample]],
|
|
68
|
+
}
|
|
69
|
+
report.append(entry)
|
|
70
|
+
finally:
|
|
71
|
+
if close_a:
|
|
72
|
+
ma.close()
|
|
73
|
+
if close_b:
|
|
74
|
+
mb.close()
|
|
75
|
+
return report
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def format_report(report: list[dict[str, Any]]) -> str:
|
|
79
|
+
lines = [f"{'table':<22}{'A':>8}{'B':>8}{'only A':>8}{'only B':>8}{'diff':>7}"]
|
|
80
|
+
for e in report:
|
|
81
|
+
flag = "" if not (e["only_a"] or e["only_b"] or e["different"]) else " <-- differs"
|
|
82
|
+
lines.append(f"{e['table']:<22}{e['a']:>8}{e['b']:>8}{e['only_a']:>8}{e['only_b']:>8}{e['different']:>7}{flag}")
|
|
83
|
+
return "\n".join(lines)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def identical(report: list[dict[str, Any]]) -> bool:
|
|
87
|
+
return all(not (e["only_a"] or e["only_b"] or e["different"]) for e in report)
|
sqlmirror/dev/e2e.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Offline end-to-end test of the whole pipeline.
|
|
2
|
+
|
|
3
|
+
Runs everything locally, with no Atlassian site and no cloud account:
|
|
4
|
+
|
|
5
|
+
synthetic tenant -> mock Jira -> Forge app code (Node, mocked Forge services)
|
|
6
|
+
-> local S3 (moto) -> sqlmirror apply -> Postgres
|
|
7
|
+
synthetic tenant -> mock Jira -> sqlmirror backfill -> Postgres
|
|
8
|
+
|
|
9
|
+
and checks that the different paths produce identical databases:
|
|
10
|
+
|
|
11
|
+
1. Forge full resync + apply == CLI backfill
|
|
12
|
+
2. after edits, deletes, new issues and links (events delivered):
|
|
13
|
+
Forge incremental + apply == fresh backfill
|
|
14
|
+
3. backfill + Forge incremental == fresh backfill (the real-world setup)
|
|
15
|
+
4. after edits whose events were lost: hourly catch-up recovers them
|
|
16
|
+
5. the golden Data Center report queries all run on the mirror
|
|
17
|
+
6. reconcile --deep finds nothing to repair
|
|
18
|
+
|
|
19
|
+
With a SQL Server (mssql_server_dsn), the same change files and the same mock
|
|
20
|
+
Jira also load a SQL Server mirror, which must equal the Postgres one:
|
|
21
|
+
|
|
22
|
+
7. Forge files applied to SQL Server == Postgres mirror
|
|
23
|
+
8. CLI backfill into SQL Server == Postgres mirror
|
|
24
|
+
9. reconcile --deep on SQL Server finds nothing to repair
|
|
25
|
+
10. the T-SQL golden reports run on SQL Server and give the same answers as
|
|
26
|
+
the Postgres golden reports on Postgres
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import socket
|
|
33
|
+
import subprocess
|
|
34
|
+
import tempfile
|
|
35
|
+
import time
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Callable
|
|
38
|
+
|
|
39
|
+
import httpx
|
|
40
|
+
import psycopg
|
|
41
|
+
|
|
42
|
+
from ..apply import apply_once
|
|
43
|
+
from ..backfill import backfill
|
|
44
|
+
from ..reconcile import reconcile
|
|
45
|
+
from ..db import PgMirror
|
|
46
|
+
from ..jira import JiraClient
|
|
47
|
+
from ..mapper import Mapper
|
|
48
|
+
from ..source import S3Source
|
|
49
|
+
from .compare import compare, format_report, identical
|
|
50
|
+
from .golden import compare_golden, run_golden
|
|
51
|
+
from .mock_jira import serve
|
|
52
|
+
from .synth import build_tenant
|
|
53
|
+
|
|
54
|
+
BUCKET = "sqlmirror-e2e"
|
|
55
|
+
PREFIX = "site1/"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _free_port() -> int:
|
|
59
|
+
with socket.socket() as s:
|
|
60
|
+
s.bind(("127.0.0.1", 0))
|
|
61
|
+
return s.getsockname()[1]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _dsn_for(server_dsn: str, dbname: str) -> str:
|
|
65
|
+
from psycopg.conninfo import conninfo_to_dict, make_conninfo
|
|
66
|
+
|
|
67
|
+
params = conninfo_to_dict(server_dsn)
|
|
68
|
+
params["dbname"] = dbname
|
|
69
|
+
return make_conninfo(**params)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _recreate_db(server_dsn: str, name: str) -> str:
|
|
73
|
+
with psycopg.connect(server_dsn, autocommit=True) as conn:
|
|
74
|
+
conn.execute(f'DROP DATABASE IF EXISTS "{name}"')
|
|
75
|
+
conn.execute(f'CREATE DATABASE "{name}"')
|
|
76
|
+
return _dsn_for(server_dsn, name)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def run_e2e(server_dsn: str, forge_dir: str | Path, *, issues: int = 600, seed: int = 11,
|
|
80
|
+
log: Callable[[str], None] = print, mssql_server_dsn: str | None = None) -> bool:
|
|
81
|
+
import logging
|
|
82
|
+
|
|
83
|
+
logging.getLogger("werkzeug").setLevel(logging.ERROR)
|
|
84
|
+
forge_dir = Path(forge_dir).resolve()
|
|
85
|
+
if not (forge_dir / "node_modules" / "@forge" / "api").exists():
|
|
86
|
+
raise SystemExit(f"Run 'npm install' in {forge_dir} first.")
|
|
87
|
+
ok = True
|
|
88
|
+
|
|
89
|
+
def check(title: str, report) -> None:
|
|
90
|
+
nonlocal ok
|
|
91
|
+
same = identical(report)
|
|
92
|
+
ok = ok and same
|
|
93
|
+
log(("PASS " if same else "FAIL ") + title)
|
|
94
|
+
if not same:
|
|
95
|
+
log(format_report(report))
|
|
96
|
+
for e in report:
|
|
97
|
+
if e.get("samples"):
|
|
98
|
+
log(f" {e['table']}: {json.dumps(e['samples'], default=str)[:1500]}")
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------- services
|
|
101
|
+
tenant = build_tenant(issues=issues, seed=seed)
|
|
102
|
+
jira_port, s3_port = _free_port(), _free_port()
|
|
103
|
+
server, _mock = serve(tenant, port=jira_port, background=True, flaky=0.01)
|
|
104
|
+
jira_url = f"http://127.0.0.1:{jira_port}"
|
|
105
|
+
|
|
106
|
+
os.environ.setdefault("AWS_ACCESS_KEY_ID", "testing")
|
|
107
|
+
os.environ.setdefault("AWS_SECRET_ACCESS_KEY", "testing")
|
|
108
|
+
os.environ.setdefault("AWS_DEFAULT_REGION", "us-west-1")
|
|
109
|
+
from moto.server import ThreadedMotoServer
|
|
110
|
+
|
|
111
|
+
moto = ThreadedMotoServer(ip_address="127.0.0.1", port=s3_port, verbose=False)
|
|
112
|
+
moto.start()
|
|
113
|
+
s3_url = f"http://127.0.0.1:{s3_port}"
|
|
114
|
+
import uuid
|
|
115
|
+
|
|
116
|
+
import boto3
|
|
117
|
+
|
|
118
|
+
# moto keeps its buckets for the life of the process, so each run gets its own.
|
|
119
|
+
BUCKET = f"sqlmirror-e2e-{uuid.uuid4().hex[:8]}"
|
|
120
|
+
# Outside us-east-1, S3 needs the region spelled out when creating a bucket.
|
|
121
|
+
boto3.client("s3", endpoint_url=s3_url, region_name="us-west-1").create_bucket(
|
|
122
|
+
Bucket=BUCKET, CreateBucketConfiguration={"LocationConstraint": "us-west-1"})
|
|
123
|
+
log(f"Mock Jira on {jira_url} ({len(tenant.issues)} issues), local S3 on {s3_url}")
|
|
124
|
+
|
|
125
|
+
state_file = tempfile.NamedTemporaryFile(prefix="sqlm-forge-sim-", suffix=".json", delete=False).name
|
|
126
|
+
os.unlink(state_file)
|
|
127
|
+
env = {**os.environ, "SQLM_SIM_STATE": state_file, "SQLM_MOCK_JIRA": jira_url}
|
|
128
|
+
|
|
129
|
+
def forge(*args: str) -> str:
|
|
130
|
+
res = subprocess.run(["node", "--import", "./test/register.mjs", "test/sim.mjs", *args], cwd=forge_dir,
|
|
131
|
+
env=env, capture_output=True, text=True, timeout=900)
|
|
132
|
+
if res.returncode != 0:
|
|
133
|
+
raise RuntimeError(f"forge sim {args[0]} failed:\n{res.stdout}\n{res.stderr}")
|
|
134
|
+
return res.stdout.strip()
|
|
135
|
+
|
|
136
|
+
def forge_state() -> dict:
|
|
137
|
+
return json.loads(forge("state"))
|
|
138
|
+
|
|
139
|
+
source = S3Source(f"s3://{BUCKET}/{PREFIX}", endpoint_url=s3_url, region="us-west-1")
|
|
140
|
+
db_forge = PgMirror(_recreate_db(server_dsn, "sqlm_e2e_forge"))
|
|
141
|
+
db_forge.init_schema()
|
|
142
|
+
db_backfill = PgMirror(_recreate_db(server_dsn, "sqlm_e2e_backfill"))
|
|
143
|
+
db_backfill.init_schema()
|
|
144
|
+
quiet = lambda _msg: None # noqa: E731
|
|
145
|
+
mssql_dbs: list = []
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
# 1. Forge full resync vs CLI backfill -------------------------------
|
|
149
|
+
forge("configure", json.dumps({"bucket": BUCKET, "region": "us-west-1", "endpoint": s3_url, "prefix": PREFIX,
|
|
150
|
+
"accessKeyId": "testing", "secretAccessKey": "testing"}))
|
|
151
|
+
forge("enqueue", "resync")
|
|
152
|
+
t0 = time.time()
|
|
153
|
+
log(forge("run-jobs"))
|
|
154
|
+
st = forge_state()
|
|
155
|
+
log(f"Forge resync: {st['lastJobSummary']} in {time.time() - t0:.1f}s; error: {st['lastError']}")
|
|
156
|
+
res = apply_once(source, db_forge, Mapper(), log=quiet)
|
|
157
|
+
log(f"apply: {res.files} files, {res.stats.summary()}")
|
|
158
|
+
|
|
159
|
+
client = JiraClient(jira_url, "e2e@example.com", "token")
|
|
160
|
+
backfill(client, db_backfill, Mapper(), log=quiet)
|
|
161
|
+
check("1. Forge resync + apply == CLI backfill", compare(db_forge.dsn, db_backfill.dsn))
|
|
162
|
+
|
|
163
|
+
# 2. Incremental changes with events ---------------------------------
|
|
164
|
+
events = httpx.post(f"{jira_url}/__mock/mutate", json={"edits": 40, "deletes": 4, "creates": 6, "links": 5,
|
|
165
|
+
"seed": 5}).json()["events"]
|
|
166
|
+
ev_file = state_file + ".events.json"
|
|
167
|
+
Path(ev_file).write_text(json.dumps(events))
|
|
168
|
+
log(forge("events", ev_file))
|
|
169
|
+
log(forge("scheduled-sync"))
|
|
170
|
+
log(forge("run-jobs"))
|
|
171
|
+
res = apply_once(source, db_forge, Mapper(), log=quiet)
|
|
172
|
+
log(f"apply: {res.files} files, {res.stats.summary()}")
|
|
173
|
+
db_truth = PgMirror(_recreate_db(server_dsn, "sqlm_e2e_truth"))
|
|
174
|
+
db_truth.init_schema()
|
|
175
|
+
backfill(client, db_truth, Mapper(), log=quiet)
|
|
176
|
+
check("2. Forge incremental (edits, deletes, creates, links) == fresh backfill",
|
|
177
|
+
compare(db_forge.dsn, db_truth.dsn))
|
|
178
|
+
|
|
179
|
+
# 3. Backfilled mirror kept current by the Forge app -------------------
|
|
180
|
+
res = apply_once(source, db_backfill, Mapper(), log=quiet)
|
|
181
|
+
log(f"apply onto backfill: {res.files} files, {res.stats.summary()}")
|
|
182
|
+
check("3. backfill + Forge incremental == fresh backfill", compare(db_backfill.dsn, db_truth.dsn))
|
|
183
|
+
|
|
184
|
+
# 4. Lost events: the hourly catch-up must recover the edits ------------
|
|
185
|
+
httpx.post(f"{jira_url}/__mock/mutate", json={"edits": 15, "deletes": 0, "creates": 2, "links": 0, "seed": 6})
|
|
186
|
+
log(forge("scheduled-catchup"))
|
|
187
|
+
log(forge("enqueue", "sync")) # the catch-up only queues; the next scheduled sync writes
|
|
188
|
+
log(forge("run-jobs"))
|
|
189
|
+
res = apply_once(source, db_forge, Mapper(), log=quiet)
|
|
190
|
+
db_truth2 = PgMirror(_recreate_db(server_dsn, "sqlm_e2e_truth2"))
|
|
191
|
+
db_truth2.init_schema()
|
|
192
|
+
backfill(client, db_truth2, Mapper(), log=quiet)
|
|
193
|
+
check("4. hourly catch-up recovers changes whose events were lost", compare(db_forge.dsn, db_truth2.dsn))
|
|
194
|
+
|
|
195
|
+
# 5. Golden Data Center queries ---------------------------------------
|
|
196
|
+
results = run_golden(db_forge.dsn)
|
|
197
|
+
failed = [r for r in results if not r["ok"]]
|
|
198
|
+
ok = ok and not failed
|
|
199
|
+
log(("PASS " if not failed else "FAIL ") + f"5. {len(results) - len(failed)}/{len(results)} golden DC report queries ran")
|
|
200
|
+
for r in failed:
|
|
201
|
+
log(f" {r['name']}: {r['error']}")
|
|
202
|
+
|
|
203
|
+
# 6. Reconcile against Jira -------------------------------------------
|
|
204
|
+
def reconciled(db, title: str) -> None:
|
|
205
|
+
nonlocal ok
|
|
206
|
+
reports = reconcile(client, db, Mapper(), deep=True, log=quiet)
|
|
207
|
+
bad = [r for r in reports if not r.ok]
|
|
208
|
+
ok = ok and not bad and bool(reports)
|
|
209
|
+
log(("PASS " if reports and not bad else "FAIL ") + title)
|
|
210
|
+
for r in bad:
|
|
211
|
+
log(f" {r.key}: jira {r.jira} mirror {r.mirror} missing {r.missing[:5]} extra {r.extra[:5]} "
|
|
212
|
+
f"stale {r.stale[:5]}")
|
|
213
|
+
|
|
214
|
+
reconciled(db_forge, "6. reconcile --deep: Postgres mirror matches Jira")
|
|
215
|
+
|
|
216
|
+
# 7-10. SQL Server ------------------------------------------------------
|
|
217
|
+
if mssql_server_dsn:
|
|
218
|
+
from ..db_mssql import MssqlMirror, recreate_database
|
|
219
|
+
|
|
220
|
+
ms_forge = MssqlMirror(recreate_database(mssql_server_dsn, "sqlm_e2e_forge"))
|
|
221
|
+
ms_backfill = MssqlMirror(recreate_database(mssql_server_dsn, "sqlm_e2e_backfill"))
|
|
222
|
+
mssql_dbs.extend([ms_forge, ms_backfill])
|
|
223
|
+
ms_forge.init_schema()
|
|
224
|
+
ms_backfill.init_schema()
|
|
225
|
+
t0 = time.time()
|
|
226
|
+
res = apply_once(source, ms_forge, Mapper(), log=quiet)
|
|
227
|
+
log(f"SQL Server apply: {res.files} files, {res.stats.summary()} in {time.time() - t0:.1f}s")
|
|
228
|
+
again = apply_once(source, ms_forge, Mapper(), log=quiet)
|
|
229
|
+
if again.files:
|
|
230
|
+
ok = False
|
|
231
|
+
log(f"FAIL re-running apply on SQL Server applied {again.files} files again")
|
|
232
|
+
check("7. Forge files applied to SQL Server == Postgres mirror", compare(ms_forge, db_forge))
|
|
233
|
+
t0 = time.time()
|
|
234
|
+
backfill(client, ms_backfill, Mapper(), log=quiet)
|
|
235
|
+
log(f"SQL Server backfill in {time.time() - t0:.1f}s")
|
|
236
|
+
check("8. CLI backfill into SQL Server == Postgres mirror", compare(ms_backfill, db_truth2))
|
|
237
|
+
reconciled(ms_forge, "9. reconcile --deep: SQL Server mirror matches Jira")
|
|
238
|
+
|
|
239
|
+
ms_results = run_golden(ms_forge)
|
|
240
|
+
ms_failed = [r for r in ms_results if not r["ok"]]
|
|
241
|
+
verdicts = compare_golden(results, ms_results)
|
|
242
|
+
differing = [v for v in verdicts if v["verdict"] != "same"]
|
|
243
|
+
ok = ok and not ms_failed and not differing
|
|
244
|
+
log(("PASS " if not ms_failed and not differing else "FAIL ")
|
|
245
|
+
+ f"10. {len(ms_results) - len(ms_failed)}/{len(ms_results)} T-SQL golden reports ran on SQL Server; "
|
|
246
|
+
+ f"{len(verdicts) - len(differing)}/{len(verdicts)} give the same answers as on Postgres")
|
|
247
|
+
for v in differing:
|
|
248
|
+
log(f" {v['name']}: {v['verdict']}: {v['detail']}")
|
|
249
|
+
|
|
250
|
+
st = forge_state()
|
|
251
|
+
log(f"Forge totals: {json.dumps(st['totals'])}")
|
|
252
|
+
finally:
|
|
253
|
+
for db in (db_forge, db_backfill, *mssql_dbs):
|
|
254
|
+
db.close()
|
|
255
|
+
server.shutdown()
|
|
256
|
+
moto.stop()
|
|
257
|
+
for f in (state_file, state_file + ".events.json"):
|
|
258
|
+
if os.path.exists(f):
|
|
259
|
+
os.unlink(f)
|
|
260
|
+
log("ALL PASSED" if ok else "SOME CHECKS FAILED")
|
|
261
|
+
return ok
|