Report a machine-readable summary; stop failing on no-result statements

Two coupled changes: the summary is what a caller should read instead of
parsing prose, and the statement fix is what makes a non-zero exit code
usable at all.

Summary: emit one "@summary {json}" line carrying status, row count and
elapsed ms. Deliberately on stderr — migration-mode stdout carries the
progress output pipekit streams to its live log, and query-mode stdout
must stay pure CSV, so structured data on stdout would corrupt one or the
other. Previously the row count reached the caller only because a \r
progress tick and a trailing " rows written" happened to render on the
same line, which pipekit then matched with three different regexes.

Statements: query mode used executeQuery(), which raises when a statement
returns no result set — Postgres "No results were returned by the query",
SQL Server "The statement did not return a result set". DDL, INSERT,
DELETE and TRUNCATE all hit this despite succeeding, so pipekit carries a
_BENIGN_EXCEPTION_SUBSTRINGS allowlist to tell a working TRUNCATE from a
real failure. That made --strict unusable: it would have failed every
merge, staging DDL and hook. Now execute() + getUpdateCount() treats them
as the successes they are and reports rows affected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-08-10 09:11:16 -04:00
parent 61b3967a36
commit 94cd89899a

View File

@ -276,13 +276,29 @@ public class jrunner {
stmt = scon.createStatement();
stmt.setFetchSize(10000);
tsStart = Timestamp.from(Instant.now());
summaryStart = tsStart;
if (!queryMode) {
System.out.println(tsStart);
}
rs = stmt.executeQuery(sq);
//while (rs.next()) {
// System.out.println(rs.getString("x"));
//}
// execute() rather than executeQuery(): a statement that returns no
// result set (DDL, INSERT, DELETE, TRUNCATE, MERGE) is a normal,
// successful outcome, but executeQuery() raises for it Postgres
// "No results were returned by the query", SQL Server "The
// statement did not return a result set". Callers had to allowlist
// those messages to tell a real failure from a working TRUNCATE,
// which made a non-zero exit code impossible to adopt.
boolean hasResultSet = stmt.execute(sq);
if (!hasResultSet) {
int updated = stmt.getUpdateCount();
if (!queryMode) {
System.out.println("------------no result set---------------------------------");
System.out.println("rows affected: " + updated);
}
emitSummary("ok", updated >= 0 ? updated : -1, summaryElapsedMs());
closeQuietly(scon, dcon, queryMode);
return;
}
rs = stmt.getResultSet();
} catch (SQLException e) {
System.out.println("issue retrieving rows from source:");
e.printStackTrace();
@ -355,6 +371,7 @@ public class jrunner {
// onto the last progress tick; the trailing " rows written" makes
// it parseable.
System.out.print("\r" + src.rowsWritten());
summaryRows = src.rowsWritten();
} catch (Exception e) {
e.printStackTrace();
die();
@ -397,6 +414,7 @@ public class jrunner {
}
cin.endCopy();
System.out.print("\r" + rows);
summaryRows = rows;
} catch (Exception e) {
e.printStackTrace();
die();
@ -545,6 +563,7 @@ public class jrunner {
stmtd = dcon.createStatement();
stmtd.executeUpdate(sql);
System.out.print("\r" + t);
summaryRows = t;
} catch (SQLException e) {
e.printStackTrace();
System.out.println(sql);
@ -573,6 +592,7 @@ public class jrunner {
tsEnd = Timestamp.from(Instant.now());
System.out.println(tsStart);
System.out.println(tsEnd);
emitSummary("ok", summaryRows, summaryElapsedMs());
}
//long time = Duration.between(tsStart, tsEnd).toMillis();
//System.out.println("time elapsed: " + time);
@ -785,9 +805,52 @@ public class jrunner {
private static boolean strictExit = false;
private static void die() {
emitSummary("error", summaryRows, summaryElapsedMs());
System.exit(strictExit ? 1 : 0);
}
// Row count for the summary. Migration has three write paths (SQLServer
// bulk copy, Postgres COPY, batched INSERT) each with its own counter, so
// whichever runs records the total here.
private static long summaryRows = -1;
private static Timestamp summaryStart = null;
private static long summaryElapsedMs() {
if (summaryStart == null) return -1;
return Duration.between(summaryStart.toInstant(), Instant.now()).toMillis();
}
// A single machine-readable line for the calling process.
//
// Deliberately on stderr: migration-mode stdout carries human-readable
// progress that pipekit streams to its live log, and query-mode stdout must
// stay pure CSV, so anything structured on stdout would corrupt one or the
// other. stderr is already captured in full by the caller.
//
// Replaces having to regex a row count out of prose the count previously
// reached the caller only because a \r progress tick and a trailing
// " rows written" happened to render on the same line.
private static void emitSummary(String status, long rows, long ms) {
StringBuilder sb = new StringBuilder("@summary {\"status\":\"");
sb.append(status).append("\"");
if (rows >= 0) sb.append(",\"rows\":").append(rows);
if (ms >= 0) sb.append(",\"ms\":").append(ms);
sb.append("}");
System.err.println(sb);
}
// Best-effort connection close for the early return taken when a statement
// produced no result set. A failure here cannot invalidate work that the
// server already committed, so it is reported but not fatal.
private static void closeQuietly(Connection scon, Connection dcon, boolean queryMode) {
try {
if (scon != null) scon.close();
if (!queryMode && dcon != null) dcon.close();
} catch (SQLException e) {
System.err.println("issue closing connections: " + e.getMessage());
}
}
private static String escapeTSV(String value) {
return value.replaceAll("\t", " ").replaceAll("\n", " ").replaceAll("\r", " ");
}