Skip to content

HTML diff report: stream to disk, cap rows per node pair - #167

Open
danolivo wants to merge 7 commits into
mainfrom
ace-213
Open

danolivo wants to merge 7 commits into
mainfrom
ace-213

Conversation

@danolivo

@danolivo danolivo commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Problem

table-diff --output html was killed by the OOM killer on a diff of
493,200 rows; the JSON report for the same diff was fine. The HTML writer
built the whole report in memory: a JSON copy of the full diff for the page
script, a struct for every row, and the rendered page in one buffer. With
several kilobytes of markup per row, that needs gigabytes. A page of that
size could not be opened in a browser anyway.

What this PR does

  • Streams the HTML report to disk. The template is split into blocks
    (page head, pair head, one block per row, pair tail, page tail). Each row
    is written to a buffered file writer and then dropped. Only the row keys
    of the whole diff stay in memory. If writing fails, the partial file is
    removed, and the error names the JSON report, which is complete.
  • Shows at most max_html_rows rows per node pair (default 10,000).
    Rows are taken in report order: value differences, then rows missing on
    the second node, then rows missing on the first. A truncated report says
    so in a banner at the top, in the summary, in the section header ("25 of
    53 rows shown"), in a note that lists the hidden rows by kind, and in the
    last row of the table. The JSON report does not change and always has
    every row.
  • Makes repair plans from a truncated page safe. table-repair applies
    the plan's default_action to every row of the diff file that no rule
    matches, including the rows the page did not show. With the old default
    keep_n1, a hidden row missing on n1 made table-repair reject the whole
    plan. A plan from a truncated page now has default_action: skip and an
    explicit rule for every shown row. The YAML starts with a comment that
    explains this and how to cover all rows. A plan from a complete report is
    the same as before.
  • Uses exact primary keys in repair plans. The page script used to read
    keys as JavaScript numbers, so a bigint above 2^53 could name the next
    row, and a text key such as "007" became 7 and matched nothing. The
    page now embeds each key as the JSON text of the diff file, and the script
    copies it into the YAML as it is. Range rules are used only when every key
    of the diff is a whole number.
  • Makes the key order stable. Keys that mix numbers and text were
    sorted in a different order on each run, because the comparison was not
    transitive. Numbers now come before text.

Settings

Where Name
ace.yaml table_diff.max_html_rows, mtree.diff.max_html_rows
Command line --max-html-rows on table-diff and mtree table-diff
Scheduled job args max_html_rows (table-diff jobs)

The command-line value wins, then the config value, then the default of
10,000. 0 means "not set". A negative value is an error. repset-diff
and schema-diff use table_diff.max_html_rows.

comparePKComponent compared two numbers by value, but a number and a
string as strings. That order is not transitive: "1a" < "9" < "10", but
"10" < "1a". The keys come from map iteration, so sort.Slice put rows
with such keys in a different order on each run.

Numbers now come before all other strings. NaN and the infinities count
as text, because NaN is not equal even to itself.
table-diff --output html was killed by the OOM killer on a diff of
493,200 rows, while the JSON report for the same diff was written
without trouble. The HTML writer held the whole report in memory at
once: a json.Marshal copy of the full diff for the page script, a struct
with escaped cells for every row, and the rendered page in one
bytes.Buffer. Each row takes several kilobytes of markup, so the buffer
alone grew to gigabytes.

The template is now split into blocks: page head, pair head, one block
per row, pair tail and page tail. The writer runs them one by one into a
buffered writer on the report file, and builds the data of a row only
while that row is written. Only the row keys of the whole diff stay in
memory. If writing fails, the half-written file is removed, and the
error names the JSON report, which is complete.

The page script no longer gets a copy of the whole diff. It gets one
entry per row, with the pair, the kind of difference, and each primary
key value as the JSON text of the diff file, and it builds the repair
plan from that. The script copies the key text into the YAML as it is.
Before, it read keys as JavaScript numbers, so a bigint above 2^53 could
name the next row, and a text key such as "007" became 7 and matched no
row. Range rules are used only when every key of the diff is a whole
number.

On a synthetic diff of 493,200 rows, peak memory fell from 4.8 GB to
0.55 GB; the diff itself takes 0.2 GB. The markup of the page does not
change.

Tests: the rows on the page match the embedded rows for each pair; key
literals; the output is the same from run to run; a write error in each
part of the page is returned and leaves no file. Three tests run the
page script in Node.js (they skip without node) and resolve the plan
with the repair executor.
Streaming keeps table-diff alive, but a report of hundreds of thousands
of rows is still gigabytes of markup that no browser can open. The HTML
report now shows at most DefaultMaxHTMLRows (10,000) rows for each node
pair. A row is one primary key: a value difference or a row missing on
one node. Rows are taken in report order: value differences, then rows
missing on the second node, then rows missing on the first. The JSON
report does not change and always has every row.

A truncated report says so: a banner at the top, a summary item, the
count in the section header ("25 of 53 rows shown"), a note under the
section toolbar with the hidden rows by kind, and a last table row. In a
truncated section the bulk controls say "all 25 shown rows", and they
come after the notes.

The repair plan needs care here. table-repair applies the plan's
default_action to every row of the diff file that no rule matches, and
that includes the rows the page did not show. With the old default
keep_n1, a hidden row missing on n1 made table-repair reject the whole
plan, and hidden value differences silently got keep_n1. So a plan built
on a truncated page now has default_action: skip and an explicit rule
for every shown row, and the YAML starts with a comment that says so and
how to cover all rows. A complete report gives the same plan as before.

Known limit: plan rules match by key and kind of difference, not by node
pair. With three or more nodes, a rule for a key shown in one pair also
acts on that key where it is hidden in another pair.

Every count in a section now comes from the rows the report renders. If
table-diff counted rows that show no visible difference (1 against
"1"), a note says how many. The row action default is labelled with the
action it takes, for example "Default: insert from n1".

WriteDiffReport takes the limit as a new argument. Callers pass 0 (the
default) until the next commit adds the setting.

Tests: truncation in value differences and in missing rows, the counts,
the plan data of a truncated page, and a Node.js test that a plan from a
truncated report resolves and changes only the shown rows.
The HTML report limit was fixed at the default. It can now be set:

- table_diff.max_html_rows and mtree.diff.max_html_rows in ace.yaml;
- --max-html-rows on table-diff and mtree table-diff;
- max_html_rows in the args of a scheduled table-diff job.

The command-line value wins, then the config value, then the default of
10,000 rows per node pair. 0 means "not set". There is no setting for
"no limit"; a large number does the same. A negative value is an error
in every place, where before a negative config value would have been
ignored without a word. repset-diff and schema-diff take the value from
table_diff in the config. The sample and default configs list the key.

Tests: how TableDiffTask.Validate resolves the value, and the errors for
negative values.
The table-diff and mtree table-diff docs describe --max-html-rows and
the max_html_rows keys, what a truncated report shows, and what a
repair plan built on a truncated page does: rules only for the shown
rows and default_action: skip for the rest. They also state the known
limit for three or more nodes, where a plan rule acts on a key in every
node pair.

The CHANGELOG lists the OOM fix, the exact keys in repair plans, and the
stable key order.
@danolivo danolivo self-assigned this Sep 24, 2026
@danolivo danolivo added the enhancement New feature or request label Sep 24, 2026
@coderabbitai

coderabbitai Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Warning

Review limit reached

Next included review available in 37 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Only developers with an assigned seat can start an on-demand review using credits. Ask an admin to assign your seat or change the review continuation mode in Billing.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 84e6dc7d-47d4-40bf-bcbb-db4590752627

📥 Commits

Reviewing files that changed from the base of the PR and between c476dad and 9bc3f45.

📒 Files selected for processing (3)
  • pkg/common/html_reporter.go
  • pkg/common/html_reporter_test.go
  • pkg/common/templates/diff_report.js

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 80715133-6ff8-441e-a643-52e6354d9e6c

📥 Commits

Reviewing files that changed from the base of the PR and between b3c9ddc and c476dad.

📒 Files selected for processing (20)
  • ace.sample.yaml
  • docs/CHANGELOG.md
  • docs/commands/diff/table-diff.md
  • docs/commands/mtree/mtree-table-diff.md
  • internal/cli/cli.go
  • internal/cli/default_config.yaml
  • internal/consistency/diff/table_diff.go
  • internal/consistency/diff/table_diff_html_rows_test.go
  • internal/consistency/mtree/merkle.go
  • internal/consistency/repair/html_plan_e2e_test.go
  • internal/jobs/config.go
  • pkg/common/html_reporter.go
  • pkg/common/html_reporter_test.go
  • pkg/common/secure_file_test.go
  • pkg/common/templates/diff_report.css
  • pkg/common/templates/diff_report.html
  • pkg/common/templates/diff_report.js
  • pkg/common/utils.go
  • pkg/common/utils_test.go
  • pkg/config/config.go

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Adds configurable per-node-pair row limits for HTML diff reports. The HTML writer streams report output, while JSON reports retain every row. Repair plans from truncated reports skip hidden rows by default, and plan generation preserves primary-key values as represented in the diff data.

Changes

HTML Diff Reports

Layer / File(s) Summary
Configure and pass the HTML row limit
ace.sample.yaml, internal/cli/default_config.yaml, pkg/config/config.go, internal/cli/cli.go, internal/consistency/diff/table_diff.go, internal/consistency/mtree/merkle.go, internal/jobs/config.go, docs/commands/diff/table-diff.md, docs/commands/mtree/mtree-table-diff.md
Adds max_html_rows configuration and --max-html-rows flags for table-diff and mtree diff. Tasks validate and resolve the setting, then pass it to report generation.
Stream and truncate HTML reports
pkg/common/html_reporter.go, pkg/common/utils.go, pkg/common/templates/diff_report.html, pkg/common/templates/diff_report.css, pkg/common/html_reporter_test.go, pkg/common/utils_test.go, pkg/common/secure_file_test.go, docs/CHANGELOG.md, docs/commands/diff/table-diff.md, docs/commands/mtree/mtree-table-diff.md
Writes HTML output incrementally and limits displayed rows per node pair. The report marks truncated sections and embeds plan data for shown rows. JSON output retains every row.
Build repair plans from shown rows and exact keys
pkg/common/templates/diff_report.js, internal/consistency/repair/html_plan_e2e_test.go, docs/CHANGELOG.md
Builds plans from embedded rows and preserves primary-key literals. Truncated plans use default_action: skip; rules match keys and difference types rather than node pairs.

Priority: ➖ Normal

Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to c476d

This change streams HTML diff reports to disk and caps the rows shown per node pair, with a configurable limit. JSON reports still contain every row. Repair plans built from a truncated report skip hidden rows by default, and primary keys keep their exact values. No outstanding defects were identified, and the change appears ready to merge.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 53.13% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 64 functions across 13 files. (7 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the two main changes: streaming HTML diff reports to disk and limiting rows per node pair.
Description check ✅ Passed The description directly explains the problem, implementation, configuration, truncation behavior, repair-plan safety, and exact primary-key handling covered by the changeset.
Full details: Docstring Coverage

Explanation

Docstring coverage is 53.13% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 64 functions across 13 files. (7 skipped: 7 unsupported.)

✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

A rabbit sees the rows stream by,
While bounded pages fill the sky.
Big keys keep every digit true,
Hidden rows get skipped from view.
The JSON keeps the full parade,
And neat reports are softly made.

Comment @coderabbitai help to get the list of available commands.

@codacy-production

codacy-production Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 52 complexity · 0 duplication

Metric Results
Complexity 52
Duplication 0

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

Codacy flagged five functions of the new report code as too complex
(cyclomatic complexity above 8, or more than 50 lines). The behaviour
does not change: for the same diff, the page markup and the embedded
data are the same bytes as before.

- renderHTMLDiffReport and writeHTMLPair had an error check after every
  template block. A small htmlBlockWriter now keeps the first error and
  skips the rest, so one check at the end is enough. The pair plans, the
  page head and the report info are built in their own functions, and
  the value rows and the missing rows of a pair are written by two.
- buildHTMLPairPlan: finding the node names, indexing the rows of one
  node, and sorting the rows into their lists are now three helpers.
- writeHTMLDiffData and buildHTMLSummaryItems got one helper each, to
  stay under the same limits.
- In the page script, collectRows and buildPKMatchers each give part of
  their work to a helper: picking the rows once per key, the default
  action of a row, and the ranges over whole numbers.

One error message changes: a failure in any part of the page now reads
"failed to write HTML diff report".
Codacy reported "Non-HTML variable 'report' is used to store raw HTML"
for `const report = diff.html_report`. The rule judges by names only: a
property whose name has "html" in it is taken to hold markup. This one
holds JSON (truncated, integer_pk, pairs, diff_file), and the page
script only reads its fields into the YAML text of the repair plan. The
script never writes markup into the page.

Call the key report_info, so the check has nothing to match.
@danolivo danolivo changed the title Ace 213 HTML diff report: stream to disk, cap rows per node pair Sep 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant