import { Injectable, Logger } from "@nestjs/common"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; const exec = promisify(execFile); /** * How far the SQL thread is behind the I/O thread, in source binlog bytes. * * This is a different question from `secondsBehind`, and it answers the case * that lag hides: while the SQL thread grinds through one huge transaction, * `Seconds_Behind_Source` can sit still or even read 0, but the relay backlog * is plainly shrinking (or not). It costs nothing extra — every field here * comes out of the same `SHOW REPLICA STATUS` the panel already runs. * * Both positions are coordinates in the SOURCE's binlog, so they are only * comparable while both threads are working on the SAME source file. When they * are not, the replica is whole files behind and the byte delta is meaningless * (positions restart at ~4 in each new file), so `backlogBytes` and `percent` * are null and `sameFile` says why. */ export interface ApplyProgress { /** Source binlog file the I/O thread is currently reading. */ sourceLogFile: string | null; /** Position in `sourceLogFile` that the I/O thread has fetched up to. */ readPos: number; /** Source binlog file the SQL thread is currently applying. */ relayLogFile: string | null; /** Position in `relayLogFile` that the SQL thread has applied up to. */ execPos: number; /** True while both threads are on the same source file. */ sameFile: boolean; /** Fetched-but-not-yet-applied bytes. Null when the files differ. */ backlogBytes: number | null; /** * `execPos / readPos` as a percentage, null when the files differ. * * Deliberately never rounded up to 100 while any backlog remains: binlog * positions are large, so a real backlog of a few KB is 99.99% of the file * and would render as "caught up" when it is not. Read `backlogBytes === 0` * for actually caught up. */ percent: number | null; } /** * How far the replica's executed history is from the master's, in transactions. * * This is the check `SHOW REPLICA STATUS` cannot give you, and it is stronger * than everything else on the card for one specific reason: every other field is * self-reported by the replica. `Seconds_Behind_Source` reads 0 both when there * is genuinely nothing to apply AND when the I/O thread is disconnected — with * no incoming event there is nothing to measure staleness against, so a dead * link reports as perfectly current. `GTID_SUBTRACT(master, replica)` asks the * master what it has done and the replica what it has applied, so a silent * disconnect shows up immediately as a growing number. */ export interface GtidDrift { /** Transactions the master executed that the replica has not. 0 = identical. */ missingTransactions: number; /** The missing GTID set verbatim. Null when nothing is missing. */ missingGtidSet: string | null; /** * Transactions in the replica's `gtid_executed` under its OWN server UUID — * writes that happened here and exist nowhere on the master. * * Reported, never alarmed on. A non-zero count is the expected residue of the * seed load: restoring a dump executes its statements locally, and they take * GTIDs from this server's UUID. They never propagate (`log_replica_updates` * is off and nothing sources from this node), so they are harmless — right up * until someone tries to promote this box, where they become a real divergence. */ localTransactions: number; } /** One table's row count and content fingerprint, on one side of the link. */ export interface TableFingerprint { table: string; masterRows: number; replicaRows: number; /** Order-independent checksum over every column of every row. */ masterChecksum: string; replicaChecksum: string; matches: boolean; } export interface VerifyResult { /** True only when every table matched on both count and checksum. */ identical: boolean; tables: TableFingerprint[]; /** Set instead of `tables` when the comparison could not be run at all. */ problem: string | null; checkedAt: string; /** Wall-clock cost, because this is a full scan and the caller should see it. */ elapsedMs: number; } export interface ReplicationStatus { /** false when the replica is not configured for this environment at all. */ configured: boolean; /** true only when both threads run, no error is set, and lag is within bounds. */ healthy: boolean; host: string | null; ioRunning: string | null; sqlRunning: string | null; /** null when MySQL reports NULL, which it does whenever a thread is down. */ secondsBehind: number | null; lastIoError: string | null; lastSqlError: string | null; sourceHost: string | null; /** Relay-log apply progress. Null when the status output has no positions. */ apply: ApplyProgress | null; /** GTID comparison against the master. Null when the master was unreachable. */ drift: GtidDrift | null; /** Human-readable reason when healthy is false. */ problem: string | null; checkedAt: string; } /** * The tables `my.jorgecuadros.com` reads through the `web_reader` grant. * * This list is the verification surface, not the replication surface — the * replica carries the whole schema. These are the eight whose divergence would * actually be visible to a customer, so they are the ones worth a full scan. */ export const REPLICATED_TABLES = [ "transactions", "customers", "customer_legacy_refs", "type_transactions", "exchange_rates", "properties", "property_services", "trust_accounts", ] as const; /** * Reports whether the my.jorgecuadros.com read replica is still replicating. * * The replica is what the public site reads once the platformDataSource flag is * on, and a replica that has silently stopped applying serves stale balances * rather than erroring — the failure is invisible from the site itself, which is * why it needs a panel. * * Shells out to the mysql client for the same reason the rest of OpsService * does: there is no MySQL driver in this API's dependencies, and the image * already ships one. */ @Injectable() export class ReplicationService { private readonly logger = new Logger(ReplicationService.name); /** Lag above this many seconds is reported as unhealthy. */ private readonly maxLagSeconds = Number(process.env.REPLICA_MAX_LAG ?? 60); async status(): Promise { const host = process.env.REPLICA_DB_HOST; const user = process.env.REPLICA_DB_USER; const password = process.env.REPLICA_DB_PASS; const now = new Date().toISOString(); const empty: ReplicationStatus = { configured: false, healthy: false, host: host ?? null, ioRunning: null, sqlRunning: null, secondsBehind: null, lastIoError: null, lastSqlError: null, sourceHost: null, apply: null, drift: null, problem: null, checkedAt: now, }; if (!host || !user || !password) { return { ...empty, problem: "REPLICA_DB_* no configuradas" }; } let raw: string; try { raw = await this.onReplica("SHOW REPLICA STATUS\\G"); } catch (e) { const msg = e instanceof Error ? e.message : String(e); this.logger.warn(`no se pudo consultar la réplica: ${msg}`); return { ...empty, configured: true, problem: `No se pudo conectar: ${msg}` }; } 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. if (!raw.includes("Replica_IO_Running")) { return { ...empty, configured: true, problem: "El servidor no está configurado como réplica", }; } const ioRunning = field("Replica_IO_Running"); const sqlRunning = field("Replica_SQL_Running"); const lagRaw = field("Seconds_Behind_Source"); const secondsBehind = lagRaw === null || lagRaw === "NULL" ? null : Number(lagRaw); const lastIoError = field("Last_IO_Error"); const lastSqlError = field("Last_SQL_Error"); // Order matters: report the most specific cause first. Checking lag before // the threads would blame "sin dato de retraso" for what is really a // stopped thread, because MySQL reports NULL lag whenever either is down. let problem: string | null = null; if (ioRunning !== "Yes") problem = "El hilo de E/S no está corriendo"; else if (sqlRunning !== "Yes") problem = "El hilo SQL no está corriendo"; else if (lastSqlError) problem = `Error SQL: ${lastSqlError}`; else if (lastIoError) problem = `Error de E/S: ${lastIoError}`; else if (secondsBehind === null) problem = "Sin dato de retraso"; else if (secondsBehind > this.maxLagSeconds) problem = `Retraso de ${secondsBehind}s (máximo ${this.maxLagSeconds}s)`; return { configured: true, healthy: problem === null, host, ioRunning, sqlRunning, secondsBehind, lastIoError, lastSqlError, sourceHost: field("Source_Host"), // Reported, never folded into `healthy`: a non-zero backlog is the normal // state of a working replica for the instant between fetch and apply, so // alarming on it would cry wolf. It is here to answer "is it moving?" // when the lag counter is stuck. apply: applyProgress(raw), // Also reported rather than alarmed on, for the same reason: a busy master // is always a few transactions ahead for the instant they are in flight. // Null rather than zero when the master could not be reached — "unknown" // and "identical" must not render the same. drift: await this.gtidDrift(), problem, checkedAt: now, }; } /** * Compare executed history between master and replica. * * Two round trips: ask the master what it has executed, then ask the replica * to subtract its own history from that. The subtraction runs on the replica * rather than in TypeScript because `GTID_SUBTRACT` already implements the * interval algebra correctly, and reimplementing set subtraction over binlog * ranges is exactly the kind of thing that looks right and is wrong at the * boundaries. * * @returns null on any failure — a broken drift check must never be mistaken * for a healthy zero. */ private async gtidDrift(): Promise { try { const masterGtid = (await this.onMaster("SELECT @@gtid_executed")).trim(); // GTID sets are UUIDs, digits, colons, commas, hyphens, whitespace and // (since 8.4) alphanumeric tags. Nothing else is legal, so rejecting // anything outside that alphabet is a whitelist, not a blacklist: with no // quote and no backslash able to survive it, the value cannot escape the // string literal it is interpolated into below. if (masterGtid && !/^[0-9a-fA-F:,\s_-]+$/.test(masterGtid)) { this.logger.warn("gtid_executed del maestro con formato inesperado"); return null; } // An empty set means the master has GTID mode off, and there is nothing // meaningful to compare. if (!masterGtid) return null; const flat = masterGtid.replace(/\s+/g, ""); // Every GTID set is flattened with REPLACE before it leaves the server. // MySQL wraps `gtid_executed` across lines once it holds more than one // source UUID, and this is read back as tab-separated columns — an // embedded newline would split one row into two and silently truncate the // set at the first UUID. const out = await this.onReplica( "SELECT REPLACE(GTID_SUBTRACT(" + `'${flat}', @@gtid_executed), '\\n', ''), ` + "@@server_uuid, REPLACE(@@gtid_executed, '\\n', '')", ["-N"], ); // Trailing newline only — never `.trim()`. When nothing is missing the // first column is the empty string, so the line begins with a tab, and // trimming it would shift every column one position left and report the // replica's own UUID as the missing GTID set. const [missingSet = "", serverUuid = "", executed = ""] = out .replace(/\r?\n+$/, "") .split("\t"); return { missingTransactions: countGtids(missingSet), missingGtidSet: missingSet || null, localTransactions: countGtids(gtidsForUuid(executed, serverUuid)), }; } catch (e) { const msg = e instanceof Error ? e.message : String(e); this.logger.warn(`no se pudo comparar GTIDs con el maestro: ${msg}`); return null; } } /** * Full-scan comparison of the customer-visible tables on both sides. * * Deliberately NOT part of `status()`: this reads every row of every table in * `REPLICATED_TABLES` on both servers, so it belongs behind a button, not a * 30-second poll. * * It answers the one question GTID drift cannot. GTIDs prove the replica * applied every transaction the master produced; they say nothing about rows * changed on the replica by some other route. A local write is invisible to * every other field on the card and shows up here as a checksum mismatch. */ async verify(): Promise { const started = Date.now(); const base: VerifyResult = { identical: false, tables: [], problem: null, checkedAt: new Date().toISOString(), elapsedMs: 0, }; let sql: string; try { sql = await this.fingerprintSql(); } catch (e) { const msg = e instanceof Error ? e.message : String(e); return { ...base, problem: `No se pudo leer el esquema: ${msg}`, elapsedMs: Date.now() - started }; } let masterOut: string; let replicaOut: string; try { // Sequential, not parallel. Running both at once would have the master // scan under the replica's own read load only sometimes, which makes a // slow run hard to attribute; and the boxes are small enough that two // concurrent full scans is a real memory event on the 946MB replica. masterOut = await this.onMaster(sql); replicaOut = await this.onReplica(sql); } catch (e) { const msg = e instanceof Error ? e.message : String(e); return { ...base, problem: `No se pudo comparar: ${msg}`, elapsedMs: Date.now() - started }; } const master = parseFingerprints(masterOut); const replica = parseFingerprints(replicaOut); const tables: TableFingerprint[] = REPLICATED_TABLES.map((table) => { const m = master.get(table); const r = replica.get(table); return { table, masterRows: m?.rows ?? -1, replicaRows: r?.rows ?? -1, masterChecksum: m?.checksum ?? "?", replicaChecksum: r?.checksum ?? "?", // Both sides must have answered. A missing row on either side is a // mismatch, never a pass — `undefined === undefined` would otherwise // report two failed reads as agreement. matches: m !== undefined && r !== undefined && m.rows === r.rows && m.checksum === r.checksum, }; }); return { identical: tables.every((t) => t.matches), tables, problem: null, checkedAt: base.checkedAt, elapsedMs: Date.now() - started, }; } /** * Build the count+checksum query from the live column list. * * The columns come from `information_schema` on the master rather than being * hardcoded, so the check keeps covering the whole row after a migration adds * one. Reading the schema from the master is safe by construction: if the two * schemas had diverged, replication would already be broken. */ private async fingerprintSql(): Promise { const list = REPLICATED_TABLES.map((t) => `'${t}'`).join(","); const raw = await this.onMaster( "SELECT CONCAT(TABLE_NAME, '\\t', COLUMN_NAME) FROM information_schema.COLUMNS " + `WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (${list}) ` + "ORDER BY TABLE_NAME, ORDINAL_POSITION", ["-N"], ); const cols = new Map(); for (const line of raw.split("\n")) { const [table, column] = line.trim().split("\t"); if (!table || !column) continue; cols.set(table, [...(cols.get(table) ?? []), column]); } const selects = REPLICATED_TABLES.map((table) => { const columns = cols.get(table); if (!columns?.length) throw new Error(`tabla ${table} sin columnas`); // CONVERT(... USING binary), never CAST(... AS CHAR). // // CAST to CHAR transcodes into the *connection* character set, which is // not the same on the two servers: the mysql client inside the master's // container negotiates latin1, while the replica's negotiates utf8mb4. // Every accented character in a Mexican name, street or note therefore // hashes to different bytes on each side, and the comparison reports a // permanent mismatch on exactly the tables that hold free text — a // verification tool that always cries wolf, which is worse than none. // Comparing the stored bytes sidesteps the session entirely. (Verified // 2026-08-06: with CAST, `customers.name` gave 3344437324815 vs // 3339150372121; with CONVERT both give 3339150372121.) // // 0x1f (unit separator) joins the columns and 0x1e (record separator) // stands in for NULL. Both matter: CONCAT_WS *skips* NULLs rather than // emitting an empty field, so without a placeholder the rows // ('a', NULL, 'b') and ('a', 'b', NULL) produce the same string and a // column-shifting bug would checksum as identical. const expr = columns .map((c) => `IFNULL(CONVERT(\`${c}\` USING binary), 0x1e)`) .join(", 0x1f, "); // SUM, not a running hash: addition is commutative, so the result does not // depend on the order rows come back in. The two servers have no reason to // scan in the same order and are not asked to. return ( `SELECT '${table}' AS t, COUNT(*) AS n, ` + `IFNULL(SUM(CRC32(CONCAT_WS(0x1f, ${expr}))), 0) AS c FROM \`${table}\`` ); }); return selects.join(" UNION ALL "); } /* ------------------------------------------------------------ plumbing */ /** * Run a statement on the replica. * * --ssl is required: the replica sets require_secure_transport=ON. * * --ssl-verify-server-cert=0 is deliberate and is NOT the same trade-off the * website makes. This hop never leaves Tailscale — the replica is reached on * its CGNAT tailnet address and the tailnet ACL admits only this host — so * WireGuard already authenticates the peer. The DreamHost leg crosses the * public internet and therefore pins the CA instead. The client here is * MariaDB's, which rejects our self-signed CA outright unless it is handed the * CA file, which would mean shipping a cert into this image for a link that is * already authenticated. */ private async onReplica(sql: string, extra: string[] = []): Promise { const host = process.env.REPLICA_DB_HOST!; const user = process.env.REPLICA_DB_USER!; const password = process.env.REPLICA_DB_PASS!; const { stdout } = await exec( "mysql", [ `--host=${host}`, `--user=${user}`, "--ssl", "--ssl-verify-server-cert=0", "--connect-timeout=5", ...extra, "-e", sql, ], { env: { ...process.env, MYSQL_PWD: password }, timeout: VERIFY_TIMEOUT_MS }, ); return stdout; } /** * Run a statement on the master, using the application's own DATABASE_URL. * * The app credential is enough here on purpose — everything this class sends * to the master is a SELECT against `information_schema` or a system variable. * Reaching for OPS_DB_ADMIN_* the way OpsService does would hand a monitoring * read path a credential that can also restore a dump. */ private async onMaster(sql: string, extra: string[] = []): Promise { const raw = process.env.DATABASE_URL; if (!raw) throw new Error("DATABASE_URL no está configurada"); const u = new URL(raw); const { stdout } = await exec( "mysql", [ `--host=${u.hostname}`, `--port=${u.port || "3306"}`, `--user=${decodeURIComponent(u.username)}`, "--connect-timeout=5", ...extra, "-N", "-e", sql, u.pathname.replace(/^\//, ""), ], { env: { ...process.env, MYSQL_PWD: decodeURIComponent(u.password) }, timeout: VERIFY_TIMEOUT_MS, }, ); return stdout; } } /** Full scans on a 1-vCPU replica are not fast; 15s would cut them off. */ const VERIFY_TIMEOUT_MS = 120_000; /** Parse the `t\tn\tc` rows the fingerprint query emits under `mysql -N`. */ function parseFingerprints(raw: string): Map { const out = new Map(); for (const line of raw.split("\n")) { const [table, n, c] = line.trim().split("\t"); if (!table || n === undefined || c === undefined) continue; const rows = Number(n); if (!Number.isFinite(rows)) continue; // The checksum stays a string. Sums of CRC32 over 40k rows exceed 2^53, so // parsing it as a number would round and make distinct tables compare equal. out.set(table, { rows, checksum: c }); } return out; } /** * Count the transactions in a GTID set. * * Exported for testing. The format is `uuid[:tag]:interval[:interval]...`, * comma-separated, where an interval is `N` or `N-M` inclusive at both ends — * so `1-5` is five transactions, not four. * * MySQL 8.4 added an optional alphanumeric tag between the UUID and the first * interval. It is skipped rather than parsed: any segment that is not a number * or a number range is not an interval, whatever else it may be. */ export function countGtids(set: string): number { if (!set.trim()) return 0; let total = 0; for (const group of set.split(",")) { for (const part of group.trim().split(":").slice(1)) { const m = /^(\d+)(?:-(\d+))?$/.exec(part.trim()); if (!m) continue; const from = Number(m[1]); const to = m[2] === undefined ? from : Number(m[2]); if (Number.isFinite(from) && Number.isFinite(to) && to >= from) total += to - from + 1; } } return total; } /** * Narrow a GTID set to the intervals belonging to one server UUID. * * Exported for testing. Used to isolate the replica's own writes from the * history it replicated, which are interleaved in the same `gtid_executed`. */ export function gtidsForUuid(set: string, uuid: string): string { if (!uuid.trim()) return ""; const wanted = uuid.trim().toLowerCase(); return set .split(",") .map((g) => g.trim()) .filter((g) => g.toLowerCase().startsWith(`${wanted}:`)) .join(","); } /** * Derive relay-apply progress from `SHOW REPLICA STATUS\G` output. * * Exported for testing. Free in query terms — it re-reads four more fields from * the output the caller already has, with no second round trip to the replica * and no connection to the source. * * @returns null when either position is missing or unparseable, which is what * happens on a server that is not a replica at all. */ export function applyProgress(raw: string): ApplyProgress | null { const num = (name: string): number | null => { const v = replicaField(raw, name); if (v === null || v === "NULL") return null; const n = Number(v); return Number.isFinite(n) ? n : null; }; const readPos = num("Read_Source_Log_Pos"); const execPos = num("Exec_Source_Log_Pos"); if (readPos === null || execPos === null) return null; const sourceLogFile = replicaField(raw, "Source_Log_File"); const relayLogFile = replicaField(raw, "Relay_Source_Log_File"); const sameFile = sourceLogFile !== null && relayLogFile !== null && sourceLogFile === relayLogFile; // Clamped at 0: the SQL thread cannot be ahead of the I/O thread, but the two // fields are sampled independently, so a rotation racing this read can print // a momentarily negative delta. Zero is the honest floor, not a bug. const backlogBytes = sameFile ? Math.max(0, readPos - execPos) : null; let percent: number | null = null; if (backlogBytes !== null && readPos > 0) { // Truncate rather than round, and hold short of 100 while bytes remain — // see the doc on ApplyProgress.percent. const p = Math.floor((execPos / readPos) * 10_000) / 100; percent = backlogBytes === 0 ? 100 : Math.min(p, 99.99); } return { sourceLogFile, readPos, relayLogFile, execPos, sameFile, backlogBytes, percent }; } /** * 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; }