Skip to content

Commit 6b1abd2

Browse files
authored
feat: supports adapter high availability (#117)
1 parent a83dce0 commit 6b1abd2

22 files changed

Lines changed: 1292 additions & 22 deletions

.github/grype-sbom.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
ignore:
2+
- vulnerability: GHSA-w5hq-g745-h8pq
3+
package:
4+
name: uuid
5+
type: npm

.github/workflows/audit.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ jobs:
2020

2121
- name: Audit check
2222
working-directory: nodejs
23-
run: npx audit-ci --moderate --allowlist GHSA-f886-m6hf-6m8v
23+
run: npx audit-ci --moderate --allowlist GHSA-f886-m6hf-6m8v --allowlist GHSA-w5hq-g745-h8pq
2424

2525
- name: License check
2626
working-directory: nodejs

.github/workflows/build.yml

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,31 @@ jobs:
3939
cd TDengine
4040
mkdir debug
4141
cd debug
42-
cmake .. -DBUILD_HTTP=false -DBUILD_JDBC=false -DBUILD_TOOLS=false -DBUILD_TEST=off -DBUILD_DEPENDENCY_TESTS=false
42+
cmake .. -DBUILD_JDBC=false -DBUILD_TOOLS=false -DBUILD_TEST=off -DBUILD_DEPENDENCY_TESTS=false -DBUILD_CONTRIB=ON
4343
make -j 4
4444
sudo make install
4545
which taosd
46-
which taosadapter
46+
47+
- name: Checkout taosadapter
48+
uses: actions/checkout@v4
49+
with:
50+
repository: "taosdata/taosadapter"
51+
path: "taosadapter"
52+
ref: "main"
53+
54+
- name: Set up Go
55+
uses: actions/setup-go@v6
56+
with:
57+
go-version: "1.25.10"
58+
cache-dependency-path: taosadapter/go.sum
59+
60+
- name: Build and install taosadapter
61+
run: |
62+
cd taosadapter
63+
go mod download
64+
go build -o taosadapter
65+
sudo install -m 755 taosadapter /bin/taosadapter
66+
taosadapter --version
4767
4868
- name: Start taosd
4969
run: nohup sudo taosd &

.github/workflows/sbom.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,5 @@ jobs:
3030
with:
3131
sbom: "${{ env.SBOM_FILENAME }}"
3232
cache-db: true
33+
config: ".github/grype-sbom.yaml"
3334
output-format: "table"

nodejs/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

nodejs/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@tdengine/websocket",
3-
"version": "3.4.0",
3+
"version": "3.5.0",
44
"description": "The websocket Node.js connector for TDengine. TDengine versions 3.3.2.0 and above are recommended to use this connector.",
55
"source": "index.ts",
66
"main": "lib/index.js",
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { v4 as uuidv4 } from "uuid";
2+
import { Address, mergeAddresses } from "../common/dsn";
3+
import logger from "../common/log";
4+
5+
export class Cluster {
6+
readonly id: string;
7+
private _addresses: readonly Address[];
8+
9+
constructor(addresses: Address[]) {
10+
this.id = uuidv4();
11+
this._addresses = Cluster.freezeAddresses(addresses);
12+
}
13+
14+
get addresses(): readonly Address[] {
15+
return this._addresses;
16+
}
17+
18+
addAddresses(addresses: Address[]): void {
19+
const merged = mergeAddresses([...this._addresses], addresses);
20+
this._addresses = Cluster.freezeAddresses(merged);
21+
}
22+
23+
private static freezeAddresses(addresses: Address[]): readonly Address[] {
24+
return Object.freeze(
25+
addresses.map((address) =>
26+
Object.freeze(new Address(address.host, address.port))
27+
)
28+
);
29+
}
30+
}
31+
32+
export class ClusterRegistry {
33+
private static _instance?: ClusterRegistry;
34+
private endpointToCluster: Map<string, Cluster> = new Map();
35+
36+
private constructor() { }
37+
38+
public static instance(): ClusterRegistry {
39+
if (!ClusterRegistry._instance) {
40+
ClusterRegistry._instance = new ClusterRegistry();
41+
}
42+
return ClusterRegistry._instance;
43+
}
44+
45+
private endpointKey(address: Address): string {
46+
return `${address.host}:${address.port}`;
47+
}
48+
49+
private collectMatchedClusters(addresses: Address[]): Map<string, Cluster> {
50+
const matchedClusters = new Map<string, Cluster>();
51+
for (const address of addresses) {
52+
const cluster = this.endpointToCluster.get(this.endpointKey(address));
53+
if (!cluster) {
54+
continue;
55+
}
56+
matchedClusters.set(cluster.id, cluster);
57+
}
58+
return matchedClusters;
59+
}
60+
61+
public getOrCreateCluster(seeds: Address[]): Cluster | null {
62+
const matchedClusters = this.collectMatchedClusters(seeds);
63+
if (matchedClusters.size > 1) {
64+
logger.warn(
65+
"Adapter HA: seed addresses span multiple known clusters, " +
66+
"skipping expansion. Ensure all seeds belong to the same cluster."
67+
);
68+
return null;
69+
}
70+
if (matchedClusters.size === 1) {
71+
return matchedClusters.values().next().value as Cluster;
72+
}
73+
74+
const cluster = new Cluster(seeds);
75+
for (const seed of seeds) {
76+
this.endpointToCluster.set(this.endpointKey(seed), cluster);
77+
}
78+
return cluster;
79+
}
80+
81+
public updateCluster(discovered: Address[]): void {
82+
if (discovered.length === 0) {
83+
return;
84+
}
85+
86+
const matchedClusters = this.collectMatchedClusters(discovered);
87+
if (matchedClusters.size === 0) {
88+
return;
89+
}
90+
91+
if (matchedClusters.size > 1) {
92+
logger.warn(
93+
"Adapter HA: discovered endpoints match multiple known clusters, skipping update."
94+
);
95+
return;
96+
}
97+
98+
const cluster = matchedClusters.values().next().value as Cluster;
99+
cluster.addAddresses(discovered);
100+
for (const address of discovered) {
101+
this.endpointToCluster.set(this.endpointKey(address), cluster);
102+
}
103+
}
104+
105+
public expandEndpoints(seeds: Address[]): Address[] {
106+
let matchedCluster: Cluster | null = null;
107+
108+
for (const seed of seeds) {
109+
const cluster = this.endpointToCluster.get(this.endpointKey(seed));
110+
if (!cluster) {
111+
continue;
112+
}
113+
114+
if (matchedCluster === null) {
115+
matchedCluster = cluster;
116+
continue;
117+
}
118+
119+
if (matchedCluster.id !== cluster.id) {
120+
logger.warn(
121+
"Adapter HA: seed addresses span multiple known clusters, " +
122+
"skipping expansion. Ensure all seeds belong to the same cluster."
123+
);
124+
return seeds.map((seedAddress) =>
125+
new Address(seedAddress.host, seedAddress.port)
126+
);
127+
}
128+
}
129+
130+
if (!matchedCluster) {
131+
return seeds.map((seedAddress) =>
132+
new Address(seedAddress.host, seedAddress.port)
133+
);
134+
}
135+
136+
return mergeAddresses(seeds, [...matchedCluster.addresses]);
137+
}
138+
}

nodejs/src/client/wsClient.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,10 @@ export class WsClient {
4848
}
4949
}
5050

51-
private buildConnMessage(database?: string | undefined | null) {
51+
private buildConnMessage(
52+
database?: string | undefined | null,
53+
listInstances?: boolean
54+
) {
5255
return {
5356
action: "conn",
5457
args: {
@@ -61,6 +64,7 @@ export class WsClient {
6164
...(this._userApp && { app: this._userApp }),
6265
...(this._userIp && { ip: this._userIp }),
6366
...(this._bearerToken && { bearer_token: this._bearerToken }),
67+
...(listInstances !== undefined && { list_instances: listInstances }),
6468
},
6569
};
6670
}
@@ -140,7 +144,8 @@ export class WsClient {
140144
}
141145

142146
async connect(database?: string | undefined | null): Promise<void> {
143-
const connMsg = this.buildConnMessage(database);
147+
const listInstances = this._dsn.isAdapterHA() ? true : undefined;
148+
const connMsg = this.buildConnMessage(database, listInstances);
144149
const normalizedDatabase = this.normalizeConnectedDatabase(database ?? null);
145150
if (logger.isDebugEnabled()) {
146151
logger.debug("[wsClient.connect.connMsg]===>" + JSONBig.stringify(connMsg, (key, value) =>
@@ -165,6 +170,9 @@ export class WsClient {
165170
if (result.msg.code == 0) {
166171
this._connectedDatabase = normalizedDatabase;
167172
this._wsConnector.markSessionReady();
173+
if (result.msg.list_instances) {
174+
this._wsConnector.mergeDiscoveredEndpoints(result.msg.list_instances);
175+
}
168176
return;
169177
}
170178
await this.close();
@@ -320,6 +328,14 @@ export class WsClient {
320328
return this.getWsConnector().getReconnectRetries();
321329
}
322330

331+
isAdapterHA(): boolean {
332+
return this._dsn.isAdapterHA();
333+
}
334+
335+
mergeDiscoveredEndpoints(instances: string[]): void {
336+
this.getWsConnector().mergeDiscoveredEndpoints(instances);
337+
}
338+
323339
async sendMsg(message: string): Promise<any> {
324340
logger.debug("[wsClient.sendMsg]===>" + message);
325341
return this.getWsConnector().sendMsg(message);

nodejs/src/client/wsConnector.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { ICloseEvent, w3cwebsocket } from "websocket";
2-
import { Address, Dsn } from "../common/dsn";
2+
import {
3+
Address,
4+
Dsn,
5+
mergeAddresses,
6+
parseDiscoveredEndpoints
7+
} from "../common/dsn";
38
import { AddressConnectionTracker } from "../common/addressConnectionTracker";
49
import {
510
ErrorCode,
@@ -12,6 +17,7 @@ import {
1217
maskSensitiveForLog,
1318
maskUrlForLog
1419
} from "../common/utils";
20+
import { ClusterRegistry } from "./clusterRegistry";
1521

1622
interface InflightRequest {
1723
reqId: bigint;
@@ -179,6 +185,16 @@ export class WebSocketConnector {
179185
}
180186
this._poolKey = poolKey;
181187
this._dsn = dsn;
188+
if (dsn.isAdapterHA()) {
189+
const expanded = ClusterRegistry.instance().expandEndpoints(this._dsn.addresses);
190+
if (expanded.length > this._dsn.addresses.length) {
191+
logger.info(
192+
"Adapter HA: expanded seed endpoints from registry, " +
193+
`${this._dsn.addresses.length} -> ${expanded.length}`
194+
);
195+
this._dsn.addresses = expanded;
196+
}
197+
}
182198
this._currentAddress = this.selectLeastConnectedAddress();
183199
this._retryConfig = RetryConfig.fromDsn(dsn);
184200
this._inflightStore = new InflightRequestStore();
@@ -592,6 +608,24 @@ export class WebSocketConnector {
592608
this._sessionReady = true;
593609
}
594610

611+
public mergeDiscoveredEndpoints(instances: string[]): void {
612+
if (!this._dsn.isAdapterHA() || !instances || instances.length === 0) {
613+
return;
614+
}
615+
const discovered = parseDiscoveredEndpoints(instances);
616+
ClusterRegistry.instance().updateCluster(discovered);
617+
const merged = mergeAddresses(this._dsn.addresses, discovered);
618+
if (merged.length <= this._dsn.addresses.length) {
619+
return;
620+
}
621+
622+
const newCount = merged.length - this._dsn.addresses.length;
623+
this._dsn.addresses = merged;
624+
logger.info(
625+
`Adapter HA: discovered ${newCount} new endpoint(s), total ${merged.length}`
626+
);
627+
}
628+
595629
private async recoverSessionContext(): Promise<void> {
596630
if (!this._sessionRecoveryHook) {
597631
return;

nodejs/src/client/wsConnectorPool.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Dsn } from "../common/dsn";
44
import { ErrorCode, TDWebSocketClientError } from "../common/wsError";
55
import logger from "../common/log";
66
import { w3cwebsocket } from "websocket";
7+
import { ClusterRegistry } from "./clusterRegistry";
78
import { WebSocketConnector } from "./wsConnector";
89

910
const mutex = new Mutex();
@@ -46,12 +47,18 @@ export class WebSocketConnectionPool {
4647
}
4748

4849
private getPoolKey(dsn: Dsn): string {
50+
const auth = this.buildAuthScope(dsn);
51+
const path = dsn.path();
52+
if (dsn.isAdapterHA()) {
53+
const cluster = ClusterRegistry.instance().getOrCreateCluster(dsn.addresses);
54+
if (cluster) {
55+
return `${dsn.scheme}://${cluster.id}/${path}#auth=${auth}`;
56+
}
57+
}
4958
const addrs = [...dsn.addresses]
5059
.sort((a, b) => `${a.host}:${a.port}`.localeCompare(`${b.host}:${b.port}`))
5160
.map((addr) => `${addr.host}:${addr.port}`)
5261
.join(",");
53-
const auth = this.buildAuthScope(dsn);
54-
const path = dsn.path();
5562
return `${dsn.scheme}://${addrs}/${path}#auth=${auth}`;
5663
}
5764

0 commit comments

Comments
 (0)