diff --git a/apps/api/src/ops/replication.service.ts b/apps/api/src/ops/replication.service.ts index c872b06..2aa6e03 100644 --- a/apps/api/src/ops/replication.service.ts +++ b/apps/api/src/ops/replication.service.ts @@ -100,11 +100,7 @@ export class ReplicationService { return { ...empty, configured: true, problem: `No se pudo conectar: ${msg}` }; } - const field = (name: string): string | null => { - const m = raw.match(new RegExp(`^\\s*${name}:\\s*(.*)$`, "m")); - const v = m?.[1]?.trim(); - return v === undefined || v === "" ? null : v; - }; + const field = (name: string): string | null => replicaField(raw, name); // An empty result set means the server is not configured as a replica at // all — distinct from "configured but broken", and worth saying plainly. @@ -151,3 +147,25 @@ export class ReplicationService { }; } } + +/** + * Read one field out of `SHOW REPLICA STATUS\G` output. + * + * Exported for testing, and worth testing: the obvious regex is wrong. + * `\s` matches newlines in JavaScript, so `^\s*NAME:\s*(.*)$` lets the `\s*` + * after the colon swallow the line break of an EMPTY field and capture the + * following line instead. Last_SQL_Error is empty on a healthy replica, so that + * version reported the next line ("Replicate_Ignore_Server_Ids:") as a SQL + * error and rendered a perfectly healthy replica as broken. + * + * Hence `[^\S\n]` — horizontal whitespace only — on both sides of the name. + * + * @returns the trimmed value, or null when the field is absent OR empty. Empty + * and absent mean the same thing to every caller here: MySQL prints + * error fields as blank rather than omitting them. + */ +export function replicaField(raw: string, name: string): string | null { + const m = raw.match(new RegExp(`^[^\\S\\n]*${name}:[^\\S\\n]*(.*)$`, "m")); + const v = m?.[1]?.trim(); + return v === undefined || v === "" ? null : v; +} diff --git a/apps/api/src/ops/replication.spec.ts b/apps/api/src/ops/replication.spec.ts new file mode 100644 index 0000000..9d75304 --- /dev/null +++ b/apps/api/src/ops/replication.spec.ts @@ -0,0 +1,87 @@ +import { replicaField } from "./replication.service"; + +/** + * Verbatim shape of `SHOW REPLICA STATUS\G` from the live replica, trimmed to + * the fields the panel reads plus the neighbours that matter. + * + * The empty `Last_SQL_Error:` immediately followed by + * `Replicate_Ignore_Server_Ids:` is the whole point of the fixture — that exact + * adjacency is what the first implementation misread. + */ +const HEALTHY = [ + "*************************** 1. row ***************************", + " Replica_IO_State: Waiting for source to send event", + " Source_Host: 100.103.77.46", + " Source_User: repl", + " Replica_IO_Running: Yes", + " Replica_SQL_Running: Yes", + " Replicate_Do_DB: ", + " Last_Errno: 0", + " Last_Error: ", + " Seconds_Behind_Source: 0", + " Last_IO_Errno: 0", + " Last_IO_Error: ", + " Last_SQL_Errno: 0", + " Last_SQL_Error: ", + " Replicate_Ignore_Server_Ids: ", + " Source_Server_Id: 1", +].join("\n"); + +const BROKEN = [ + " Replica_IO_Running: Yes", + " Replica_SQL_Running: No", + " Seconds_Behind_Source: NULL", + " Last_IO_Error: ", + " Last_SQL_Error: Could not execute Write_rows event on table jorgecuadros.customers", + " Replicate_Ignore_Server_Ids: ", +].join("\n"); + +describe("replicaField", () => { + it("reads plain values", () => { + expect(replicaField(HEALTHY, "Replica_IO_Running")).toBe("Yes"); + expect(replicaField(HEALTHY, "Replica_SQL_Running")).toBe("Yes"); + expect(replicaField(HEALTHY, "Source_Host")).toBe("100.103.77.46"); + expect(replicaField(HEALTHY, "Seconds_Behind_Source")).toBe("0"); + }); + + /** + * The regression this file exists for. `\s` matches newlines in JavaScript, + * so `^\s*NAME:\s*(.*)$` walks past an empty field's line break and captures + * the NEXT line — turning a healthy replica into + * "Error SQL: Replicate_Ignore_Server_Ids:" in the admin panel. + */ + it("returns null for an empty field instead of the following line", () => { + expect(replicaField(HEALTHY, "Last_SQL_Error")).toBeNull(); + expect(replicaField(HEALTHY, "Last_IO_Error")).toBeNull(); + expect(replicaField(HEALTHY, "Last_Error")).toBeNull(); + expect(replicaField(HEALTHY, "Replicate_Do_DB")).toBeNull(); + expect(replicaField(HEALTHY, "Replicate_Ignore_Server_Ids")).toBeNull(); + }); + + it("still reads a real error when there is one", () => { + expect(replicaField(BROKEN, "Last_SQL_Error")).toBe( + "Could not execute Write_rows event on table jorgecuadros.customers", + ); + expect(replicaField(BROKEN, "Replica_SQL_Running")).toBe("No"); + }); + + /** NULL is a distinct state from empty and must survive as the literal. */ + it("preserves the literal NULL that MySQL prints for unknown lag", () => { + expect(replicaField(BROKEN, "Seconds_Behind_Source")).toBe("NULL"); + }); + + it("returns null for a field that is not present at all", () => { + expect(replicaField(HEALTHY, "Nonexistent_Field")).toBeNull(); + }); + + /** + * Field names are matched at the start of a line. Without the line anchor, + * "Last_Error" would also match inside "Last_SQL_Error" and read the wrong + * value — the two carry different things and both feed the panel. + */ + it("does not match a field name that is a suffix of another", () => { + const raw = " Last_SQL_Error: boom\n Last_Error: "; + expect(replicaField(raw, "Last_Error")).toBeNull(); + expect(replicaField(raw, "Last_SQL_Error")).toBe("boom"); + }); +});