antietcd 1.0.0

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/antietcd.d.ts ADDED
@@ -0,0 +1,155 @@
1
+ import type { EventEmitter } from 'events';
2
+
3
+ import type { TinyRaftEvents } from './tinyraft';
4
+
5
+ export type AntiEtcdEvents = {
6
+ raftchange: TinyRaftEvents['change'],
7
+ };
8
+
9
+ export class AntiEtcd extends EventEmitter<AntiEtcdEvents>
10
+ {
11
+ constructor(cfg: object);
12
+ start(): Promise<void>;
13
+ stop(): Promise<void>;
14
+ api(path: 'kv_txn'|'kv_range'|'kv_put'|'kv_deleterange'|'lease_grant'|'lease_revoke'|'lease_keepalive', params: object): Promise<object>;
15
+ txn(params: TxnRequest): Promise<TxnResponse>;
16
+ range(params: RangeRequest): Promise<RangeResponse>;
17
+ put(params: PutRequest): Promise<PutResponse>;
18
+ deleterange(params: DeleteRangeRequest): Promise<DeleteRangeResponse>;
19
+ lease_grant(params: LeaseGrantRequest): Promise<LeaseGrantResponse>;
20
+ lease_revoke(params: LeaseRevokeRequest): Promise<LeaseRevokeResponse>;
21
+ lease_keepalive(params: LeaseKeepaliveRequest): Promise<LeaseKeepaliveResponse>;
22
+ create_watch(params: WatchCreateRequest, callback: (ServerMessage) => void): Promise<string|number>;
23
+ cancel_watch(watch_id: string|number): Promise<void>;
24
+ }
25
+
26
+ export type TxnRequest = {
27
+ compare?: (
28
+ { key: string, target: "MOD", mod_revision: number, result?: "LESS" }
29
+ | { key: string, target: "CREATE", create_revision: number, result?: "LESS" }
30
+ | { key: string, target: "VERSION", version: number, result?: "LESS" }
31
+ | { key: string, target: "LEASE", lease: string, result?: "LESS" }
32
+ | { key: string, target: "VALUE", value: string }
33
+ )[],
34
+ success?: (
35
+ { request_put: PutRequest }
36
+ | { request_range: RangeRequest }
37
+ | { request_delete_range: DeleteRangeRequest }
38
+ )[],
39
+ failure?: (
40
+ { request_put: PutRequest }
41
+ | { request_range: RangeRequest }
42
+ | { request_delete_range: DeleteRangeRequest }
43
+ )[],
44
+ serializable?: boolean,
45
+ };
46
+
47
+ export type TxnResponse = {
48
+ header: { revision: number },
49
+ succeeded: boolean,
50
+ responses: (
51
+ { response_put: PutResponse }
52
+ | { response_range: RangeResponse }
53
+ | { response_delete_range: DeleteRangeResponse }
54
+ )[],
55
+ };
56
+
57
+ export type PutRequest = {
58
+ key: string,
59
+ value: string,
60
+ lease?: string,
61
+ };
62
+
63
+ export type PutResponse = {
64
+ header: { revision: number },
65
+ };
66
+
67
+ export type RangeRequest = {
68
+ key: string,
69
+ range_end?: string,
70
+ keys_only?: boolean,
71
+ serializable?: boolean,
72
+ };
73
+
74
+ export type RangeResponse = {
75
+ header: { revision: number },
76
+ kvs: { key: string }[] | {
77
+ key: string,
78
+ value: string,
79
+ lease?: string,
80
+ mod_revision: number,
81
+ }[],
82
+ };
83
+
84
+ export type DeleteRangeRequest = {
85
+ key: string,
86
+ range_end?: string,
87
+ };
88
+
89
+ export type DeleteRangeResponse = {
90
+ header: { revision: number },
91
+ // number of deleted keys
92
+ deleted: number,
93
+ };
94
+
95
+ export type LeaseGrantRequest = {
96
+ ID?: string,
97
+ TTL: number,
98
+ };
99
+
100
+ export type LeaseGrantResponse = {
101
+ header: { revision: number },
102
+ ID: string,
103
+ TTL: number,
104
+ };
105
+
106
+ export type LeaseKeepaliveRequest = {
107
+ ID: string,
108
+ };
109
+
110
+ export type LeaseKeepaliveResponse = {
111
+ result: {
112
+ header: { revision: number },
113
+ ID: string,
114
+ TTL: number,
115
+ }
116
+ };
117
+
118
+ export type LeaseRevokeRequest = {
119
+ ID: string,
120
+ };
121
+
122
+ export type LeaseRevokeResponse = {
123
+ header: { revision: number },
124
+ };
125
+
126
+ export type WatchCreateRequest = {
127
+ key: string,
128
+ range_end?: string,
129
+ start_revision?: number,
130
+ watch_id?: string|number,
131
+ }
132
+
133
+ export type ClientMessage =
134
+ { create_request: WatchCreateRequest }
135
+ | { cancel_request: { watch_id: string } }
136
+ | { progress_request: {} };
137
+
138
+ export type ServerMessage = {
139
+ result: {
140
+ header: { revision: number },
141
+ watch_id: string|number,
142
+ created?: boolean,
143
+ canceled?: boolean,
144
+ compact_revision?: number,
145
+ events?: {
146
+ type: 'PUT'|'DELETE',
147
+ kv: {
148
+ key: string,
149
+ value: string,
150
+ lease?: string,
151
+ mod_revision: number,
152
+ },
153
+ }[],
154
+ }
155
+ } | { error: 'bad-json' } | { error: 'empty-message' };
package/antietcd.js ADDED
@@ -0,0 +1,552 @@
1
+ #!/usr/bin/node
2
+
3
+ // AntiEtcd embeddable class
4
+ // (c) Vitaliy Filippov, 2024
5
+ // License: Mozilla Public License 2.0 or Vitastor Network Public License 1.1
6
+
7
+ const fsp = require('fs').promises;
8
+ const { URL } = require('url');
9
+ const http = require('http');
10
+ const https = require('https');
11
+ const EventEmitter = require('events');
12
+
13
+ const ws = require('ws');
14
+
15
+ const EtcTree = require('./etctree.js');
16
+ const AntiPersistence = require('./antipersistence.js');
17
+ const AntiCluster = require('./anticluster.js');
18
+ const { runCallbacks, RequestError } = require('./common.js');
19
+
20
+ class AntiEtcd extends EventEmitter
21
+ {
22
+ constructor(cfg)
23
+ {
24
+ super();
25
+ this.clients = {};
26
+ this.client_id = 1;
27
+ this.etctree = new EtcTree(true);
28
+ this.persistence = null;
29
+ this.cluster = null;
30
+ this.stored_term = 0;
31
+ this.cfg = cfg;
32
+ this.loading = false;
33
+ this.stopped = false;
34
+ this.inflight = 0;
35
+ this.wait_inflight = [];
36
+ this.api_watches = {};
37
+ }
38
+
39
+ async start()
40
+ {
41
+ if (this.cfg.data || this.cfg.cluster)
42
+ {
43
+ this.etctree.set_replicate_watcher(msg => this._persistAndReplicate(msg));
44
+ }
45
+ if (this.cfg.data)
46
+ {
47
+ this.persistence = new AntiPersistence(this);
48
+ // Load data from disk
49
+ await this.persistence.load();
50
+ }
51
+ if (this.cfg.cluster)
52
+ {
53
+ this.cluster = new AntiCluster(this);
54
+ }
55
+ if (this.cfg.cert)
56
+ {
57
+ this.tls = {
58
+ key: await fsp.readFile(this.cfg.key),
59
+ cert: await fsp.readFile(this.cfg.cert),
60
+ };
61
+ if (this.cfg.ca)
62
+ {
63
+ this.tls.ca = await fsp.readFile(this.cfg.ca);
64
+ }
65
+ if (this.cfg.client_cert_auth)
66
+ {
67
+ this.tls.requestCert = true;
68
+ }
69
+ this.server = https.createServer(this.tls, (req, res) => this._handleRequest(req, res));
70
+ }
71
+ else
72
+ {
73
+ this.server = http.createServer((req, res) => this._handleRequest(req, res));
74
+ }
75
+ this.wss = new ws.WebSocketServer({ server: this.server });
76
+ // eslint-disable-next-line no-unused-vars
77
+ this.wss.on('connection', (conn, req) => this._startWebsocket(conn, null));
78
+ this.server.listen(this.cfg.port || 2379);
79
+ }
80
+
81
+ async stop()
82
+ {
83
+ if (this.stopped)
84
+ {
85
+ return;
86
+ }
87
+ this.stopped = true;
88
+ // Wait until all requests complete
89
+ while (this.inflight > 0)
90
+ {
91
+ await new Promise(ok => this.wait_inflight.push(ok));
92
+ }
93
+ if (this.persistence)
94
+ {
95
+ await this.persistence.persist();
96
+ }
97
+ }
98
+
99
+ async _persistAndReplicate(msg)
100
+ {
101
+ let res = [];
102
+ if (this.cluster)
103
+ {
104
+ // We have to guarantee that replication is processed sequentially
105
+ // So we have to send messages without first awaiting for anything!
106
+ res.push(this.cluster.replicateChange(msg));
107
+ }
108
+ if (this.persistence)
109
+ {
110
+ res.push(this.persistence.persistChange(msg));
111
+ }
112
+ if (res.length == 1)
113
+ {
114
+ await res[0];
115
+ }
116
+ else if (res.length > 0)
117
+ {
118
+ let done = 0;
119
+ await new Promise((allOk, allNo) =>
120
+ {
121
+ res.map(promise => promise.then(res =>
122
+ {
123
+ if ((++done) == res.length)
124
+ allOk();
125
+ }).catch(e =>
126
+ {
127
+ console.error(e);
128
+ allNo(e);
129
+ }));
130
+ });
131
+ }
132
+ if (!this.cluster)
133
+ {
134
+ // Run deletion compaction without followers
135
+ const mod_revision = this.antietcd.etctree.mod_revision;
136
+ if (mod_revision - this.antietcd.etctree.compact_revision > (this.cfg.compact_revisions||1000)*2)
137
+ {
138
+ const revision = mod_revision - (this.cfg.compact_revisions||1000);
139
+ this.antietcd.etctree.compact(revision);
140
+ }
141
+ }
142
+ }
143
+
144
+ _handleRequest(req, res)
145
+ {
146
+ let data = [];
147
+ req.on('data', (chunk) => data.push(chunk));
148
+ req.on('end', async () =>
149
+ {
150
+ this.inflight++;
151
+ data = Buffer.concat(data);
152
+ let body = '';
153
+ let code = 200;
154
+ let ctype = 'text/plain; charset=utf-8';
155
+ let reply;
156
+ try
157
+ {
158
+ if (req.headers['content-type'] != 'application/json')
159
+ {
160
+ throw new RequestError(400, 'content-type should be application/json');
161
+ }
162
+ body = data.toString();
163
+ try
164
+ {
165
+ data = data.length ? JSON.parse(data) : {};
166
+ }
167
+ catch (e)
168
+ {
169
+ throw new RequestError(400, 'body should be valid JSON');
170
+ }
171
+ if (!(data instanceof Object) || data instanceof Array)
172
+ {
173
+ throw new RequestError(400, 'body should be JSON object');
174
+ }
175
+ reply = await this._runHandler(req, data);
176
+ reply = JSON.stringify(reply);
177
+ ctype = 'application/json';
178
+ }
179
+ catch (e)
180
+ {
181
+ if (e instanceof RequestError)
182
+ {
183
+ code = e.code;
184
+ reply = e.message;
185
+ }
186
+ else
187
+ {
188
+ console.error(e);
189
+ code = 500;
190
+ reply = 'Internal error: '+e.message;
191
+ }
192
+ }
193
+ try
194
+ {
195
+ // Access log
196
+ if (this.cfg.log_level > 1)
197
+ {
198
+ console.log(
199
+ new Date().toISOString()+
200
+ ' '+(req.headers['x-forwarded-for'] || (req.socket.remoteAddress + ':' + req.socket.remotePort))+
201
+ ' '+req.method+' '+req.url+' '+code+'\n '+body.replace(/\n/g, '\\n')+
202
+ '\n '+reply.replace(/\n/g, '\\n')
203
+ );
204
+ }
205
+ reply = Buffer.from(reply);
206
+ res.writeHead(code, {
207
+ 'Content-Type': ctype,
208
+ 'Content-Length': reply.length,
209
+ });
210
+ res.write(reply);
211
+ res.end();
212
+ }
213
+ catch (e)
214
+ {
215
+ console.error(e);
216
+ }
217
+ this.inflight--;
218
+ if (!this.inflight)
219
+ {
220
+ runCallbacks(this, 'wait_inflight', []);
221
+ }
222
+ });
223
+ }
224
+
225
+ _startWebsocket(socket, reconnect)
226
+ {
227
+ const client_id = this.client_id++;
228
+ this.clients[client_id] = {
229
+ id: client_id,
230
+ addr: socket._socket ? socket._socket.remoteAddress+':'+socket._socket.remotePort : '',
231
+ socket,
232
+ alive: true,
233
+ watches: {},
234
+ };
235
+ socket.on('pong', () => this.clients[client_id].alive = true);
236
+ socket.on('error', e => console.error(e.syscall === 'connect' ? e.message : e));
237
+ const pinger = setInterval(() =>
238
+ {
239
+ if (!this.clients[client_id])
240
+ {
241
+ return;
242
+ }
243
+ if (!this.clients[client_id].alive)
244
+ {
245
+ return socket.terminate();
246
+ }
247
+ this.clients[client_id].alive = false;
248
+ socket.ping(() => {});
249
+ }, this.cfg.ws_keepalive_interval||30000);
250
+ socket.on('message', (msg) =>
251
+ {
252
+ try
253
+ {
254
+ msg = JSON.parse(msg);
255
+ }
256
+ catch (e)
257
+ {
258
+ socket.send(JSON.stringify({ error: 'bad-json' }));
259
+ return;
260
+ }
261
+ if (!msg)
262
+ {
263
+ socket.send(JSON.stringify({ error: 'empty-message' }));
264
+ }
265
+ else
266
+ {
267
+ this._handleMessage(client_id, msg, socket);
268
+ }
269
+ });
270
+ socket.on('close', () =>
271
+ {
272
+ this._unsubscribeClient(client_id);
273
+ clearInterval(pinger);
274
+ delete this.clients[client_id];
275
+ socket.terminate();
276
+ if (reconnect)
277
+ {
278
+ reconnect();
279
+ }
280
+ });
281
+ return client_id;
282
+ }
283
+
284
+ async _runHandler(req, data)
285
+ {
286
+ // v3/kv/txn
287
+ // v3/kv/range
288
+ // v3/kv/put
289
+ // v3/kv/deleterange
290
+ // v3/lease/grant
291
+ // v3/lease/keepalive
292
+ // v3/lease/revoke O_o
293
+ // v3/kv/lease/revoke O_o
294
+ const requestUrl = new URL(req.url, 'http://'+(req.headers.host || 'localhost'));
295
+ if (requestUrl.searchParams.get('leaderonly'))
296
+ {
297
+ data.leaderonly = true;
298
+ }
299
+ if (requestUrl.searchParams.get('serializable'))
300
+ {
301
+ data.serializable = true;
302
+ }
303
+ if (requestUrl.searchParams.get('nowaitquorum'))
304
+ {
305
+ data.nowaitquorum = true;
306
+ }
307
+ try
308
+ {
309
+ if (requestUrl.pathname.substr(0, 4) == '/v3/')
310
+ {
311
+ const path = requestUrl.pathname.substr(4).replace(/\/+$/, '').replace(/\/+/g, '_');
312
+ if (req.method != 'POST')
313
+ {
314
+ throw new RequestError(405, 'Please use POST method');
315
+ }
316
+ return await this.api(path, data);
317
+ }
318
+ else if (requestUrl.pathname == '/dump')
319
+ {
320
+ return await this.api('dump', data);
321
+ }
322
+ else
323
+ {
324
+ throw new RequestError(404, '');
325
+ }
326
+ }
327
+ catch (e)
328
+ {
329
+ if ((e instanceof RequestError) && e.code == 404)
330
+ {
331
+ throw new RequestError(404, 'Supported APIs: /v3/kv/txn, /v3/kv/range, /v3/kv/put, /v3/kv/deleterange, '+
332
+ '/v3/lease/grant, /v3/lease/revoke, /v3/kv/lease/revoke, /v3/lease/keepalive');
333
+ }
334
+ else
335
+ {
336
+ throw e;
337
+ }
338
+ }
339
+ }
340
+
341
+ // public generic handler
342
+ async api(path, data)
343
+ {
344
+ if (this.stopped)
345
+ {
346
+ throw new RequestError(502, 'Server is stopping');
347
+ }
348
+ if (path !== 'dump' && this.cluster)
349
+ {
350
+ const res = await this.cluster.checkRaftState(
351
+ path,
352
+ (data.leaderonly ? AntiCluster.LEADER_ONLY : 0) |
353
+ (data.serializable ? AntiCluster.READ_FROM_FOLLOWER : 0) |
354
+ (data.nowaitquorum ? AntiCluster.NO_WAIT_QUORUM : 0),
355
+ data
356
+ );
357
+ if (res)
358
+ {
359
+ return res;
360
+ }
361
+ }
362
+ const cb = this['_handle_'+path];
363
+ if (cb)
364
+ {
365
+ const res = cb.call(this, data);
366
+ if (res instanceof Promise)
367
+ {
368
+ return await res;
369
+ }
370
+ return res;
371
+ }
372
+ throw new RequestError(404, 'Unsupported API');
373
+ }
374
+
375
+ // public wrappers
376
+ async txn(params)
377
+ {
378
+ return await this.api('kv_txn', params);
379
+ }
380
+
381
+ async range(params)
382
+ {
383
+ return await this.api('kv_range', params);
384
+ }
385
+
386
+ async put(params)
387
+ {
388
+ return await this.api('kv_put', params);
389
+ }
390
+
391
+ async deleterange(params)
392
+ {
393
+ return await this.api('kv_deleterange', params);
394
+ }
395
+
396
+ async lease_grant(params)
397
+ {
398
+ return await this.api('lease_grant', params);
399
+ }
400
+
401
+ async lease_revoke(params)
402
+ {
403
+ return await this.api('lease_revoke', params);
404
+ }
405
+
406
+ async lease_keepalive(params)
407
+ {
408
+ return await this.api('lease_keepalive', params);
409
+ }
410
+
411
+ // public watch API
412
+ async create_watch(params, callback)
413
+ {
414
+ const watch = this.etctree.api_create_watch({ ...params, watch_id: null }, callback);
415
+ if (!watch.created)
416
+ {
417
+ throw new RequestError(400, 'Requested watch revision is compacted', { compact_revision: watch.compact_revision });
418
+ }
419
+ const watch_id = params.watch_id || watch.watch_id;
420
+ this.api_watches[watch_id] = watch.watch_id;
421
+ return watch_id;
422
+ }
423
+
424
+ async cancel_watch(watch_id)
425
+ {
426
+ const mapped_id = this.api_watches[watch_id];
427
+ if (!mapped_id)
428
+ {
429
+ throw new RequestError(400, 'Watch not found');
430
+ }
431
+ this.etctree.api_cancel_watch({ watch_id: mapped_id });
432
+ delete this.api_watches[watch_id];
433
+ }
434
+
435
+ // internal handlers
436
+ async _handle_kv_txn(data)
437
+ {
438
+ return await this.etctree.api_txn(data);
439
+ }
440
+
441
+ async _handle_kv_range(data)
442
+ {
443
+ const r = await this.etctree.api_txn({ success: [ { request_range: data } ] });
444
+ return { header: r.header, ...r.responses[0].response_range };
445
+ }
446
+
447
+ async _handle_kv_put(data)
448
+ {
449
+ const r = await this.etctree.api_txn({ success: [ { request_put: data } ] });
450
+ return { header: r.header, ...r.responses[0].response_put };
451
+ }
452
+
453
+ async _handle_kv_deleterange(data)
454
+ {
455
+ const r = await this.etctree.api_txn({ success: [ { request_delete_range: data } ] });
456
+ return { header: r.header, ...r.responses[0].response_delete_range };
457
+ }
458
+
459
+ _handle_lease_grant(data)
460
+ {
461
+ return this.etctree.api_grant_lease(data);
462
+ }
463
+
464
+ _handle_lease_revoke(data)
465
+ {
466
+ return this.etctree.api_revoke_lease(data);
467
+ }
468
+
469
+ _handle_kv_lease_revoke(data)
470
+ {
471
+ return this.etctree.api_revoke_lease(data);
472
+ }
473
+
474
+ _handle_lease_keepalive(data)
475
+ {
476
+ return this.etctree.api_keepalive_lease(data);
477
+ }
478
+
479
+ // eslint-disable-next-line no-unused-vars
480
+ _handle_dump(data)
481
+ {
482
+ return { ...this.etctree.dump(), term: this.stored_term };
483
+ }
484
+
485
+ _handleMessage(client_id, msg, socket)
486
+ {
487
+ const client = this.clients[client_id];
488
+ if (this.cfg.access_log)
489
+ {
490
+ console.log(new Date().toISOString()+' '+client.addr+' '+(client.raft_node_id || '-')+' -> '+JSON.stringify(msg));
491
+ }
492
+ if (msg.create_request)
493
+ {
494
+ const create_request = msg.create_request;
495
+ if (!create_request.watch_id || !client.watches[create_request.watch_id])
496
+ {
497
+ const watch = this.etctree.api_create_watch(
498
+ { ...create_request, watch_id: null }, (msg) => socket.send(JSON.stringify(msg))
499
+ );
500
+ if (!watch.created)
501
+ {
502
+ socket.send(JSON.stringify({ result: { header: { revision: this.etctree.mod_revision }, watch_id: create_request.watch_id, ...watch } }));
503
+ }
504
+ else
505
+ {
506
+ create_request.watch_id = create_request.watch_id || watch.watch_id;
507
+ client.watches[create_request.watch_id] = watch.watch_id;
508
+ socket.send(JSON.stringify({ result: { header: { revision: this.etctree.mod_revision }, watch_id: create_request.watch_id, created: true } }));
509
+ }
510
+ }
511
+ }
512
+ else if (msg.cancel_request)
513
+ {
514
+ const mapped_id = client.watches[msg.cancel_request.watch_id];
515
+ if (mapped_id)
516
+ {
517
+ this.etctree.api_cancel_watch({ watch_id: mapped_id });
518
+ delete client.watches[msg.cancel_request.watch_id];
519
+ socket.send(JSON.stringify({ result: { header: { revision: this.etctree.mod_revision }, watch_id: msg.cancel_request.watch_id, canceled: true } }));
520
+ }
521
+ }
522
+ else if (msg.progress_request)
523
+ {
524
+ socket.send(JSON.stringify({ result: { header: { revision: this.etctree.mod_revision } } }));
525
+ }
526
+ else
527
+ {
528
+ if (!this.cluster)
529
+ {
530
+ return;
531
+ }
532
+ this.cluster.handleWsMsg(client, msg);
533
+ }
534
+ }
535
+
536
+ _unsubscribeClient(client_id)
537
+ {
538
+ if (!this.clients[client_id])
539
+ {
540
+ return;
541
+ }
542
+ for (const watch_id in this.clients[client_id].watches)
543
+ {
544
+ const mapped_id = this.clients[client_id].watches[watch_id];
545
+ this.etctree.api_cancel_watch({ watch_id: mapped_id });
546
+ }
547
+ }
548
+ }
549
+
550
+ AntiEtcd.RequestError = RequestError;
551
+
552
+ module.exports = AntiEtcd;