Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f1bfe906e | ||
|
|
7797c45e9f | ||
|
|
dac1f1982f | ||
|
|
1d689d8f46 | ||
|
|
7bec2a13d8 |
@@ -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.8",
|
||||
"version": "1.0.9",
|
||||
"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,153 @@
|
||||
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 => {
|
||||
const m = raw.match(new RegExp(`^\\s*${name}:\\s*(.*)$`, "m"));
|
||||
const v = m?.[1]?.trim();
|
||||
return v === undefined || v === "" ? null : v;
|
||||
};
|
||||
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jorgecuadros/web",
|
||||
"version": "1.0.8",
|
||||
"version": "1.0.9",
|
||||
"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
|
||||
@@ -166,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)
|
||||
@@ -189,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():
|
||||
@@ -237,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.8",
|
||||
"version": "1.0.9",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jorgecuadros/database",
|
||||
"version": "1.0.8",
|
||||
"version": "1.0.9",
|
||||
"private": true,
|
||||
"main": "generated/client/index.js",
|
||||
"types": "generated/client/index.d.ts",
|
||||
|
||||
Reference in New Issue
Block a user