@supalive/core 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -2
- package/dist/bytes-AuEkyxeU.js +19 -0
- package/dist/bytes-AuEkyxeU.js.map +1 -0
- package/dist/codec-BHAmGEIQ.js +62 -0
- package/dist/codec-BHAmGEIQ.js.map +1 -0
- package/dist/helper-CiacMqje.d.ts +14 -0
- package/dist/helper-CiacMqje.d.ts.map +1 -0
- package/dist/helper-wGbzz6zo.js +42 -0
- package/dist/helper-wGbzz6zo.js.map +1 -0
- package/dist/index-CaWMDqq0.d.ts +1754 -0
- package/dist/index-CaWMDqq0.d.ts.map +1 -0
- package/dist/mysql-Cxd4Uq5p.d.ts +104 -0
- package/dist/mysql-Cxd4Uq5p.d.ts.map +1 -0
- package/dist/mysql-bvnjYWvZ.js +570 -0
- package/dist/mysql-bvnjYWvZ.js.map +1 -0
- package/dist/overlap-checker-Ct_HP_iB.js +187 -0
- package/dist/overlap-checker-Ct_HP_iB.js.map +1 -0
- package/dist/postgres-B8u1YN8X.js +824 -0
- package/dist/postgres-B8u1YN8X.js.map +1 -0
- package/dist/postgres-Ck-xt72U.d.ts +108 -0
- package/dist/postgres-Ck-xt72U.d.ts.map +1 -0
- package/dist/procedure-DX87tyef.js +124 -0
- package/dist/procedure-DX87tyef.js.map +1 -0
- package/dist/query-Bh0RNxDz.js +673 -0
- package/dist/query-Bh0RNxDz.js.map +1 -0
- package/dist/realtime_db-DOCgqbwi.js +247 -0
- package/dist/realtime_db-DOCgqbwi.js.map +1 -0
- package/dist/router-DlTYWpop.js +81 -0
- package/dist/router-DlTYWpop.js.map +1 -0
- package/dist/schema-Dd7u0A1L.js +109 -0
- package/dist/schema-Dd7u0A1L.js.map +1 -0
- package/dist/src/client/index.d.ts +2 -17
- package/dist/src/client/index.js +1152 -18
- package/dist/src/client/index.js.map +1 -1
- package/dist/src/exports/mysql.d.ts +2 -2
- package/dist/src/exports/mysql.js +2 -2
- package/dist/src/exports/postgres.d.ts +2 -2
- package/dist/src/exports/postgres.js +2 -2
- package/dist/src/exports/procedure.d.ts +3 -5
- package/dist/src/exports/procedure.js +6 -5
- package/dist/src/exports/schema-sql.d.ts +18 -0
- package/dist/src/exports/schema-sql.d.ts.map +1 -0
- package/dist/src/exports/schema-sql.js +161 -0
- package/dist/src/exports/schema-sql.js.map +1 -0
- package/dist/src/exports/server.d.ts +368 -2
- package/dist/src/exports/server.d.ts.map +1 -1
- package/dist/src/exports/server.js +1346 -2
- package/dist/src/exports/server.js.map +1 -1
- package/dist/src/exports/subscription-manager-worker-entry.d.ts +1 -0
- package/dist/src/exports/subscription-manager-worker-entry.js +115 -0
- package/dist/src/exports/subscription-manager-worker-entry.js.map +1 -0
- package/dist/src/exports/types.d.ts +4 -6
- package/dist/src/exports/types.js +5 -6
- package/dist/subscription-worker-pahX0zxg.js +511 -0
- package/dist/subscription-worker-pahX0zxg.js.map +1 -0
- package/dist/types_client_rpc-CIsjQQGG.js +105 -0
- package/dist/types_client_rpc-CIsjQQGG.js.map +1 -0
- package/dist/types_db-DXj_l9n-.js +168 -0
- package/dist/types_db-DXj_l9n-.js.map +1 -0
- package/dist/types_server-Dk6o_B7N.js +24 -0
- package/dist/types_server-Dk6o_B7N.js.map +1 -0
- package/dist/types_server-TFo6SWDH.d.ts +216 -0
- package/dist/types_server-TFo6SWDH.d.ts.map +1 -0
- package/package.json +7 -3
- package/dist/src/client/caller_client.d.ts +0 -42
- package/dist/src/client/caller_client.d.ts.map +0 -1
- package/dist/src/client/caller_client.js +0 -77
- package/dist/src/client/caller_client.js.map +0 -1
- package/dist/src/client/client.d.ts +0 -149
- package/dist/src/client/client.d.ts.map +0 -1
- package/dist/src/client/client.js +0 -308
- package/dist/src/client/client.js.map +0 -1
- package/dist/src/client/index.d.ts.map +0 -1
- package/dist/src/client/sub_key.d.ts +0 -20
- package/dist/src/client/sub_key.d.ts.map +0 -1
- package/dist/src/client/sub_key.js +0 -37
- package/dist/src/client/sub_key.js.map +0 -1
- package/dist/src/client/ws_client_manager.d.ts +0 -268
- package/dist/src/client/ws_client_manager.d.ts.map +0 -1
- package/dist/src/client/ws_client_manager.js +0 -1013
- package/dist/src/client/ws_client_manager.js.map +0 -1
- package/dist/src/config.d.ts +0 -117
- package/dist/src/config.d.ts.map +0 -1
- package/dist/src/config.js +0 -2
- package/dist/src/config.js.map +0 -1
- package/dist/src/db/bytes.d.ts +0 -4
- package/dist/src/db/bytes.d.ts.map +0 -1
- package/dist/src/db/bytes.js +0 -24
- package/dist/src/db/bytes.js.map +0 -1
- package/dist/src/db/cache.d.ts +0 -30
- package/dist/src/db/cache.d.ts.map +0 -1
- package/dist/src/db/cache.js +0 -153
- package/dist/src/db/cache.js.map +0 -1
- package/dist/src/db/codec.d.ts +0 -10
- package/dist/src/db/codec.d.ts.map +0 -1
- package/dist/src/db/codec.js +0 -73
- package/dist/src/db/codec.js.map +0 -1
- package/dist/src/db/context.d.ts +0 -28
- package/dist/src/db/context.d.ts.map +0 -1
- package/dist/src/db/context.js +0 -147
- package/dist/src/db/context.js.map +0 -1
- package/dist/src/db/db.d.ts +0 -154
- package/dist/src/db/db.d.ts.map +0 -1
- package/dist/src/db/db.js +0 -23
- package/dist/src/db/db.js.map +0 -1
- package/dist/src/db/mysql.d.ts +0 -120
- package/dist/src/db/mysql.d.ts.map +0 -1
- package/dist/src/db/mysql.js +0 -708
- package/dist/src/db/mysql.js.map +0 -1
- package/dist/src/db/postgres.d.ts +0 -116
- package/dist/src/db/postgres.d.ts.map +0 -1
- package/dist/src/db/postgres.js +0 -998
- package/dist/src/db/postgres.js.map +0 -1
- package/dist/src/db/query.d.ts +0 -174
- package/dist/src/db/query.d.ts.map +0 -1
- package/dist/src/db/query.js +0 -790
- package/dist/src/db/query.js.map +0 -1
- package/dist/src/db/query_definition.d.ts +0 -54
- package/dist/src/db/query_definition.d.ts.map +0 -1
- package/dist/src/db/query_definition.js +0 -47
- package/dist/src/db/query_definition.js.map +0 -1
- package/dist/src/db/realtime_db.d.ts +0 -19
- package/dist/src/db/realtime_db.d.ts.map +0 -1
- package/dist/src/db/realtime_db.js +0 -98
- package/dist/src/db/realtime_db.js.map +0 -1
- package/dist/src/db/schema.d.ts +0 -110
- package/dist/src/db/schema.d.ts.map +0 -1
- package/dist/src/db/schema.js +0 -47
- package/dist/src/db/schema.js.map +0 -1
- package/dist/src/db/types_client_rpc.d.ts +0 -157
- package/dist/src/db/types_client_rpc.d.ts.map +0 -1
- package/dist/src/db/types_client_rpc.js +0 -106
- package/dist/src/db/types_client_rpc.js.map +0 -1
- package/dist/src/db/types_db.d.ts +0 -236
- package/dist/src/db/types_db.d.ts.map +0 -1
- package/dist/src/db/types_db.js +0 -188
- package/dist/src/db/types_db.js.map +0 -1
- package/dist/src/exports/mysql.d.ts.map +0 -1
- package/dist/src/exports/mysql.js.map +0 -1
- package/dist/src/exports/postgres.d.ts.map +0 -1
- package/dist/src/exports/postgres.js.map +0 -1
- package/dist/src/exports/procedure.d.ts.map +0 -1
- package/dist/src/exports/procedure.js.map +0 -1
- package/dist/src/exports/types.d.ts.map +0 -1
- package/dist/src/exports/types.js.map +0 -1
- package/dist/src/helper.d.ts +0 -11
- package/dist/src/helper.d.ts.map +0 -1
- package/dist/src/helper.js +0 -45
- package/dist/src/helper.js.map +0 -1
- package/dist/src/logger.d.ts +0 -7
- package/dist/src/logger.d.ts.map +0 -1
- package/dist/src/logger.js +0 -47
- package/dist/src/logger.js.map +0 -1
- package/dist/src/router/index.d.ts +0 -4
- package/dist/src/router/index.d.ts.map +0 -1
- package/dist/src/router/index.js +0 -4
- package/dist/src/router/index.js.map +0 -1
- package/dist/src/router/procedure.d.ts +0 -204
- package/dist/src/router/procedure.d.ts.map +0 -1
- package/dist/src/router/procedure.js +0 -122
- package/dist/src/router/procedure.js.map +0 -1
- package/dist/src/router/router.d.ts +0 -88
- package/dist/src/router/router.d.ts.map +0 -1
- package/dist/src/router/router.js +0 -88
- package/dist/src/router/router.js.map +0 -1
- package/dist/src/server/hash.d.ts +0 -12
- package/dist/src/server/hash.d.ts.map +0 -1
- package/dist/src/server/hash.js +0 -20
- package/dist/src/server/hash.js.map +0 -1
- package/dist/src/server/hash.test.d.ts +0 -2
- package/dist/src/server/hash.test.d.ts.map +0 -1
- package/dist/src/server/hash.test.js +0 -43
- package/dist/src/server/hash.test.js.map +0 -1
- package/dist/src/server/index.d.ts +0 -9
- package/dist/src/server/index.d.ts.map +0 -1
- package/dist/src/server/index.js +0 -8
- package/dist/src/server/index.js.map +0 -1
- package/dist/src/server/overlap-checker.d.ts +0 -18
- package/dist/src/server/overlap-checker.d.ts.map +0 -1
- package/dist/src/server/overlap-checker.js +0 -247
- package/dist/src/server/overlap-checker.js.map +0 -1
- package/dist/src/server/sub_hash.d.ts +0 -23
- package/dist/src/server/sub_hash.d.ts.map +0 -1
- package/dist/src/server/sub_hash.js +0 -38
- package/dist/src/server/sub_hash.js.map +0 -1
- package/dist/src/server/subscription-manager-client.d.ts +0 -120
- package/dist/src/server/subscription-manager-client.d.ts.map +0 -1
- package/dist/src/server/subscription-manager-client.js +0 -207
- package/dist/src/server/subscription-manager-client.js.map +0 -1
- package/dist/src/server/subscription-manager-types.d.ts +0 -168
- package/dist/src/server/subscription-manager-types.d.ts.map +0 -1
- package/dist/src/server/subscription-manager-types.js +0 -94
- package/dist/src/server/subscription-manager-types.js.map +0 -1
- package/dist/src/server/subscription-manager-worker-entry.d.ts +0 -2
- package/dist/src/server/subscription-manager-worker-entry.d.ts.map +0 -1
- package/dist/src/server/subscription-manager-worker-entry.js +0 -127
- package/dist/src/server/subscription-manager-worker-entry.js.map +0 -1
- package/dist/src/server/subscription-manager-worker-protocol.d.ts +0 -40
- package/dist/src/server/subscription-manager-worker-protocol.d.ts.map +0 -1
- package/dist/src/server/subscription-manager-worker-protocol.js +0 -20
- package/dist/src/server/subscription-manager-worker-protocol.js.map +0 -1
- package/dist/src/server/subscription-manager.d.ts +0 -32
- package/dist/src/server/subscription-manager.d.ts.map +0 -1
- package/dist/src/server/subscription-manager.js +0 -249
- package/dist/src/server/subscription-manager.js.map +0 -1
- package/dist/src/server/subscription-manager.test.d.ts +0 -2
- package/dist/src/server/subscription-manager.test.d.ts.map +0 -1
- package/dist/src/server/subscription-manager.test.js +0 -610
- package/dist/src/server/subscription-manager.test.js.map +0 -1
- package/dist/src/server/subscription-worker.d.ts +0 -32
- package/dist/src/server/subscription-worker.d.ts.map +0 -1
- package/dist/src/server/subscription-worker.js +0 -190
- package/dist/src/server/subscription-worker.js.map +0 -1
- package/dist/src/server/supalive-server.d.ts +0 -158
- package/dist/src/server/supalive-server.d.ts.map +0 -1
- package/dist/src/server/supalive-server.js +0 -1048
- package/dist/src/server/supalive-server.js.map +0 -1
- package/dist/src/server/types_server.d.ts +0 -96
- package/dist/src/server/types_server.d.ts.map +0 -1
- package/dist/src/server/types_server.js +0 -36
- package/dist/src/server/types_server.js.map +0 -1
- package/dist/tsconfig.tsbuildinfo +0 -1
|
@@ -1,1048 +0,0 @@
|
|
|
1
|
-
import { createServer } from "http";
|
|
2
|
-
import { WebSocketServer, WebSocket } from "ws";
|
|
3
|
-
import { v4 as uuidv4 } from "uuid";
|
|
4
|
-
import Redis from "ioredis";
|
|
5
|
-
import { retry, handleAll, ExponentialBackoff } from "cockatiel";
|
|
6
|
-
import { stableStringify, supaliveStringify } from "../helper";
|
|
7
|
-
import { getContextRegistry, } from "../router";
|
|
8
|
-
import { sleep, SupaliveDb } from "../db/realtime_db";
|
|
9
|
-
import { AuthenticationError, ClientMessageSchema } from "../db/types_client_rpc";
|
|
10
|
-
import { RemoteSubscriptionMessageSchema, } from "./types_server";
|
|
11
|
-
import { PgDatabase } from "../db/postgres";
|
|
12
|
-
import { MySqlDatabase } from "../db/mysql";
|
|
13
|
-
import { ANONYMOUS_IDENTITY, generateSubscriptionId, generateCacheKey, getHashOf, } from "./sub_hash";
|
|
14
|
-
import { CacheLayer } from "../db/cache";
|
|
15
|
-
import { SubManagerClient } from "./subscription-manager-client";
|
|
16
|
-
import { appInstanceId, logger as baseLogger, logLevelOf } from "../logger";
|
|
17
|
-
const redisRetryPolicy = retry(handleAll, {
|
|
18
|
-
maxAttempts: 3,
|
|
19
|
-
backoff: new ExponentialBackoff({
|
|
20
|
-
initialDelay: 100,
|
|
21
|
-
maxDelay: 5000,
|
|
22
|
-
exponent: 2,
|
|
23
|
-
}),
|
|
24
|
-
});
|
|
25
|
-
/**
|
|
26
|
-
* Global broadcast channel used to signal column-type cache invalidation
|
|
27
|
-
* across every running server instance. Publishers (migration tooling,
|
|
28
|
-
* the publishSchemaReload helper below) send a JSON message of shape
|
|
29
|
-
* `{ type: "schema:reload"; tables?: string[]; originInstance?: string }`.
|
|
30
|
-
* Subscribers re-bootstrap from PG (or Redis) so the next batched write
|
|
31
|
-
* sees up-to-date column metadata without a process restart.
|
|
32
|
-
*/
|
|
33
|
-
const SCHEMA_RELOAD_CHANNEL = "supalive:schema:reload";
|
|
34
|
-
/**
|
|
35
|
-
* Per-socket cap on pending mutations. The Nth+1 mutation from a single
|
|
36
|
-
* client gets rejected with TOO_MANY_CONCURRENT_MUTATIONS rather than
|
|
37
|
-
* being enqueued — this is rate-limit pressure on a misbehaving client,
|
|
38
|
-
* not a hard system limit.
|
|
39
|
-
*
|
|
40
|
-
* Mirrors Convex's OPERATION_QUEUE_BUFFER_SIZE (1000). Mutations drain
|
|
41
|
-
* one-at-a-time per socket, so a healthy client never approaches this.
|
|
42
|
-
*/
|
|
43
|
-
const MAX_MUTATION_QUEUE_BUFFER = 1000;
|
|
44
|
-
/**
|
|
45
|
-
* Per-socket cap on in-flight actions. Actions run in parallel (unlike
|
|
46
|
-
* mutations) because they may do long external work (HTTP, queue calls);
|
|
47
|
-
* the cap exists so one socket can't open thousands of outbound
|
|
48
|
-
* connections from inside the server.
|
|
49
|
-
*/
|
|
50
|
-
const MAX_ACTION_CONCURRENCY = 1000;
|
|
51
|
-
/**
|
|
52
|
-
* Default and env override for the per-statement DB timeout. PG enforces
|
|
53
|
-
* this via `statement_timeout` (server cancels), MySQL via the per-query
|
|
54
|
-
* `timeout` option (driver issues KILL QUERY). Set 0 to disable.
|
|
55
|
-
*/
|
|
56
|
-
const DB_QUERY_TIMEOUT_MS = Number(process.env.SUPALIVE_DB_QUERY_TIMEOUT_MS ?? 30_000);
|
|
57
|
-
// ─── Heartbeat constants ─────────────────────────────────────
|
|
58
|
-
//
|
|
59
|
-
// Two independent jobs run on a single per-session interval:
|
|
60
|
-
//
|
|
61
|
-
// 1. Dead-TCP detection: terminate sockets that have gone fully silent
|
|
62
|
-
// (no inbound text/ping/pong) past `CLIENT_TIMEOUT_MS`. Otherwise
|
|
63
|
-
// emit a ws-protocol ping (browser auto-pongs, refreshing `session.lastReceivedAt`).
|
|
64
|
-
// 2. Client-watchdog refresh: if the application channel has been idle
|
|
65
|
-
// for at least `HEARTBEAT_INTERVAL_MS`, send a `{type:"ping"}` JSON
|
|
66
|
-
// frame so the client's inactivity watchdog stays fresh. Busy
|
|
67
|
-
// sockets skip this (the `lastSentAt` check makes it idle-only).
|
|
68
|
-
//
|
|
69
|
-
// Both fire on the same `heartbeatTimer` — one `setInterval` per session.
|
|
70
|
-
const HEARTBEAT_INTERVAL_MS = Number(process.env.SUPALIVE_HEARTBEAT_INTERVAL_MS ?? 15_000);
|
|
71
|
-
const CLIENT_TIMEOUT_MS = 120_000;
|
|
72
|
-
/**
|
|
73
|
-
* TTL on the Redis lock that elects the recompute winner when the writer
|
|
74
|
-
* doesn't hold the sub locally. Each (subId, commitTs) gets its own lock
|
|
75
|
-
* key, so this only needs to bound the worst-case recompute duration —
|
|
76
|
-
* if the winner crashes mid-recompute, the lock expires and the next
|
|
77
|
-
* mutation triggers a new race.
|
|
78
|
-
*/
|
|
79
|
-
const RECOMPUTE_LOCK_TTL_SECONDS = Number(process.env.SUPALIVE_RECOMPUTE_LOCK_TTL_SECONDS ?? 10);
|
|
80
|
-
const logger = baseLogger.child({
|
|
81
|
-
"name": "SUPALIVE",
|
|
82
|
-
}, { level: logLevelOf("SERVER_LOG_LEVEL") });
|
|
83
|
-
/**
|
|
84
|
-
* Supalive WebSocket Server
|
|
85
|
-
*/
|
|
86
|
-
export class SupaliveWebSocketServer {
|
|
87
|
-
config;
|
|
88
|
-
instanceId;
|
|
89
|
-
httpServer;
|
|
90
|
-
wss;
|
|
91
|
-
cache;
|
|
92
|
-
db;
|
|
93
|
-
contextRegistry;
|
|
94
|
-
router;
|
|
95
|
-
sessions = new Map();
|
|
96
|
-
serverSubscriptions = new Map();
|
|
97
|
-
subManagerClient;
|
|
98
|
-
redisSub;
|
|
99
|
-
stats = {
|
|
100
|
-
activeConnections: 0,
|
|
101
|
-
totalConnections: 0,
|
|
102
|
-
activeSubscriptions: 0,
|
|
103
|
-
};
|
|
104
|
-
constructor(config) {
|
|
105
|
-
this.config = {
|
|
106
|
-
port: 3000,
|
|
107
|
-
host: "0.0.0.0",
|
|
108
|
-
contextName: "default",
|
|
109
|
-
sessionTtlSeconds: 3600,
|
|
110
|
-
...config,
|
|
111
|
-
};
|
|
112
|
-
this.instanceId = appInstanceId;
|
|
113
|
-
if (!config.subManagerUrl) {
|
|
114
|
-
throw new Error("subManagerUrl config is required (WebSocket URL of the sub-manager process)");
|
|
115
|
-
}
|
|
116
|
-
this.subManagerClient = new SubManagerClient(config.subManagerUrl);
|
|
117
|
-
this.cache = new CacheLayer(new Redis(config.upstash.redisUrl, {
|
|
118
|
-
connectTimeout: 15_000,
|
|
119
|
-
maxRetriesPerRequest: 1,
|
|
120
|
-
keepAlive: 3000,
|
|
121
|
-
}), this.config.cacheTtlSeconds ?? 3600);
|
|
122
|
-
this.redisSub = new Redis(config.upstash.redisUrl, {
|
|
123
|
-
connectTimeout: 15_000,
|
|
124
|
-
maxRetriesPerRequest: 5,
|
|
125
|
-
retryStrategy: (times) => {
|
|
126
|
-
if (times > 5)
|
|
127
|
-
return null;
|
|
128
|
-
return Math.min(times * 200, 2000);
|
|
129
|
-
}
|
|
130
|
-
});
|
|
131
|
-
// Wire the sub-manager client's recovery callbacks to drive re-registration
|
|
132
|
-
// from this app server's serverSubscriptions whenever the RPC reconnects,
|
|
133
|
-
// and to process the resulting batched invalidate flush.
|
|
134
|
-
this.subManagerClient.setRecoveryDriver(this.runSubManagerRecovery.bind(this));
|
|
135
|
-
this.subManagerClient.setAffectedHandler(this.runAffectedHandler.bind(this));
|
|
136
|
-
this.httpServer = createServer();
|
|
137
|
-
this.wss = new WebSocketServer({
|
|
138
|
-
server: this.httpServer,
|
|
139
|
-
autoPong: true,
|
|
140
|
-
maxPayload: 100 * 1024 * 1024,
|
|
141
|
-
// backlog: //max pending connections queue (default: 511)
|
|
142
|
-
});
|
|
143
|
-
this.setupWebSocketHandlers();
|
|
144
|
-
this.subscribeToInstanceChannel();
|
|
145
|
-
}
|
|
146
|
-
/**
|
|
147
|
-
* Per-session heartbeat. Fires every `HEARTBEAT_INTERVAL_MS` and does
|
|
148
|
-
* both jobs on the same tick:
|
|
149
|
-
*
|
|
150
|
-
* 1. Inactivity check + ws-protocol ping (dead-TCP detection).
|
|
151
|
-
* 2. App-level `{type:"ping"}` if the channel has been idle for at
|
|
152
|
-
* least one heartbeat interval (client-watchdog refresh).
|
|
153
|
-
*
|
|
154
|
-
* The `lastSentAt` gate on (2) preserves the original "only ping when
|
|
155
|
-
* idle" semantics: a session producing sub:updates faster than the
|
|
156
|
-
* heartbeat interval never emits a redundant app-ping.
|
|
157
|
-
*/
|
|
158
|
-
startSessionHeartbeat(session) {
|
|
159
|
-
session.heartbeatTimer = setInterval(() => {
|
|
160
|
-
// (1) Dead-TCP detection.
|
|
161
|
-
if (Date.now() - session.lastReceivedAt > CLIENT_TIMEOUT_MS) {
|
|
162
|
-
logger.info(`ws inactivity → terminate session ${session.id}`);
|
|
163
|
-
try {
|
|
164
|
-
session.ws.terminate();
|
|
165
|
-
}
|
|
166
|
-
catch { /* close handler cleans up */ }
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
try {
|
|
170
|
-
session.ws.ping();
|
|
171
|
-
}
|
|
172
|
-
catch {
|
|
173
|
-
// socket already broken — close handler will clean up
|
|
174
|
-
}
|
|
175
|
-
// (2) Client-watchdog refresh, idle-only.
|
|
176
|
-
if (Date.now() - session.lastSentAt >= HEARTBEAT_INTERVAL_MS) {
|
|
177
|
-
this.sendMessage(session, { type: "ping" });
|
|
178
|
-
}
|
|
179
|
-
}, HEARTBEAT_INTERVAL_MS);
|
|
180
|
-
}
|
|
181
|
-
clearSessionTimers(session) {
|
|
182
|
-
if (session.heartbeatTimer) {
|
|
183
|
-
clearInterval(session.heartbeatTimer);
|
|
184
|
-
session.heartbeatTimer = null;
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
registerRouter(router) {
|
|
188
|
-
this.router = router;
|
|
189
|
-
this.contextRegistry = getContextRegistry(router.contextName);
|
|
190
|
-
}
|
|
191
|
-
async initDatabase() {
|
|
192
|
-
const db = this.createDatabase(this.config.database);
|
|
193
|
-
this.db = SupaliveDb.create({ db });
|
|
194
|
-
await this.bootstrapDbTypes();
|
|
195
|
-
}
|
|
196
|
-
/**
|
|
197
|
-
* Eagerly load column types into the apply-path cache at server start.
|
|
198
|
-
* In production, the result is cached in Redis under
|
|
199
|
-
* `supalive:schema:types:${SUPALIVE_SCHEMA_VERSION ?? "v1"}` so other
|
|
200
|
-
* instances starting cold can skip the pg_attribute round-trip. In any
|
|
201
|
-
* other env we go straight to PG every time so local schema iteration
|
|
202
|
-
* is never blocked by stale Redis state. Migrations should publish a
|
|
203
|
-
* `schema:reload` message (see {@link publishSchemaReload}) after
|
|
204
|
-
* applying DDL — every instance picks that up and re-bootstraps.
|
|
205
|
-
*/
|
|
206
|
-
async bootstrapDbTypes(forceReload = false) {
|
|
207
|
-
// TODO: remove this check if we needs mysql column types
|
|
208
|
-
if (this.db.impl.dbType === "mysql") {
|
|
209
|
-
// currently we don't use mysql column type
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
const inProd = process.env.NODE_ENV === "production";
|
|
213
|
-
const version = process.env.SUPALIVE_SCHEMA_VERSION ?? "v1";
|
|
214
|
-
const key = `supalive:schema:types:${version}`;
|
|
215
|
-
try {
|
|
216
|
-
await this.db.impl.bootstrapColumnTypes({
|
|
217
|
-
cacheKey: key,
|
|
218
|
-
redis: inProd && !forceReload ? this.cache : undefined,
|
|
219
|
-
});
|
|
220
|
-
logger.info({ columnsTypes: this.db.impl.columnTypes }, `Bootstrapped db column types (redis=${inProd}, key=${key})`);
|
|
221
|
-
}
|
|
222
|
-
catch (err) {
|
|
223
|
-
logger.error(err, "Failed to bootstrap db column types");
|
|
224
|
-
throw err;
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
createDatabase(config) {
|
|
228
|
-
if (config.type === "postgres") {
|
|
229
|
-
return new PgDatabase(logger.child({ database: "postgres" }), {
|
|
230
|
-
...config,
|
|
231
|
-
connectionString: config.connectionString,
|
|
232
|
-
min: config.minConnections ?? 0,
|
|
233
|
-
max: config.maxConnections ?? 20,
|
|
234
|
-
connectionTimeoutMillis: config.connectionTimeoutMillis ?? 15_000,
|
|
235
|
-
idleTimeoutMillis: config.idleTimeoutMillis ?? 30_000,
|
|
236
|
-
// server-enforced per-statement timeout; pg cancels the running
|
|
237
|
-
// query when it fires. 0 disables it.
|
|
238
|
-
statement_timeout: DB_QUERY_TIMEOUT_MS > 0 ? DB_QUERY_TIMEOUT_MS : undefined,
|
|
239
|
-
});
|
|
240
|
-
}
|
|
241
|
-
else {
|
|
242
|
-
return new MySqlDatabase(logger.child({ database: "mysql" }), {
|
|
243
|
-
...config,
|
|
244
|
-
connectionLimit: config.maxConnections ?? 20,
|
|
245
|
-
connectTimeout: config.connectionTimeoutMillis ?? 15_000,
|
|
246
|
-
// mysql2 pool: 0 = wait forever for a free conn (default). queueLimit
|
|
247
|
-
// bounds the wait queue length so backpressure surfaces as an error
|
|
248
|
-
// rather than unbounded growth.
|
|
249
|
-
queueLimit: config.queueLimit ?? 0,
|
|
250
|
-
// driver-side per-statement timeout applied uniformly in
|
|
251
|
-
// MySqlDatabase.query.
|
|
252
|
-
queryTimeoutMs: DB_QUERY_TIMEOUT_MS,
|
|
253
|
-
});
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
async start() {
|
|
257
|
-
await this.initDatabase();
|
|
258
|
-
return new Promise((resolve) => {
|
|
259
|
-
this.httpServer.listen(this.config.port, this.config.host, () => {
|
|
260
|
-
logger.info(`Server: ${this.instanceId} listening on ${this.config.host}:${this.config.port}`);
|
|
261
|
-
resolve();
|
|
262
|
-
});
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
|
-
async stop() {
|
|
266
|
-
this.wss.close();
|
|
267
|
-
for (const [ws] of this.sessions) {
|
|
268
|
-
ws.close(1001, "Server shutting down");
|
|
269
|
-
}
|
|
270
|
-
this.httpServer.close();
|
|
271
|
-
logger.info("Server stopped");
|
|
272
|
-
}
|
|
273
|
-
getStats() {
|
|
274
|
-
return {
|
|
275
|
-
instanceId: this.instanceId,
|
|
276
|
-
activeConnections: this.stats.activeConnections,
|
|
277
|
-
totalConnections: this.stats.totalConnections,
|
|
278
|
-
activeSubscriptions: this.stats.activeSubscriptions,
|
|
279
|
-
};
|
|
280
|
-
}
|
|
281
|
-
getInstanceId() {
|
|
282
|
-
return this.instanceId;
|
|
283
|
-
}
|
|
284
|
-
setupWebSocketHandlers() {
|
|
285
|
-
this.wss.on("connection", (ws, req) => {
|
|
286
|
-
this.handleConnection(ws, req).catch((err) => {
|
|
287
|
-
logger.error(err, "Connection handler error");
|
|
288
|
-
});
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
async handleConnection(ws, req) {
|
|
292
|
-
this.stats.totalConnections++;
|
|
293
|
-
this.stats.activeConnections++;
|
|
294
|
-
const session = {
|
|
295
|
-
id: uuidv4(),
|
|
296
|
-
ws,
|
|
297
|
-
serverCtx: {},
|
|
298
|
-
subIds: new Set(),
|
|
299
|
-
mutationQueue: [],
|
|
300
|
-
mutationInFlight: false,
|
|
301
|
-
actionInFlight: 0,
|
|
302
|
-
lastReceivedAt: Date.now(),
|
|
303
|
-
lastSentAt: Date.now(),
|
|
304
|
-
heartbeatTimer: null,
|
|
305
|
-
};
|
|
306
|
-
logger.info(`New session: ${session.id}`);
|
|
307
|
-
this.sessions.set(ws, session);
|
|
308
|
-
this.sendMessage(session, { type: "connected", sessionId: session.id });
|
|
309
|
-
// Any inbound frame (text, ws-ping, ws-pong) refreshes the liveness
|
|
310
|
-
// clock. This is the only thing keeping `CLIENT_TIMEOUT_MS` from
|
|
311
|
-
// tripping on a silent-but-live client, since the browser auto-pong
|
|
312
|
-
// is the only thing such a client emits.
|
|
313
|
-
ws.on("message", (data) => {
|
|
314
|
-
session.lastReceivedAt = Date.now();
|
|
315
|
-
this.handleMessage(session, data.toString()).catch((err) => {
|
|
316
|
-
logger.error(err, "Message handler error");
|
|
317
|
-
});
|
|
318
|
-
});
|
|
319
|
-
ws.on("ping", () => { session.lastReceivedAt = Date.now(); });
|
|
320
|
-
ws.on("pong", () => { session.lastReceivedAt = Date.now(); });
|
|
321
|
-
this.startSessionHeartbeat(session);
|
|
322
|
-
ws.on("close", (code) => {
|
|
323
|
-
logger.info(`Connection closed: ${session.id}, code: ${code}`);
|
|
324
|
-
this.clearSessionTimers(session);
|
|
325
|
-
this.handleDisconnect(session);
|
|
326
|
-
this.sessions.delete(ws);
|
|
327
|
-
this.stats.activeConnections--;
|
|
328
|
-
});
|
|
329
|
-
ws.on("error", (err) => {
|
|
330
|
-
logger.error(err, "Websocket error");
|
|
331
|
-
});
|
|
332
|
-
}
|
|
333
|
-
async handleMessage(session, message) {
|
|
334
|
-
try {
|
|
335
|
-
const rawMsg = JSON.parse(message);
|
|
336
|
-
const parseResult = ClientMessageSchema.safeParse(rawMsg);
|
|
337
|
-
if (!parseResult.success) {
|
|
338
|
-
this.sendError(session, "", "Invalid message format", "INVALID_MESSAGE");
|
|
339
|
-
return;
|
|
340
|
-
}
|
|
341
|
-
await this.processMessage(session, parseResult.data);
|
|
342
|
-
}
|
|
343
|
-
catch (err) {
|
|
344
|
-
logger.error(err, "Message handling error");
|
|
345
|
-
this.sendError(session, "", "Parse error", "PARSE_ERROR");
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
async processMessage(session, msg) {
|
|
349
|
-
switch (msg.type) {
|
|
350
|
-
case "auth":
|
|
351
|
-
await this.handleAuth(session, msg.data);
|
|
352
|
-
break;
|
|
353
|
-
case "call":
|
|
354
|
-
await this.handleCall(session, msg.id, msg.procedure, msg.input);
|
|
355
|
-
break;
|
|
356
|
-
case "subscribe":
|
|
357
|
-
await this.handleSubscribe(session, msg.id, msg.procedure, msg.input);
|
|
358
|
-
break;
|
|
359
|
-
case "unsubscribe":
|
|
360
|
-
await this.handleUnsubscribe(session, msg.subId);
|
|
361
|
-
break;
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
async handleAuth(session, data) {
|
|
365
|
-
if (!this.config.verifyAuth || !data) {
|
|
366
|
-
this.sendMessage(session, { type: "auth:success", userId: "anonymous" });
|
|
367
|
-
return;
|
|
368
|
-
}
|
|
369
|
-
try {
|
|
370
|
-
const authResult = await this.config.verifyAuth(data, session.id);
|
|
371
|
-
if (!authResult) {
|
|
372
|
-
this.sendMessage(session, { type: "auth:failed", message: "Invalid authentication" });
|
|
373
|
-
return;
|
|
374
|
-
}
|
|
375
|
-
session.serverCtx = authResult;
|
|
376
|
-
this.sendMessage(session, { type: "auth:success", userId: authResult.user.userId });
|
|
377
|
-
}
|
|
378
|
-
catch (e) {
|
|
379
|
-
if (e instanceof AuthenticationError) {
|
|
380
|
-
this.sendMessage(session, {
|
|
381
|
-
type: "auth:failed",
|
|
382
|
-
message: "Invalid authentication: " + e.message,
|
|
383
|
-
extra: e.extra,
|
|
384
|
-
});
|
|
385
|
-
}
|
|
386
|
-
else {
|
|
387
|
-
this.sendMessage(session, { type: "auth:failed", message: "Invalid authentication" });
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
/**
|
|
392
|
-
* Park a mutation on this session's FIFO queue and resolve when it
|
|
393
|
-
* eventually drains. The drainer is fire-and-forget: it picks up any
|
|
394
|
-
* newly enqueued op as long as one drain loop is running on this
|
|
395
|
-
* session. Callers (handleCall) own the response/error for their own
|
|
396
|
-
* op via the returned promise.
|
|
397
|
-
*/
|
|
398
|
-
enqueueMutation(session, procedure, validatedInput) {
|
|
399
|
-
return new Promise((resolve, reject) => {
|
|
400
|
-
const pending = {
|
|
401
|
-
procedure,
|
|
402
|
-
validatedInput,
|
|
403
|
-
resolve,
|
|
404
|
-
reject,
|
|
405
|
-
};
|
|
406
|
-
session.mutationQueue.push(pending);
|
|
407
|
-
this.drainMutationQueue(session).catch((err) => logger.error(err, "drainMutationQueue crashed"));
|
|
408
|
-
});
|
|
409
|
-
}
|
|
410
|
-
/**
|
|
411
|
-
* Drain the per-session mutation queue serially. Only one drain loop
|
|
412
|
-
* runs at a time per session — re-entrant calls bail immediately, the
|
|
413
|
-
* loop already in progress will pick up the new op before exiting.
|
|
414
|
-
*/
|
|
415
|
-
async drainMutationQueue(session) {
|
|
416
|
-
if (session.mutationInFlight)
|
|
417
|
-
return;
|
|
418
|
-
session.mutationInFlight = true;
|
|
419
|
-
try {
|
|
420
|
-
while (session.mutationQueue.length > 0) {
|
|
421
|
-
const op = session.mutationQueue.shift();
|
|
422
|
-
try {
|
|
423
|
-
const fn = op.procedure.fn;
|
|
424
|
-
const result = await this.db.mutation(async (ctx) => {
|
|
425
|
-
return fn({ db: ctx, serverCtx: session.serverCtx }, op.validatedInput);
|
|
426
|
-
});
|
|
427
|
-
op.resolve(result);
|
|
428
|
-
}
|
|
429
|
-
catch (err) {
|
|
430
|
-
op.reject(err);
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
finally {
|
|
435
|
-
session.mutationInFlight = false;
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
async handleCall(session, id, procedureName, input) {
|
|
439
|
-
if (!this.router) {
|
|
440
|
-
this.sendError(session, id, "Router not registered", "SERVER_ERROR");
|
|
441
|
-
return;
|
|
442
|
-
}
|
|
443
|
-
const procedure = this.contextRegistry.getProcedure(procedureName);
|
|
444
|
-
if (!procedure) {
|
|
445
|
-
this.sendError(session, id, `Procedure "${procedureName}" not found`, "NOT_FOUND");
|
|
446
|
-
return;
|
|
447
|
-
}
|
|
448
|
-
try {
|
|
449
|
-
const validatedInput = procedure.inputSchema.parse(input);
|
|
450
|
-
if (procedure.type === "query") {
|
|
451
|
-
const result = await this.db.query(async (ctx) => {
|
|
452
|
-
const fn = procedure.fn;
|
|
453
|
-
return fn({ db: ctx, serverCtx: session.serverCtx }, validatedInput);
|
|
454
|
-
});
|
|
455
|
-
this.sendMessage(session, {
|
|
456
|
-
type: "response",
|
|
457
|
-
id: id,
|
|
458
|
-
success: true,
|
|
459
|
-
data: result,
|
|
460
|
-
});
|
|
461
|
-
}
|
|
462
|
-
else if (procedure.type === "mutation") {
|
|
463
|
-
if (session.mutationQueue.length >= MAX_MUTATION_QUEUE_BUFFER) {
|
|
464
|
-
this.sendError(session, id, `Too many concurrent mutations on this connection (max ${MAX_MUTATION_QUEUE_BUFFER})`, "TOO_MANY_CONCURRENT_MUTATIONS");
|
|
465
|
-
return;
|
|
466
|
-
}
|
|
467
|
-
const mutationResult = await this.enqueueMutation(session, procedure, validatedInput);
|
|
468
|
-
this.sendMessage(session, {
|
|
469
|
-
type: "response",
|
|
470
|
-
id: id,
|
|
471
|
-
success: true,
|
|
472
|
-
});
|
|
473
|
-
await this.handleMutationCommit(mutationResult.writeSet, mutationResult.commitTs);
|
|
474
|
-
}
|
|
475
|
-
else if (procedure.type === "action") {
|
|
476
|
-
if (session.actionInFlight >= MAX_ACTION_CONCURRENCY) {
|
|
477
|
-
this.sendError(session, id, `Too many concurrent actions on this connection (max ${MAX_ACTION_CONCURRENCY})`, "TOO_MANY_CONCURRENT_ACTIONS");
|
|
478
|
-
return;
|
|
479
|
-
}
|
|
480
|
-
session.actionInFlight++;
|
|
481
|
-
let result;
|
|
482
|
-
try {
|
|
483
|
-
const fn = procedure.fn;
|
|
484
|
-
result = await fn({ db: this.db, serverCtx: session.serverCtx }, validatedInput);
|
|
485
|
-
}
|
|
486
|
-
finally {
|
|
487
|
-
session.actionInFlight--;
|
|
488
|
-
}
|
|
489
|
-
this.sendMessage(session, {
|
|
490
|
-
type: "response",
|
|
491
|
-
id: id,
|
|
492
|
-
success: true,
|
|
493
|
-
data: result,
|
|
494
|
-
});
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
catch (err) {
|
|
498
|
-
logger.error(err, `Error executing "${procedureName}"`);
|
|
499
|
-
this.sendError(session, id, err instanceof Error ? err.message : "Execution failed", "EXECUTION_ERROR");
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
async handleSubscribe(session, id, procedureName, input) {
|
|
503
|
-
if (!this.router) {
|
|
504
|
-
this.sendError(session, id, "Router not registered", "SERVER_ERROR");
|
|
505
|
-
return;
|
|
506
|
-
}
|
|
507
|
-
const procedure = this.contextRegistry.getProcedure(procedureName);
|
|
508
|
-
if (!procedure || procedure.type !== "query") {
|
|
509
|
-
this.sendError(session, id, `Procedure "${procedureName}" not found or not a query`, "NOT_FOUND");
|
|
510
|
-
return;
|
|
511
|
-
}
|
|
512
|
-
try {
|
|
513
|
-
const validatedInput = procedure.inputSchema.parse(input);
|
|
514
|
-
const queryIdentity = this.resolveQueryIdentity(procedure, session, validatedInput);
|
|
515
|
-
const inputJsonStable = stableStringify(validatedInput);
|
|
516
|
-
const subId = await generateSubscriptionId(procedureName, inputJsonStable, queryIdentity);
|
|
517
|
-
const cacheKey = await generateCacheKey(procedureName, inputJsonStable, queryIdentity);
|
|
518
|
-
const existingServerSub = this.serverSubscriptions.get(subId);
|
|
519
|
-
let data;
|
|
520
|
-
let dataHash;
|
|
521
|
-
let liveResult = null;
|
|
522
|
-
// track this session subscribed this subscription
|
|
523
|
-
session.subIds.add(subId);
|
|
524
|
-
if (existingServerSub) {
|
|
525
|
-
existingServerSub.subscribedTimes += 1;
|
|
526
|
-
const cached = await this.cache.getQueryCacheFor(existingServerSub.cacheKey);
|
|
527
|
-
if (cached) {
|
|
528
|
-
data = cached.data;
|
|
529
|
-
}
|
|
530
|
-
else {
|
|
531
|
-
liveResult = await this.executeLiveQuery(procedureName, validatedInput, session.serverCtx);
|
|
532
|
-
data = liveResult.data;
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
else {
|
|
536
|
-
const lastSnapshotTs = await this.db.impl.getLatestSnapshotTimestamp();
|
|
537
|
-
const subManagerResult = await this.subManagerClient.registerSubscription({
|
|
538
|
-
subId,
|
|
539
|
-
cacheKey,
|
|
540
|
-
queryName: procedureName,
|
|
541
|
-
args: validatedInput,
|
|
542
|
-
queryIdentity,
|
|
543
|
-
instanceName: this.instanceId,
|
|
544
|
-
lastSnapshotTs: lastSnapshotTs,
|
|
545
|
-
});
|
|
546
|
-
const cached = await this.cache.getQueryCacheFor(subManagerResult.cacheKey);
|
|
547
|
-
if (cached && !subManagerResult.recompute) {
|
|
548
|
-
data = cached.data;
|
|
549
|
-
}
|
|
550
|
-
else {
|
|
551
|
-
liveResult = await this.executeLiveQuery(procedureName, validatedInput, session.serverCtx);
|
|
552
|
-
data = liveResult.data;
|
|
553
|
-
}
|
|
554
|
-
this.serverSubscriptions.set(subId, {
|
|
555
|
-
subId,
|
|
556
|
-
cacheKey,
|
|
557
|
-
subscribedTimes: 1,
|
|
558
|
-
});
|
|
559
|
-
this.stats.activeSubscriptions++;
|
|
560
|
-
}
|
|
561
|
-
if (liveResult) {
|
|
562
|
-
const [updateResult] = await Promise.all([
|
|
563
|
-
this.subManagerClient.updateSubscriptionReadSet({
|
|
564
|
-
cacheKey,
|
|
565
|
-
subId,
|
|
566
|
-
lastSnapshotTs: liveResult.ts,
|
|
567
|
-
readSet: liveResult.readSet,
|
|
568
|
-
instanceName: this.instanceId,
|
|
569
|
-
}),
|
|
570
|
-
this.cache.setQueryCacheFor(cacheKey, data),
|
|
571
|
-
]);
|
|
572
|
-
// If the sub-manager just told us this subId isn't tracked (we hit
|
|
573
|
-
// a window between its register and a separate restart), our register
|
|
574
|
-
// above should have rebuilt the state — log loudly if not.
|
|
575
|
-
if (!updateResult.tracked) {
|
|
576
|
-
logger.warn(`updateSubscriptionReadSet returned tracked:false immediately after register for ${subId} ` +
|
|
577
|
-
`— possible sub-manager restart mid-subscribe`);
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
dataHash = await getHashOf(data);
|
|
581
|
-
// Hand the canonical subId back to the client so it doesn't need to
|
|
582
|
-
// recompute the hash locally (which would require pulling in wasm and
|
|
583
|
-
// wouldn't reflect per-procedure `queryIdentity` overrides).
|
|
584
|
-
this.sendMessage(session, { type: "response", id, success: true, subId });
|
|
585
|
-
this.sendMessage(session, { type: "sub:update", subId, data, dataHash });
|
|
586
|
-
logger.info(`Subscribed: ${subId} for session ${session.id}`);
|
|
587
|
-
}
|
|
588
|
-
catch (err) {
|
|
589
|
-
logger.error(err, `Error subscribing to "${procedureName}"`);
|
|
590
|
-
this.sendMessage(session, { type: "response", id, success: false });
|
|
591
|
-
this.sendError(session, id, err instanceof Error ? err.message : "Subscribe failed", "SUBSCRIBE_ERROR");
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
async handleUnsubscribe(session, subId) {
|
|
595
|
-
const serverSub = this.serverSubscriptions.get(subId);
|
|
596
|
-
if (!serverSub) {
|
|
597
|
-
session.subIds.delete(subId);
|
|
598
|
-
return;
|
|
599
|
-
}
|
|
600
|
-
serverSub.subscribedTimes -= 1;
|
|
601
|
-
if (serverSub.subscribedTimes <= 0) {
|
|
602
|
-
session.subIds.delete(subId);
|
|
603
|
-
this.serverSubscriptions.delete(subId);
|
|
604
|
-
this.stats.activeSubscriptions--;
|
|
605
|
-
try {
|
|
606
|
-
await this.subManagerClient.unregisterSubscription({ subId, instanceName: this.instanceId });
|
|
607
|
-
}
|
|
608
|
-
catch (err) {
|
|
609
|
-
logger.error(err, "Failed to unregister from sub-manager:");
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
logger.info(`Unsubscribed: ${subId} for session ${session.id}, remaining server subs: ${serverSub.subscribedTimes}`);
|
|
613
|
-
}
|
|
614
|
-
async handleDisconnect(session) {
|
|
615
|
-
logger.info(`Session disconnected: ${session.id}`);
|
|
616
|
-
let unregisterSubs = undefined;
|
|
617
|
-
for (const subId of session.subIds) {
|
|
618
|
-
const sub = this.serverSubscriptions.get(subId);
|
|
619
|
-
if (!sub)
|
|
620
|
-
continue;
|
|
621
|
-
sub.subscribedTimes -= 1;
|
|
622
|
-
if (sub.subscribedTimes <= 0) {
|
|
623
|
-
if (!unregisterSubs) {
|
|
624
|
-
unregisterSubs = [];
|
|
625
|
-
}
|
|
626
|
-
unregisterSubs.push(sub);
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
if (!unregisterSubs || unregisterSubs.length === 0)
|
|
630
|
-
return;
|
|
631
|
-
try {
|
|
632
|
-
await this.subManagerClient.unregisterSubscriptions(unregisterSubs.map((sub) => ({ subId: sub.subId, instanceName: this.instanceId })));
|
|
633
|
-
}
|
|
634
|
-
catch (err) {
|
|
635
|
-
logger.error(err, "Failed to unregister from sub-manager:");
|
|
636
|
-
}
|
|
637
|
-
for (const sub of unregisterSubs) {
|
|
638
|
-
// if still not subscribing
|
|
639
|
-
if (sub.subscribedTimes <= 0) {
|
|
640
|
-
this.serverSubscriptions.delete(sub.subId);
|
|
641
|
-
this.stats.activeSubscriptions--;
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
logger.info(`All Subscription sessions is disconnected: ${session.id}, subs: ${unregisterSubs.length}`);
|
|
645
|
-
}
|
|
646
|
-
/**
|
|
647
|
-
* Drives recovery on a sub-manager reconnect. Iterates this app server's
|
|
648
|
-
* `serverSubscriptions`, reads each subId's registration record from
|
|
649
|
-
* Redis (`sl:reg:<subId>`), and re-registers it with the sub-manager.
|
|
650
|
-
*
|
|
651
|
-
* • If the Redis record is missing, another app-server instance fully
|
|
652
|
-
* unregistered the sub during our disconnect. We drop it locally and
|
|
653
|
-
* send `sub:gone` to every session that was holding it.
|
|
654
|
-
* • If the sub-manager reports `recompute: true` (cache-metadata + commit
|
|
655
|
-
* log replay detected staleness), we kick a reexec for that sub.
|
|
656
|
-
*
|
|
657
|
-
* Returns after all subs have been processed. Only then does the
|
|
658
|
-
* SubManagerClient flush its buffered invalidate writeSets.
|
|
659
|
-
*/
|
|
660
|
-
async runSubManagerRecovery() {
|
|
661
|
-
const snapshot = Array.from(this.serverSubscriptions.values());
|
|
662
|
-
if (snapshot.length === 0) {
|
|
663
|
-
logger.info("Sub-manager recovery: no subscriptions to restore");
|
|
664
|
-
return;
|
|
665
|
-
}
|
|
666
|
-
logger.info(`Sub-manager recovery: restoring ${snapshot.length} subscription(s)`);
|
|
667
|
-
const lastSnapshotTs = await this.db.impl.getLatestSnapshotTimestamp();
|
|
668
|
-
const subIds = snapshot.map((e) => e.subId);
|
|
669
|
-
const recordsMap = await this.cache.fetchRegistrationRecordsBatch(subIds);
|
|
670
|
-
const registerParams = [];
|
|
671
|
-
for (const entry of snapshot) {
|
|
672
|
-
const record = recordsMap.get(entry.subId) ?? null;
|
|
673
|
-
if (!record) {
|
|
674
|
-
this.dropSubscriptionLocally(entry.subId, "registration not found after recovery");
|
|
675
|
-
continue;
|
|
676
|
-
}
|
|
677
|
-
registerParams.push({ entry, record });
|
|
678
|
-
}
|
|
679
|
-
const recompute = [];
|
|
680
|
-
const BATCH_SIZE = 50;
|
|
681
|
-
for (let i = 0; i < registerParams.length; i += BATCH_SIZE) {
|
|
682
|
-
const batch = registerParams.slice(i, i + BATCH_SIZE);
|
|
683
|
-
const params = batch.map(({ entry, record }) => ({
|
|
684
|
-
subId: entry.subId,
|
|
685
|
-
cacheKey: entry.cacheKey,
|
|
686
|
-
queryName: record.queryName,
|
|
687
|
-
args: record.args,
|
|
688
|
-
queryIdentity: record.queryIdentity,
|
|
689
|
-
instanceName: this.instanceId,
|
|
690
|
-
lastSnapshotTs,
|
|
691
|
-
}));
|
|
692
|
-
try {
|
|
693
|
-
const results = await this.subManagerClient.registerSubscriptionBatch(params);
|
|
694
|
-
for (let j = 0; j < batch.length; j++) {
|
|
695
|
-
const { entry, record } = batch[j];
|
|
696
|
-
const result = results[j];
|
|
697
|
-
if (result.recompute) {
|
|
698
|
-
recompute.push({
|
|
699
|
-
subId: entry.subId,
|
|
700
|
-
queryName: record.queryName,
|
|
701
|
-
args: record.args,
|
|
702
|
-
cacheKey: entry.cacheKey,
|
|
703
|
-
notifyInstances: [],
|
|
704
|
-
});
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
|
-
}
|
|
708
|
-
catch (err) {
|
|
709
|
-
logger.error(err, `Sub-manager recovery: failed to batch register ${i}-${i + batch.length}`);
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
await Promise.allSettled(recompute.map((a) => this.reexecuteAffectedQuery(a)));
|
|
713
|
-
logger.info(`Sub-manager recovery: done (${recompute.length} reexec'd)`);
|
|
714
|
-
}
|
|
715
|
-
async runAffectedHandler(affected) {
|
|
716
|
-
await Promise.allSettled(affected.map((a) => this.reexecuteAffectedQuery(a)));
|
|
717
|
-
}
|
|
718
|
-
/**
|
|
719
|
-
* Removes a subscription from this app server's state and notifies every
|
|
720
|
-
* session that was holding it. Used during recovery when the central
|
|
721
|
-
* registration record is gone, or after a `tracked: false` reply from the
|
|
722
|
-
* sub-manager that we can't reconcile.
|
|
723
|
-
*/
|
|
724
|
-
dropSubscriptionLocally(subId, reason) {
|
|
725
|
-
const serverSub = this.serverSubscriptions.get(subId);
|
|
726
|
-
let notified = 0;
|
|
727
|
-
if (serverSub) {
|
|
728
|
-
this.serverSubscriptions.delete(subId);
|
|
729
|
-
this.stats.activeSubscriptions = Math.max(0, this.stats.activeSubscriptions - 1);
|
|
730
|
-
for (const [, session] of this.sessions) {
|
|
731
|
-
if (session.subIds.has(subId)) {
|
|
732
|
-
notified++;
|
|
733
|
-
session.subIds.delete(subId);
|
|
734
|
-
this.sendMessage(session, { type: "sub:gone", subId, reason });
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
logger.warn(`Dropped subscription ${subId} locally (${reason}); notified ${notified} session(s)`);
|
|
739
|
-
}
|
|
740
|
-
async handleMutationCommit(writeSet, commitTs) {
|
|
741
|
-
if (writeSet.length === 0)
|
|
742
|
-
return;
|
|
743
|
-
const tables = [...new Set(writeSet.map((w) => w.table))];
|
|
744
|
-
logger.debug(`Mutation committed for tables: [${tables.join(", ")}]`);
|
|
745
|
-
try {
|
|
746
|
-
const result = await this.subManagerClient.invalidateWriteset({ writeSet });
|
|
747
|
-
logger.debug({ affected: result }, "Sub-managers invalidation result");
|
|
748
|
-
if (result?.affected?.length > 0) {
|
|
749
|
-
await Promise.allSettled(result.affected.map((affected) => this.dispatchAffected(affected, commitTs)));
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
catch (err) {
|
|
753
|
-
logger.error(err, "Failed to notify sub-managers");
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
/**
|
|
757
|
-
* Route an affected sub to whichever instance can actually run the
|
|
758
|
-
* recompute:
|
|
759
|
-
* • If this instance has a local session for the sub, recompute here
|
|
760
|
-
* (fast path) and broadcast the result to every other instance.
|
|
761
|
-
* • Otherwise broadcast a `recompute-race` to every instance in
|
|
762
|
-
* `notifyInstances`. Each receiver tries to acquire a Redis lock
|
|
763
|
-
* keyed by (subId, commitTs); the winner recomputes and publishes
|
|
764
|
-
* an `update` via the normal broadcast path, losers no-op.
|
|
765
|
-
*
|
|
766
|
-
* If `notifyInstances` is empty (the sub has no live holders), we drop
|
|
767
|
-
* the message — the next subscribe will recompute fresh state.
|
|
768
|
-
*/
|
|
769
|
-
async dispatchAffected(affected, commitTs) {
|
|
770
|
-
if (this.hasLocalSessionFor(affected.subId)) {
|
|
771
|
-
await this.reexecuteAffectedQuery(affected);
|
|
772
|
-
return;
|
|
773
|
-
}
|
|
774
|
-
if (affected.notifyInstances.length === 0) {
|
|
775
|
-
logger.debug(`dispatchAffected: no live instances hold ${affected.subId} — dropping`);
|
|
776
|
-
return;
|
|
777
|
-
}
|
|
778
|
-
await this.publishRecomputeRace(affected, commitTs);
|
|
779
|
-
}
|
|
780
|
-
hasLocalSessionFor(subId) {
|
|
781
|
-
for (const [, session] of this.sessions) {
|
|
782
|
-
if (session.subIds.has(subId))
|
|
783
|
-
return true;
|
|
784
|
-
}
|
|
785
|
-
return false;
|
|
786
|
-
}
|
|
787
|
-
async publishRecomputeRace(affected, commitTs) {
|
|
788
|
-
const payload = supaliveStringify({
|
|
789
|
-
type: "recompute-race",
|
|
790
|
-
subId: affected.subId,
|
|
791
|
-
cacheKey: affected.cacheKey,
|
|
792
|
-
queryName: affected.queryName,
|
|
793
|
-
args: affected.args,
|
|
794
|
-
notifyInstances: affected.notifyInstances,
|
|
795
|
-
commitTs: commitTs.toString(),
|
|
796
|
-
originInstance: this.instanceId,
|
|
797
|
-
});
|
|
798
|
-
const publishPromises = affected.notifyInstances.map(async (instance) => {
|
|
799
|
-
if (instance === this.instanceId)
|
|
800
|
-
return;
|
|
801
|
-
const channel = `sub:instance:${instance}`;
|
|
802
|
-
try {
|
|
803
|
-
await redisRetryPolicy.execute(() => this.cache.redis.publish(channel, payload));
|
|
804
|
-
}
|
|
805
|
-
catch (err) {
|
|
806
|
-
logger.error(err, `Failed to publish recompute-race to ${channel} after many retries`);
|
|
807
|
-
}
|
|
808
|
-
});
|
|
809
|
-
await Promise.all(publishPromises);
|
|
810
|
-
}
|
|
811
|
-
async reexecuteAffectedQuery(affected) {
|
|
812
|
-
let foundSession = null;
|
|
813
|
-
for (const [, session] of this.sessions) {
|
|
814
|
-
if (session.subIds.has(affected.subId)) {
|
|
815
|
-
foundSession = session;
|
|
816
|
-
break;
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
if (!foundSession) {
|
|
820
|
-
// Should only happen if every local session unsubscribed between
|
|
821
|
-
// dispatchAffected's check and now. dispatchAffected forwards to
|
|
822
|
-
// another instance when no local session exists, so we'd lose the
|
|
823
|
-
// update for everyone in this rare race. Accept the gap.
|
|
824
|
-
logger.debug(`Session not found for subId: ${affected.subId}`);
|
|
825
|
-
return;
|
|
826
|
-
}
|
|
827
|
-
try {
|
|
828
|
-
const liveResult = await this.executeLiveQuery(affected.queryName, affected.args, foundSession.serverCtx);
|
|
829
|
-
const dataHash = await getHashOf(liveResult.data);
|
|
830
|
-
const [updateResult] = await Promise.all([
|
|
831
|
-
this.subManagerClient.updateSubscriptionReadSet({
|
|
832
|
-
cacheKey: affected.cacheKey,
|
|
833
|
-
subId: affected.subId,
|
|
834
|
-
lastSnapshotTs: liveResult.ts,
|
|
835
|
-
readSet: liveResult.readSet,
|
|
836
|
-
instanceName: this.instanceId,
|
|
837
|
-
}),
|
|
838
|
-
this.cache.setQueryCacheFor(affected.cacheKey, liveResult.data),
|
|
839
|
-
]);
|
|
840
|
-
// `tracked: false` means the sub-manager has no record of this subId
|
|
841
|
-
// (it restarted before we recovered, or a concurrent unregister wiped
|
|
842
|
-
// it). Drop the sub locally and let clients re-subscribe.
|
|
843
|
-
if (!updateResult.tracked) {
|
|
844
|
-
this.dropSubscriptionLocally(affected.subId, "no longer tracked by sub-manager");
|
|
845
|
-
return;
|
|
846
|
-
}
|
|
847
|
-
// find any session with same subId and send update
|
|
848
|
-
for (const [, session] of this.sessions) {
|
|
849
|
-
if (session.subIds.has(affected.subId)) {
|
|
850
|
-
this.sendMessage(session, {
|
|
851
|
-
type: "sub:update",
|
|
852
|
-
subId: affected.subId,
|
|
853
|
-
data: liveResult.data,
|
|
854
|
-
dataHash,
|
|
855
|
-
});
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
if (affected.notifyInstances.length > 0) {
|
|
859
|
-
await this.publishSubscriptionUpdate(affected.subId, liveResult.data, dataHash, affected.notifyInstances);
|
|
860
|
-
}
|
|
861
|
-
logger.debug(`Re-executed affected query for subId: ${affected.subId}`);
|
|
862
|
-
}
|
|
863
|
-
catch (err) {
|
|
864
|
-
logger.error(err, `Failed to re-execute query for subId ${affected.subId}`);
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
async executeLiveQuery(procedureName, input, serverCtx) {
|
|
868
|
-
const procedure = this.contextRegistry.getProcedure(procedureName);
|
|
869
|
-
if (!procedure || procedure.type !== "query") {
|
|
870
|
-
throw new Error(`Procedure not defined: ${procedureName}`);
|
|
871
|
-
}
|
|
872
|
-
return this.db.liveQuery(async (ctx) => {
|
|
873
|
-
const fn = procedure.fn;
|
|
874
|
-
return fn({ db: ctx, serverCtx }, input);
|
|
875
|
-
});
|
|
876
|
-
}
|
|
877
|
-
/**
|
|
878
|
-
* Resolve the segmentation key used in the subId/cacheKey hash for a
|
|
879
|
-
* query subscription. Priority:
|
|
880
|
-
*
|
|
881
|
-
* 1. Procedure-level `queryIdentity` override:
|
|
882
|
-
* - `false` → identity = `ANONYMOUS_IDENTITY` (cache shared across users)
|
|
883
|
-
* - `string` → that literal
|
|
884
|
-
* - `function` → ctx + input → string | null | undefined
|
|
885
|
-
* 2. `config.getUserId(serverCtx)` → falls back to ANONYMOUS_IDENTITY if it
|
|
886
|
-
* returns null/undefined or isn't configured.
|
|
887
|
-
*/
|
|
888
|
-
resolveQueryIdentity(procedure, session, input) {
|
|
889
|
-
const override = procedure.queryIdentity;
|
|
890
|
-
if (override !== undefined) {
|
|
891
|
-
if (override === false)
|
|
892
|
-
return ANONYMOUS_IDENTITY;
|
|
893
|
-
if (typeof override === "string")
|
|
894
|
-
return override;
|
|
895
|
-
if (typeof override === "function") {
|
|
896
|
-
const value = override(session.serverCtx, input);
|
|
897
|
-
if (value === null || value === undefined)
|
|
898
|
-
return ANONYMOUS_IDENTITY;
|
|
899
|
-
return value;
|
|
900
|
-
}
|
|
901
|
-
}
|
|
902
|
-
if (!this.config.getUserId)
|
|
903
|
-
return ANONYMOUS_IDENTITY;
|
|
904
|
-
return this.config.getUserId(session.serverCtx) ?? ANONYMOUS_IDENTITY;
|
|
905
|
-
}
|
|
906
|
-
sendMessage(session, message) {
|
|
907
|
-
if (session.ws.readyState !== WebSocket.OPEN)
|
|
908
|
-
return;
|
|
909
|
-
session.ws.send(supaliveStringify(message));
|
|
910
|
-
// Mark the channel as having had recent outbound traffic. The
|
|
911
|
-
// heartbeat tick uses this to skip the idle-only app-level ping
|
|
912
|
-
// for busy sessions (no point pinging a client that just got a
|
|
913
|
-
// sub:update).
|
|
914
|
-
session.lastSentAt = Date.now();
|
|
915
|
-
}
|
|
916
|
-
sendMessageRaw(session, message) {
|
|
917
|
-
if (session.ws.readyState === WebSocket.OPEN) {
|
|
918
|
-
session.ws.send(message);
|
|
919
|
-
}
|
|
920
|
-
}
|
|
921
|
-
sendError(session, id, error, code) {
|
|
922
|
-
this.sendMessage(session, { type: "response", id, success: false, error, code });
|
|
923
|
-
}
|
|
924
|
-
async subscribeToInstanceChannel() {
|
|
925
|
-
const instanceChannel = `sub:instance:${this.instanceId}`;
|
|
926
|
-
const schemaChannel = SCHEMA_RELOAD_CHANNEL;
|
|
927
|
-
await this.redisSub.subscribe(instanceChannel, schemaChannel);
|
|
928
|
-
this.redisSub.on("message", (channel, message) => {
|
|
929
|
-
if (channel === instanceChannel) {
|
|
930
|
-
this.handleRemoteInstanceMessage(message).catch((err) => {
|
|
931
|
-
logger.error(err, "Error handling remote instance message");
|
|
932
|
-
});
|
|
933
|
-
}
|
|
934
|
-
else if (channel === schemaChannel) {
|
|
935
|
-
this.handleSchemaReload().catch((err) => {
|
|
936
|
-
logger.error(err, "Error handling schema reload");
|
|
937
|
-
});
|
|
938
|
-
}
|
|
939
|
-
});
|
|
940
|
-
this.redisSub.on("error", (err) => logger.error(err, `Subscriber error for channels: ${instanceChannel}, ${schemaChannel}`));
|
|
941
|
-
logger.info(`Subscribed to Redis channels: ${instanceChannel}, ${schemaChannel}`);
|
|
942
|
-
}
|
|
943
|
-
/**
|
|
944
|
-
* React to a `schema:reload` broadcast: invalidate the in-memory column
|
|
945
|
-
* type cache for the named tables (or all if omitted) and re-bootstrap
|
|
946
|
-
* from Postgres so subsequent batched writes see the fresh types. Skips
|
|
947
|
-
* messages published by this instance (we already have the new types).
|
|
948
|
-
*/
|
|
949
|
-
async handleSchemaReload() {
|
|
950
|
-
logger.info(`Schema reload request received in instance ${this.instanceId} — reloading schema...`);
|
|
951
|
-
try {
|
|
952
|
-
await sleep(1000 + (Math.random() * 5000)); // jitter to avoid thundering herd if many instances
|
|
953
|
-
await this.bootstrapDbTypes(true);
|
|
954
|
-
logger.info(`Schema reload applied in instance ${this.instanceId}`);
|
|
955
|
-
}
|
|
956
|
-
catch (err) {
|
|
957
|
-
logger.error(err, "Failed to reload schema");
|
|
958
|
-
return;
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
|
-
/**
|
|
962
|
-
* Broadcast a schema-reload signal to every server instance (including
|
|
963
|
-
* this one — the handler skips self-originated messages, so calling
|
|
964
|
-
* after a local migration is safe). Pass `tables` to scope the reload;
|
|
965
|
-
* omit to flush everyone's cache.
|
|
966
|
-
*/
|
|
967
|
-
async publishSchemaReload(tables) {
|
|
968
|
-
const payload = JSON.stringify({
|
|
969
|
-
type: "schema:reload",
|
|
970
|
-
tables,
|
|
971
|
-
originInstance: this.instanceId,
|
|
972
|
-
});
|
|
973
|
-
await redisRetryPolicy.execute(() => this.cache.redis.publish(SCHEMA_RELOAD_CHANNEL, payload));
|
|
974
|
-
}
|
|
975
|
-
async handleRemoteInstanceMessage(message) {
|
|
976
|
-
let parsed;
|
|
977
|
-
try {
|
|
978
|
-
parsed = RemoteSubscriptionMessageSchema.parse(JSON.parse(message));
|
|
979
|
-
}
|
|
980
|
-
catch (err) {
|
|
981
|
-
logger.error(err, "Failed to parse remote instance message");
|
|
982
|
-
return;
|
|
983
|
-
}
|
|
984
|
-
if (parsed.originInstance === this.instanceId)
|
|
985
|
-
return;
|
|
986
|
-
if (parsed.type === "update") {
|
|
987
|
-
for (const [, session] of this.sessions) {
|
|
988
|
-
if (!session.subIds.has(parsed.subId))
|
|
989
|
-
continue;
|
|
990
|
-
const subEntry = this.serverSubscriptions.get(parsed.subId);
|
|
991
|
-
if (!subEntry)
|
|
992
|
-
continue;
|
|
993
|
-
this.sendMessage(session, {
|
|
994
|
-
type: "sub:update",
|
|
995
|
-
subId: parsed.subId,
|
|
996
|
-
data: parsed.data,
|
|
997
|
-
dataHash: parsed.dataHash,
|
|
998
|
-
});
|
|
999
|
-
}
|
|
1000
|
-
return;
|
|
1001
|
-
}
|
|
1002
|
-
// type === "recompute-race" — writer didn't hold the sub locally and
|
|
1003
|
-
// broadcast a race hint to every notifyInstance. The first to acquire
|
|
1004
|
-
// the lock recomputes; the rest no-op.
|
|
1005
|
-
await this.handleRemoteRecomputeRace(parsed);
|
|
1006
|
-
}
|
|
1007
|
-
async handleRemoteRecomputeRace(req) {
|
|
1008
|
-
if (!this.hasLocalSessionFor(req.subId)) {
|
|
1009
|
-
// We're in notifyInstances per sub-manager but the sub has since
|
|
1010
|
-
// been dropped here. Another holder may still pick it up — no warn.
|
|
1011
|
-
return;
|
|
1012
|
-
}
|
|
1013
|
-
const won = await this.cache.tryAcquireRecomputeLock(this.instanceId, req.subId, req.commitTs, RECOMPUTE_LOCK_TTL_SECONDS);
|
|
1014
|
-
if (!won) {
|
|
1015
|
-
logger.debug(`recompute-race: lost lock for ${req.subId}@${req.commitTs}, waiting for winner's update`);
|
|
1016
|
-
return;
|
|
1017
|
-
}
|
|
1018
|
-
await this.reexecuteAffectedQuery({
|
|
1019
|
-
subId: req.subId,
|
|
1020
|
-
queryName: req.queryName,
|
|
1021
|
-
args: req.args,
|
|
1022
|
-
cacheKey: req.cacheKey,
|
|
1023
|
-
notifyInstances: req.notifyInstances,
|
|
1024
|
-
});
|
|
1025
|
-
}
|
|
1026
|
-
async publishSubscriptionUpdate(subId, data, dataHash, notifyInstances) {
|
|
1027
|
-
const payload = supaliveStringify({
|
|
1028
|
-
type: "update",
|
|
1029
|
-
subId,
|
|
1030
|
-
data,
|
|
1031
|
-
dataHash,
|
|
1032
|
-
originInstance: this.instanceId,
|
|
1033
|
-
});
|
|
1034
|
-
const publishPromises = notifyInstances.map(async (instance) => {
|
|
1035
|
-
if (instance === this.instanceId)
|
|
1036
|
-
return;
|
|
1037
|
-
const channel = `sub:instance:${instance}`;
|
|
1038
|
-
try {
|
|
1039
|
-
await redisRetryPolicy.execute(() => this.cache.redis.publish(channel, payload));
|
|
1040
|
-
}
|
|
1041
|
-
catch (err) {
|
|
1042
|
-
logger.error(err, `Failed to publish to ${channel} after many retries`);
|
|
1043
|
-
}
|
|
1044
|
-
});
|
|
1045
|
-
await Promise.all(publishPromises);
|
|
1046
|
-
}
|
|
1047
|
-
}
|
|
1048
|
-
//# sourceMappingURL=supalive-server.js.map
|