Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f269dc8bfa | ||
|
|
eef9a5f4c8 | ||
|
|
7f1bfe906e | ||
|
|
7797c45e9f | ||
|
|
dac1f1982f | ||
|
|
1d689d8f46 | ||
|
|
7bec2a13d8 | ||
|
|
127eaa9689 | ||
|
|
7226772c22 |
@@ -295,6 +295,9 @@ jobs:
|
||||
"SESSION_COOKIE_SECURE": "false",
|
||||
"OPS_DB_ADMIN_USER": "root",
|
||||
"OPS_DB_ADMIN_PASSWORD": "${{ secrets.MYSQL_ROOT_PASSWORD }}",
|
||||
"REPLICA_DB_HOST": "${{ secrets.REPLICA_DB_HOST }}",
|
||||
"REPLICA_DB_USER": "${{ secrets.REPLICA_DB_USER }}",
|
||||
"REPLICA_DB_PASS": "${{ secrets.REPLICA_DB_PASS }}",
|
||||
"MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}",
|
||||
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}",
|
||||
"SES_REGION": "${{ secrets.SES_REGION }}",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jorgecuadros/api",
|
||||
"version": "1.0.7",
|
||||
"version": "1.0.10",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
|
||||
@@ -19,6 +19,7 @@ import { AbilityGuard } from "../auth/ability.guard";
|
||||
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||
import { AuditService } from "../common/audit.service";
|
||||
import { OpsService } from "./ops.service";
|
||||
import { ReplicationService } from "./replication.service";
|
||||
import { StartJobDto } from "./start-job.dto";
|
||||
|
||||
/** Every route is ADMIN-only (ability "db:manage"). */
|
||||
@@ -28,6 +29,7 @@ import { StartJobDto } from "./start-job.dto";
|
||||
export class OpsController {
|
||||
constructor(
|
||||
private readonly ops: OpsService,
|
||||
private readonly replication: ReplicationService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
@@ -96,6 +98,12 @@ export class OpsController {
|
||||
|
||||
/* --------------------------------------------------------------- jobs */
|
||||
|
||||
/** Health of the my.jorgecuadros.com read replica. Read-only, no audit entry. */
|
||||
@Get("replication")
|
||||
replicationStatus() {
|
||||
return this.replication.status();
|
||||
}
|
||||
|
||||
@Get("jobs")
|
||||
listJobs() {
|
||||
return this.ops.listJobs();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { OpsController } from "./ops.controller";
|
||||
import { OpsService } from "./ops.service";
|
||||
import { ReplicationService } from "./replication.service";
|
||||
|
||||
@Module({
|
||||
controllers: [OpsController],
|
||||
providers: [OpsService],
|
||||
providers: [OpsService, ReplicationService],
|
||||
})
|
||||
export class OpsModule {}
|
||||
|
||||
@@ -67,6 +67,55 @@ export class OpsService implements OnModuleInit {
|
||||
async onModuleInit(): Promise<void> {
|
||||
await fs.mkdir(this.ingestDir, { recursive: true });
|
||||
await fs.mkdir(this.backupDir, { recursive: true });
|
||||
await this.reconcileOrphanedJobs();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail any job still marked RUNNING at startup.
|
||||
*
|
||||
* Jobs run as a child of THIS process, so no job can outlive it: if a row says
|
||||
* RUNNING while we are booting, its process died with the previous instance
|
||||
* and nothing will ever finalize it. Since startJob() refuses to start while
|
||||
* any RUNNING row exists, one interrupted job wedges the panel permanently
|
||||
* with no way out from the UI — it took a manual UPDATE against production to
|
||||
* recover the first time this happened, when a deploy landed 110 seconds into
|
||||
* a REIMPORT.
|
||||
*
|
||||
* Deliberately unconditional rather than filtered on age: "started recently"
|
||||
* does not mean "still alive" here, and a fresh boot is proof enough that
|
||||
* nothing survived.
|
||||
*/
|
||||
private async reconcileOrphanedJobs(): Promise<void> {
|
||||
try {
|
||||
// Read then write one by one rather than updateMany: the log needs the
|
||||
// reason APPENDED, and a job whose log just stops mid-step with no
|
||||
// explanation is what made the first occurrence hard to diagnose.
|
||||
const orphans = await this.prisma.opsJob.findMany({
|
||||
where: { status: "RUNNING" },
|
||||
select: { id: true, kind: true, log: true },
|
||||
});
|
||||
for (const job of orphans) {
|
||||
await this.prisma.opsJob.update({
|
||||
where: { id: job.id },
|
||||
data: {
|
||||
status: "FAILED",
|
||||
finishedAt: new Date(),
|
||||
log: {
|
||||
set:
|
||||
job.log +
|
||||
"\n[interrumpido: el contenedor se reinició mientras el trabajo corría; " +
|
||||
"el proceso hijo no sobrevive a un redespliegue. " +
|
||||
"Vuelva a ejecutar la operación desde el principio.]\n",
|
||||
},
|
||||
},
|
||||
});
|
||||
this.logger.warn(`trabajo ${job.kind} ${job.id} quedó huérfano; marcado FAILED`);
|
||||
}
|
||||
} catch (e) {
|
||||
// Never block startup on this. A failed reconcile leaves the panel
|
||||
// wedged, which is bad, but an API that will not boot is worse.
|
||||
this.logger.error(`no se pudieron reconciliar trabajos huérfanos: ${String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- ingest */
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
|
||||
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;
|
||||
/** Human-readable reason when healthy is false. */
|
||||
problem: string | null;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<ReplicationStatus> {
|
||||
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,
|
||||
problem: null,
|
||||
checkedAt: now,
|
||||
};
|
||||
|
||||
if (!host || !user || !password) {
|
||||
return { ...empty, problem: "REPLICA_DB_* no configuradas" };
|
||||
}
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
// --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 its firewall 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.
|
||||
const { stdout } = await exec(
|
||||
"mysql",
|
||||
[
|
||||
`--host=${host}`,
|
||||
`--user=${user}`,
|
||||
"--ssl",
|
||||
"--ssl-verify-server-cert=0",
|
||||
"--connect-timeout=5",
|
||||
"-e",
|
||||
"SHOW REPLICA STATUS\\G",
|
||||
],
|
||||
{
|
||||
env: { ...process.env, MYSQL_PWD: password },
|
||||
timeout: 15_000,
|
||||
},
|
||||
);
|
||||
raw = stdout;
|
||||
} 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"),
|
||||
problem,
|
||||
checkedAt: now,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jorgecuadros/web",
|
||||
"version": "1.0.7",
|
||||
"version": "1.0.10",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 4500",
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
deleteBackup,
|
||||
deleteIngest,
|
||||
getOpsJob,
|
||||
getReplicationStatus,
|
||||
listBackups,
|
||||
listIngest,
|
||||
listOpsJobs,
|
||||
@@ -26,6 +27,7 @@ import type {
|
||||
IngestFile,
|
||||
OpsJob,
|
||||
OpsJobKind,
|
||||
ReplicationStatus,
|
||||
} from "@/lib/types";
|
||||
|
||||
const INGEST_MAX_BYTES = 2 * 1024 * 1024 * 1024;
|
||||
@@ -227,6 +229,8 @@ function Operaciones() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ReplicationCard />
|
||||
|
||||
{/* Ingest folder */}
|
||||
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
||||
<h2 className="section-title">Carpeta de ingesta</h2>
|
||||
@@ -596,3 +600,102 @@ function OpTile({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Health of the read replica behind my.jorgecuadros.com.
|
||||
*
|
||||
* Worth a panel because the failure mode is silent: a replica whose SQL thread
|
||||
* has stopped keeps answering queries, just with data frozen at the moment it
|
||||
* stopped. Nothing on the customer site looks wrong — the balances are simply
|
||||
* out of date — so without this the only signal is a customer complaining.
|
||||
*/
|
||||
function ReplicationCard() {
|
||||
const [status, setStatus] = useState<ReplicationStatus | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
getReplicationStatus()
|
||||
.then((s) => {
|
||||
setStatus(s);
|
||||
setFailed(false);
|
||||
})
|
||||
.catch(() => setFailed(true));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const t = setInterval(load, 30_000);
|
||||
return () => clearInterval(t);
|
||||
}, [load]);
|
||||
|
||||
// Not configured is the normal state in dev and before cutover, so it is a
|
||||
// quiet note rather than an alarm — showing red here would train people to
|
||||
// ignore the card.
|
||||
if (failed || (status && !status.configured)) {
|
||||
return (
|
||||
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
||||
<h2 className="section-title">Réplica del sitio de clientes</h2>
|
||||
<p className="inline-form-note">
|
||||
{failed
|
||||
? "No se pudo consultar el estado de la réplica."
|
||||
: "No configurada en este entorno."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
||||
<h2 className="section-title">Réplica del sitio de clientes</h2>
|
||||
<p className="inline-form-note">Consultando…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
||||
<div className="row-actions" style={{ justifyContent: "space-between" }}>
|
||||
<h2 className="section-title" style={{ margin: 0 }}>
|
||||
Réplica del sitio de clientes{" "}
|
||||
<span className={`badge ${status.healthy ? "badge-positive" : "badge-negative"}`}>
|
||||
{status.healthy ? "Replicando" : "Detenida"}
|
||||
</span>
|
||||
</h2>
|
||||
<button className="btn btn-ghost" type="button" onClick={load}>
|
||||
Actualizar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status.problem && (
|
||||
<div className="state-box state-error" style={{ marginTop: 12 }}>
|
||||
{status.problem}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="kv-grid" style={{ paddingLeft: 0, paddingRight: 0 }}>
|
||||
<KV label="Servidor" value={status.host} />
|
||||
<KV label="Origen" value={status.sourceHost} />
|
||||
<KV label="Hilo de E/S" value={status.ioRunning} />
|
||||
<KV label="Hilo SQL" value={status.sqlRunning} />
|
||||
{/* Never render a null lag as "0 s": MySQL reports NULL whenever a
|
||||
thread is down, so the honest word is "unknown", not "up to date". */}
|
||||
<KV
|
||||
label="Retraso"
|
||||
value={status.secondsBehind === null ? "sin dato" : `${status.secondsBehind} s`}
|
||||
/>
|
||||
<KV label="Consultado" value={formatDateTime(status.checkedAt)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Matches the KV in the clientes/polizas/servicios detail pages. */
|
||||
function KV({ label, value }: { label: string; value: string | null | undefined }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="kv-label">{label}</div>
|
||||
<div className="kv-value">{value || "—"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ import type {
|
||||
LookupsResponse,
|
||||
OpsJob,
|
||||
OpsJobKind,
|
||||
ReplicationStatus,
|
||||
IngestFile,
|
||||
BackupFile,
|
||||
PropertyDetail,
|
||||
@@ -939,6 +940,16 @@ export function deleteBackup(name: string): Promise<unknown> {
|
||||
return apiFetch(`/ops/backups/${encodeURIComponent(name)}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Health of the read replica my.jorgecuadros.com serves customers from.
|
||||
*
|
||||
* A stopped replica does not error — it answers with stale balances — so this
|
||||
* is the only place the failure is visible.
|
||||
*/
|
||||
export function getReplicationStatus(): Promise<ReplicationStatus> {
|
||||
return apiFetch<ReplicationStatus>("/ops/replication");
|
||||
}
|
||||
|
||||
export function listOpsJobs(): Promise<OpsJob[]> {
|
||||
return apiFetch<OpsJob[]>("/ops/jobs");
|
||||
}
|
||||
|
||||
@@ -72,6 +72,27 @@ export interface OpsJob {
|
||||
finishedAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Health of the MySQL read replica that my.jorgecuadros.com queries.
|
||||
*
|
||||
* `secondsBehind` is null whenever MySQL reports NULL, which it does when
|
||||
* EITHER thread is down — so null means "unknown", never "up to date". Read
|
||||
* `healthy`/`problem` rather than inferring health from the lag.
|
||||
*/
|
||||
export interface ReplicationStatus {
|
||||
configured: boolean;
|
||||
healthy: boolean;
|
||||
host: string | null;
|
||||
ioRunning: string | null;
|
||||
sqlRunning: string | null;
|
||||
secondsBehind: number | null;
|
||||
lastIoError: string | null;
|
||||
lastSqlError: string | null;
|
||||
sourceHost: string | null;
|
||||
problem: string | null;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
/** One of the four legacy Access files expected in the ingest folder. */
|
||||
export interface IngestFile {
|
||||
name: string;
|
||||
|
||||
@@ -69,6 +69,14 @@ services:
|
||||
# apps/api/src/ops/ops.service.ts.
|
||||
OPS_DB_ADMIN_USER: ${OPS_DB_ADMIN_USER:-root}
|
||||
OPS_DB_ADMIN_PASSWORD: ${OPS_DB_ADMIN_PASSWORD:?OPS_DB_ADMIN_PASSWORD must be set}
|
||||
# Read-only replica that my.jorgecuadros.com serves customers from. Used
|
||||
# ONLY to report health on the Operaciones screen — the account holds
|
||||
# REPLICATION CLIENT and nothing else, so it cannot read a single row.
|
||||
# Unset is a supported state: the panel then says "no configurada"
|
||||
# instead of erroring, which is correct before cutover and in dev.
|
||||
REPLICA_DB_HOST: ${REPLICA_DB_HOST:-}
|
||||
REPLICA_DB_USER: ${REPLICA_DB_USER:-}
|
||||
REPLICA_DB_PASS: ${REPLICA_DB_PASS:-}
|
||||
S3_ENDPOINT: ${S3_ENDPOINT:?S3_ENDPOINT must be set}
|
||||
S3_BUCKET: ${S3_BUCKET:-jorgecuadros-documents}
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER must be set}
|
||||
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Is the my.jorgecuadros.com read replica actually replicating?
|
||||
#
|
||||
# deploy/scripts/check-replication.sh
|
||||
#
|
||||
# Answers it from the REPLICA alone, so it needs no credentials for the
|
||||
# galactus master — only ssh to the VPS. Exits non-zero when replication is
|
||||
# broken or lagging, so it is usable from cron or a monitor.
|
||||
#
|
||||
# Why not just eyeball `SHOW REPLICA STATUS`: the two obvious fields are both
|
||||
# misleading on their own.
|
||||
#
|
||||
# * "Replica_IO_Running: Yes" only means the network thread is alive. The SQL
|
||||
# thread can be stopped with a duplicate-key error while IO keeps happily
|
||||
# downloading binlog, so the replica looks busy and falls further behind.
|
||||
#
|
||||
# * "Seconds_Behind_Source: 0" reads 0 both when there is genuinely nothing
|
||||
# to apply AND when the IO thread is disconnected — there is no event to
|
||||
# measure staleness against, so absence of work is reported as being current.
|
||||
#
|
||||
# The trustworthy check is GTID_SUBTRACT(Retrieved, Executed): binlog we have
|
||||
# fetched but not yet applied. Empty means genuinely caught up.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
REPLICA_HOST="${REPLICA_HOST:-opc@163.192.62.37}"
|
||||
MAX_LAG="${MAX_LAG:-30}"
|
||||
|
||||
raw=$(ssh -o ConnectTimeout=10 -o BatchMode=yes "$REPLICA_HOST" \
|
||||
'sudo mysql -e "SHOW REPLICA STATUS\G"' 2>/dev/null)
|
||||
|
||||
if [ -z "$raw" ]; then
|
||||
echo "FAIL: could not reach $REPLICA_HOST or mysql returned nothing"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# sed rather than `head -n1`: on some machines `head` is shadowed by LWP's
|
||||
# HTTP head(1), which silently mangles the pipeline instead of erroring.
|
||||
field() { printf '%s\n' "$raw" | grep -E "^[[:space:]]*$1:" | sed -n '1p' | sed -E "s/^[[:space:]]*$1:[[:space:]]*//"; }
|
||||
|
||||
io=$(field Replica_IO_Running)
|
||||
sql=$(field Replica_SQL_Running)
|
||||
lag=$(field Seconds_Behind_Source)
|
||||
io_err=$(field Last_IO_Error)
|
||||
sql_err=$(field Last_SQL_Error)
|
||||
|
||||
# The authoritative "am I caught up" test: anything fetched but not applied.
|
||||
backlog=$(ssh -o ConnectTimeout=10 -o BatchMode=yes "$REPLICA_HOST" \
|
||||
'sudo mysql -NB -e "
|
||||
SELECT IFNULL(NULLIF(GTID_SUBTRACT(
|
||||
(SELECT RECEIVED_TRANSACTION_SET FROM performance_schema.replication_connection_status),
|
||||
@@GLOBAL.gtid_executed), \"\"), \"(none)\")" 2>/dev/null' 2>/dev/null)
|
||||
[ -z "$backlog" ] && backlog="(performance_schema off — using lag only)"
|
||||
|
||||
echo "replica : $REPLICA_HOST"
|
||||
echo "IO thread : $io"
|
||||
echo "SQL thread : $sql"
|
||||
if [ "$lag" = "NULL" ] || [ -z "$lag" ]; then
|
||||
echo "lag : NULL"
|
||||
else
|
||||
echo "lag : ${lag}s"
|
||||
fi
|
||||
echo "unapplied : $backlog"
|
||||
[ -n "$io_err" ] && echo "IO error : $io_err"
|
||||
[ -n "$sql_err" ] && echo "SQL error : $sql_err"
|
||||
|
||||
rc=0
|
||||
[ "$io" = "Yes" ] || { echo "FAIL: IO thread not running"; rc=1; }
|
||||
[ "$sql" = "Yes" ] || { echo "FAIL: SQL thread not running"; rc=1; }
|
||||
[ -n "$io_err" ] && { rc=1; }
|
||||
[ -n "$sql_err" ] && { rc=1; }
|
||||
# SHOW reports NULL lag whenever EITHER thread is down — there is no applied
|
||||
# event to measure against. Never report which one from the lag alone; the
|
||||
# thread fields above already said, and guessing produces a wrong diagnosis.
|
||||
if [ "$lag" = "NULL" ] || [ -z "$lag" ]; then
|
||||
echo "FAIL: lag is NULL (replication not applying)"
|
||||
rc=1
|
||||
elif [ "$lag" -gt "$MAX_LAG" ] 2>/dev/null; then
|
||||
echo "WARN: lag ${lag}s exceeds ${MAX_LAG}s"
|
||||
rc=1
|
||||
fi
|
||||
|
||||
[ $rc -eq 0 ] && echo "OK: replica is running and caught up"
|
||||
exit $rc
|
||||
@@ -189,6 +189,11 @@ def customer_from_utilities(row, name_index) -> dict:
|
||||
customerSince=as_date(row["cliente_desde"]),
|
||||
status=as_bool(row["status"]),
|
||||
feeAmount=as_decimal(row["fee"]),
|
||||
# DATGRAL.TIPO is the minimum-balance threshold (100/200/300/500 —
|
||||
# 1,017 of 1,172 customers carry one), NOT an identification or account
|
||||
# type as the column name suggests. It reaches the website as
|
||||
# datosfreak.TIPO and is returned to the customer app as `minBalance`.
|
||||
minimumBalance=as_decimal(row["tipo"]),
|
||||
updatedAt=NOW,
|
||||
)
|
||||
|
||||
@@ -217,6 +222,7 @@ def customer_from_insurance(row, name_index) -> dict:
|
||||
customerSince=None,
|
||||
status=1,
|
||||
feeAmount=None,
|
||||
minimumBalance=None,
|
||||
updatedAt=NOW,
|
||||
)
|
||||
|
||||
@@ -225,7 +231,7 @@ _CUST_COLS = [
|
||||
"id", "name", "nameSource", "nameMissing", "addressLine1", "addressLine2", "city", "state", "zipCode",
|
||||
"country", "phone", "mobile", "fax", "email", "notes", "identificationType",
|
||||
"identificationNumber", "identificationExpiration", "customerSince",
|
||||
"status", "feeAmount", "updatedAt",
|
||||
"status", "feeAmount", "minimumBalance", "updatedAt",
|
||||
]
|
||||
|
||||
|
||||
@@ -316,7 +322,7 @@ def main() -> None:
|
||||
remap[rec["id"]] = stable or rec["id"]
|
||||
for rec in customers:
|
||||
rec["id"] = remap[rec["id"]]
|
||||
cur.execute(f"INSERT INTO customers ({','.join(f'`{c}`' for c in _CUST_COLS)}) VALUES ({placeholders}) ON DUPLICATE KEY UPDATE name=VALUES(name),nameSource=VALUES(nameSource),nameMissing=VALUES(nameMissing),addressLine1=VALUES(addressLine1),addressLine2=VALUES(addressLine2),city=VALUES(city),state=VALUES(state),zipCode=VALUES(zipCode),country=VALUES(country),phone=VALUES(phone),mobile=VALUES(mobile),fax=VALUES(fax),email=VALUES(email),notes=VALUES(notes),identificationType=VALUES(identificationType),identificationNumber=VALUES(identificationNumber),identificationExpiration=VALUES(identificationExpiration),customerSince=VALUES(customerSince),status=VALUES(status),feeAmount=VALUES(feeAmount),updatedAt=VALUES(updatedAt)", tuple(rec[c] for c in _CUST_COLS))
|
||||
cur.execute(f"INSERT INTO customers ({','.join(f'`{c}`' for c in _CUST_COLS)}) VALUES ({placeholders}) ON DUPLICATE KEY UPDATE name=VALUES(name),nameSource=VALUES(nameSource),nameMissing=VALUES(nameMissing),addressLine1=VALUES(addressLine1),addressLine2=VALUES(addressLine2),city=VALUES(city),state=VALUES(state),zipCode=VALUES(zipCode),country=VALUES(country),phone=VALUES(phone),mobile=VALUES(mobile),fax=VALUES(fax),email=VALUES(email),notes=VALUES(notes),identificationType=VALUES(identificationType),identificationNumber=VALUES(identificationNumber),identificationExpiration=VALUES(identificationExpiration),customerSince=VALUES(customerSince),status=VALUES(status),feeAmount=VALUES(feeAmount),minimumBalance=VALUES(minimumBalance),updatedAt=VALUES(updatedAt)", tuple(rec[c] for c in _CUST_COLS))
|
||||
for ref in refs:
|
||||
cur.execute("INSERT INTO customer_legacy_refs (id,customerId,sourceSystem,sourceTable,legacyId) VALUES (%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE customerId=VALUES(customerId)",
|
||||
(ref[0], remap[ref[1]], ref[2], ref[3], ref[4]))
|
||||
|
||||
@@ -112,6 +112,29 @@ def main():
|
||||
type_rows.append((tid, en, s(r["espa_ol"]), 0))
|
||||
type_map[en.upper()] = tid
|
||||
|
||||
def type_id_for(raw) -> str | None:
|
||||
"""Resolve a transaction type, minting one when the lookup lacks it.
|
||||
|
||||
The Access `TYPE OF TRX` table is a stale pick-list, not a constraint —
|
||||
staff free-text straight into DATOS2, so 78 values covering 3,939 rows
|
||||
(BALANCE FORWARD 1,188, ANNUAL FEE 1,116, IZZI 367, ...) appear in the
|
||||
ledger but not the lookup. Leaving those unmapped stored typeId NULL and
|
||||
lost the label outright: nothing else on `transactions` carries the type
|
||||
text, so the row rendered blank and was unrecoverable after migration.
|
||||
Minting from the literal keeps the display string; nameEs stays NULL
|
||||
because only the lookup has translations.
|
||||
"""
|
||||
en = s(raw)
|
||||
if not en:
|
||||
return None
|
||||
key = en.upper()
|
||||
tid = type_map.get(key)
|
||||
if tid is None:
|
||||
tid = str(uuid.uuid4())
|
||||
type_rows.append((tid, en, None, 0))
|
||||
type_map[key] = tid
|
||||
return tid
|
||||
|
||||
xr = load("stg_utilities", "tipo_hist")
|
||||
xr_rows = []
|
||||
for _, r in xr.iterrows():
|
||||
@@ -143,12 +166,24 @@ def main():
|
||||
s(r["conepto"]),
|
||||
)
|
||||
|
||||
def efectivo_like(src, name, domain, custmap, src_db, legacy_tbl, *, seen=None):
|
||||
def efectivo_like(src, name, domain, custmap, src_db, legacy_tbl, *, seen=None,
|
||||
type_label=None):
|
||||
"""Load an EFECTIVO-shaped cash ledger.
|
||||
|
||||
`seen` (a set) makes the load de-duplicating: keys are added to it as
|
||||
rows load, and a row whose key is already present is skipped. That is
|
||||
how EFECTIVO_BACKUP contributes only its genuinely-new rows.
|
||||
|
||||
`type_label` names the transaction type for every row. These tables have
|
||||
no type column at all — in Access the type is implied by which table the
|
||||
row lives in — so unlike DATOS2 there is no string to map and typeId came
|
||||
out NULL for all of them.
|
||||
|
||||
That is not merely a blank label. handleGetAccountDetails in
|
||||
my.jorgecuadros.com identifies payments by matching TYPEOFTRX against
|
||||
('PAYMENT THANK YOU', 'PAYPAL', 'CASH DEPOSIT', 'CHECK DEPOSIT') to reset
|
||||
the running balance in mode=current; an unlabelled payment is not
|
||||
recognised and the balance silently diverges from legacy.
|
||||
"""
|
||||
nonlocal skip_cust, skip_date, skip_dupe
|
||||
df = load(src, name)
|
||||
@@ -166,9 +201,20 @@ def main():
|
||||
skip_date += 1; continue
|
||||
add(cid, domain, td, dec(r["monto"], Decimal(0)), cur(r["monedas"]),
|
||||
reference=s(r["folio"]), message=s(r["conepto"]),
|
||||
typeid=type_id_for(type_label),
|
||||
src_db=src_db, src_tbl=legacy_tbl, legacy=str(int(r["_row_num"])))
|
||||
|
||||
def fm3(name, legacy_tbl, check_col=None):
|
||||
"""FM3 fee streams. Deliberately left unlabelled, unlike EFECTIVO.
|
||||
|
||||
These rows (EFECTIVO FM3 627, CHEQUE FM3 157) also have no type column,
|
||||
but every one of them predates the two periods the site exposes — it
|
||||
allowlists only the current year and the prior year — so none can be
|
||||
matched against a legacy label, and none can reach a customer. Inventing
|
||||
a plausible name like "CHECK DEPOSIT" would feed the payment-detection
|
||||
list in handleGetAccountDetails on nothing but a guess. Leave them NULL
|
||||
until a real mapping is available.
|
||||
"""
|
||||
nonlocal skip_cust, skip_date
|
||||
df = load("stg_utilities", name)
|
||||
for _, r in df.iterrows():
|
||||
@@ -193,7 +239,7 @@ def main():
|
||||
td = dt(r["date"])
|
||||
if td is None:
|
||||
skip_date += 1; continue
|
||||
tid = type_map.get((s(r["type_of_trx"]) or "").upper())
|
||||
tid = type_id_for(r["type_of_trx"])
|
||||
add(cid, "UTILITY", td, dec(r["chargecredit"], Decimal(0)), "MXN",
|
||||
period=s(r["period"]), reference=s(r["refer"]), typeid=tid,
|
||||
check=s(r["cheque"]), src_db="UTILITIES", src_tbl=legacy_tbl,
|
||||
@@ -214,17 +260,25 @@ def main():
|
||||
# order matters: EFECTIVO is the live table and loads first, so a collision
|
||||
# always resolves in its favour.
|
||||
cash_seen: set = set()
|
||||
# "CASH DEPOSIT" is not a guess: matching these rows to the live site on
|
||||
# (NUMid, date, amount) resolves to that label unanimously — 66/66 in the
|
||||
# current-year `datosfreak` and 100/100 in the prior-year `2025` table,
|
||||
# which are the only two periods the site exposes.
|
||||
efectivo_like("stg_utilities", "efectivo", "UTILITY", util_cust, "UTILITIES",
|
||||
"EFECTIVO", seen=cash_seen)
|
||||
"EFECTIVO", seen=cash_seen, type_label="CASH DEPOSIT")
|
||||
efectivo_like("stg_utilities", "efectivo_backup", "UTILITY", util_cust, "UTILITIES",
|
||||
"EFECTIVO_BACKUP", seen=cash_seen)
|
||||
"EFECTIVO_BACKUP", seen=cash_seen, type_label="CASH DEPOSIT")
|
||||
fm3("efectivo_fm3", "EFECTIVO FM3")
|
||||
fm3("cheque_fm3", "CHEQUE FM3", check_col="num_cheque")
|
||||
billing("datos2", "datos2")
|
||||
billing("fee_anual", "FEE ANUAL")
|
||||
billing("fee15", "fee15")
|
||||
iva()
|
||||
efectivo_like("stg_seguros", "efectivo", "INSURANCE", ins_cust, "SEGUROS 16_be", "EFECTIVO")
|
||||
# Same record shape in the seguros DB. Labelled for consistency in the
|
||||
# platform's own UI; unverifiable against the site, which only ever reads
|
||||
# domain='UTILITY', so no customer-facing behaviour depends on it.
|
||||
efectivo_like("stg_seguros", "efectivo", "INSURANCE", ins_cust, "SEGUROS 16_be",
|
||||
"EFECTIVO", type_label="CASH DEPOSIT")
|
||||
|
||||
if sync_mode:
|
||||
# Transaction types are rebuilt with fresh uuids each run; resolve them
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jorgecuadros-platform",
|
||||
"version": "1.0.7",
|
||||
"version": "1.0.10",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jorgecuadros/database",
|
||||
"version": "1.0.7",
|
||||
"version": "1.0.10",
|
||||
"private": true,
|
||||
"main": "generated/client/index.js",
|
||||
"types": "generated/client/index.d.ts",
|
||||
|
||||
Reference in New Issue
Block a user