Skip to content

v2.9.0 — multi-site join, replication and mesh fixes

Choose a tag to compare

@wmantly wmantly released this 11 Aug 15:42
· 32 commits to master since this release
9bfd58d

Fixed

  • The multi-site join's LDAP import had never worked. Three stacked defects, each hidden because the only report was an ldap.note string no test asserted on: ldapAddArgs() omitted argv[0], so the caller ran execFile('-c', ...) and every join/resync died with spawn -c ENOENT; raw slapcat output carries operational attributes (structuralObjectClass, entryUUID, memberOf, …) that ldapadd rejects outright; and ldapadd -c exits non-zero on the benign "Already exists" every spoke produces, so even a clean import reported failure. A spoke adopted the resource catalog and signing key but not one user or group. Fixed with stripOperationalAttrs() + summarizeLdapAddResult(); the e2e now seeds a master-only user and requires it to appear in the spoke's own slapd.
  • Catalog updates and deletions never reached a spoke. importDirectory() called Resource.update(id, data) and ResourceEdge.delete(id) — statics @simpleworkjs/orm has never had (update/delete are instance methods). Both threw into a swallowing catch, so renames, metadata edits, edge removals and resource deletions were silently dropped; only new resources ever appeared. The injected test stubs implemented them as statics, which is why the suite stayed green. Stubs now mirror the real ORM shape, tests/orm_method_guard.test.js covers update/delete (and is now actually in npm test — it never ran in CI), and a contract test pins the stubs to the real base model.
  • importDirectory() is now converging rather than destructive. It deleted every edge before recreating them, so a crash mid-import left a truncated graph. Creates land first, extras are removed afterward, and edge endpoints are remapped master-id → slug → local-id (a spoke's own bootstrap rows have local ids, and slugs like openresty are shared across sites, so raw ids pointed at rows that didn't exist). Resource deletions now propagate too, but only for rows this path adopted (__replicatedFrom provenance) — never a spoke's own locally-bootstrapped catalog.
  • Promotion orphaned every site beyond the second. The promoted node was a spoke, so its own SiteSpoke registry was empty and replicateToSpokes('master-promoted') fanned out to nobody, while sibling spokes kept following the demoted master. POST /api/site/demote now hands its registry (including each spoke's pushToken) to the incoming master, which adopts it and re-points each sibling via the new POST /api/site/master-changed. Two-site clusters always worked, which is why this was invisible.
  • Concurrent spoke registrations were assigned the same LDAP ServerID. nextFreeLdapServerId() + SiteSpoke.create() is a read-then-write; two simultaneous joins both read the same used-set and both got id 2. Duplicate ServerIDs don't fail loudly — they break MMR, since ServerID is how syncrepl tells originators apart. Serialized with a new utils/mutex.js — and, because that lock is process-local and would protect nothing if this app were ever run as two processes against one database, enforced for real by a unique index (see below).
  • unique: true on a model field never reached the database. Two independent gaps: @simpleworkjs/orm only forwards unique for string fields (IntegerField.toSequelize() drops it), and the ORM calls sequelize.sync() with no options, which creates missing tables but never alters existing ones — so even SiteSpoke.endpoint's long-declared constraint does not exist on any already-deployed site. models/index.js's new ensureUniqueIndexes() adds the indexes explicitly, on the same add-only, fail-soft terms as the existing healSchema(). A database written before this can already hold duplicate ServerIDs, which would make addIndex fail, so repairDuplicateServerIds() runs first: the oldest registration keeps the id, the rest move to free ones (safe unattended — a spoke re-reads its ServerID from the master on every reconcile, and the duplicate state was already broken).
  • The mutex could deadlock silently. Re-entering the same lock — directly, or by an outbound call that comes back into a route taking it — waited forever on a promise that could never settle, with the symptom surfacing somewhere else entirely (during promotion it appeared on the calling node as re-point failed: This operation was aborted, its own fetch timeout, while the callee sat waiting on itself). Same-context re-entry now throws immediately via AsyncLocalStorage; cross-node re-entry hits an acquisition timeout that names the current holder. withLock also no longer passes the previous holder's resolution value into the next callback, and drops its bookkeeping entry once a lock is idle so data-derived lock names cannot leak.
  • A base-DN mismatch between sites now fails the join up front. CFG_DOMAIN must be identical cluster-wide (MMR replicas cannot diverge on base DN), but a mismatch used to half-succeed: catalog and signing key adopted, LDAP silently rejected entry-by-entry with "no global superior knowledge".
  • The mesh carried no service traffic. Consumers dialled 172.24.<idx>.1:3001 directly — an address that exists only inside the peer gateway's network namespace, unreachable from the sso-manager/theta-proxy containers told to use it, with nothing listening on :3001 there anyway. No-inbound relay routes and mesh-preferred resync pushes both resolved to a dead target. utils/mesh_route.js now routes via the LOCAL gateway's per-peer forwarding port, derived from the mesh index (see theta-gateway's services/mesh_forwarder.js).
  • LDAP tunnel relay sockets were unbounded. No idle timeout, no connect deadline, no per-agent ceiling, so an agent that vanished without a clean close stranded its relay sockets indefinitely; and cleanup(agentId) tore down a reconnecting agent's new sockets along with the old ones. Now bounded on all three axes and scoped per WebSocket.
  • The Multi-Site modal's confirmations did nothing. app.messages.confirm() renders into a .actionMessage element and its promise never settles without one — the modal had none, so both the spoke-remove and the pre-existing promote confirmation silently no-op'd on click. Added the region; per-row removal uses an inline row-scoped confirm instead (a single shared banner desyncs across rows).

Added

  • LDAP replication config is applied live — no setup.sh re-run, on any node. slapd now runs from the cn=config dynamic backend (converted at container start from the generated slapd.conf seed), so olcServerID/olcSyncrepl are modifiable while it serves. utils/ldap_runtime_config.js converges the running config on the cluster's view (read, diff, apply only what differs); utils/ldap_reconcile.js triggers it on spoke registration/removal, join, resync, master-changed, promotion, boot, and a periodic sweep. Previously every site's peer list went stale each time any site joined, fixable only by an operator re-running setup.sh everywhere. Drift detection is retained as a fault indicator.
  • Registered Spokes is actionable: DELETE /api/site/spokes/:id, POST /api/site/spokes/resync (awaited, so "Sync now" reports real reachability), and GET /api/site/spokes.
  • POST /api/site/reregister — the recovery path when a spoke and its master disagree about the push token (a removed/recreated registry row, or a join made without selfUrl). POST /join cannot fix it: it refuses once a node is a spoke.
  • Three-site e2e (docker-compose.multisite-e2e.yml) covering the promotion handoff, live LDAP replication config, LDAP tree replication, and resource update/delete convergence — all previously uncovered.