From 18f2d428ee1ff1a61df36c7f037c44456e364971 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Kreith?= Date: Thu, 15 Aug 2024 09:47:07 +0300 Subject: [PATCH] `2.3.0` (#5) * Update README.md * Add `JOIN_NOTIFICATION` message type * Add `join()` method to hamok to make auto discovery easier * remove 'hello-notification' event from Hamok as it become internal * add encode / decode for JoinNotification * add `JoinNotification` message class * refine `Join` method * add more condition for `no-heartbeat` event * change examples * `JoinNotification` is sent without destination, hence broadcast * some additional condition for adding and removing remote peers * change readme for describing `join()` method * make it compatible with node v18 * add remoteMap support * add `HamokRemoteMap` * update readme file, and documentation * add `redis` example to examples * add `STORAGE_APPLIED_COMMIT_NOTIFICATION` to schema * add `StorageAppliedCommit` to messages * add `encode` / `decode` functions for `StorageAppliedCommit` messages in `StorageCodec` * change `HamokSnapshot` to contain `remoteMap` information * expose `RaftLogs`, `MemoryStoredRaftLogs`, and newly created `HamokRemoteMap` to index * increment version number * add compatibility table --- README.md | 3 +- assets/logo-readme.png | Bin 0 -> 2696 bytes assets/logo.png | Bin 0 -> 35864 bytes assets/logo_32.svg | 97 ++++ docs/index.md | 401 +++++++++++---- docs/map.md | 126 +++-- docs/queue.md | 183 +++---- docs/record.md | 196 ++++---- docs/remoteMap.md | 310 ++++++++++++ examples/package.json | 8 +- examples/src/common-discovery-example-2.ts | 106 ---- examples/src/common-discovery-example.ts | 93 ---- examples/src/common-join-example.ts | 50 ++ examples/src/redis-job-executing-example.ts | 223 +++++++++ examples/src/redis-remote-map-example.ts | 158 ++++++ examples/src/run-all.ts | 7 +- examples/yarn.lock | 59 ++- package.json | 2 +- schema/hamokMessage.proto | 26 + schema/hamokMessage_pb.ts | 46 ++ src/Hamok.ts | 457 +++++++++++++++--- src/HamokSnapshot.ts | 6 + src/collections/HamokConnection.ts | 183 ++++++- src/collections/HamokMap.ts | 10 +- src/collections/HamokQueue.ts | 10 +- src/collections/HamokRecord.ts | 10 +- src/collections/HamokRemoteMap.ts | 438 +++++++++++++++++ src/collections/RemoteMap.ts | 17 + src/common/ConcurrentExecutor.ts | 14 +- src/common/HamokCodec.ts | 49 +- src/index.ts | 18 +- src/messages/HamokGridCodec.ts | 27 ++ src/messages/HamokMessage.ts | 46 ++ src/messages/RaftMessageEmitter.ts | 11 +- src/messages/StorageCodec.ts | 152 +++++- src/messages/messagetypes/InsertEntries.ts | 9 + src/messages/messagetypes/JoinNotification.ts | 8 + src/messages/messagetypes/RemoveEntries.ts | 10 + .../messagetypes/StorageAppliedCommit.ts | 9 + src/messages/messagetypes/UpdateEntries.ts | 12 + src/raft/MemoryStoredRaftLogs.ts | 29 +- src/raft/RaftEngine.ts | 20 +- src/raft/RaftFollowerState.ts | 4 +- src/raft/RaftLeaderState.ts | 2 +- src/raft/RaftLogs.ts | 2 +- 45 files changed, 2985 insertions(+), 662 deletions(-) create mode 100644 assets/logo-readme.png create mode 100644 assets/logo.png create mode 100644 assets/logo_32.svg create mode 100644 docs/remoteMap.md delete mode 100644 examples/src/common-discovery-example-2.ts delete mode 100644 examples/src/common-discovery-example.ts create mode 100644 examples/src/common-join-example.ts create mode 100644 examples/src/redis-job-executing-example.ts create mode 100644 examples/src/redis-remote-map-example.ts create mode 100644 src/collections/HamokRemoteMap.ts create mode 100644 src/collections/RemoteMap.ts create mode 100644 src/messages/messagetypes/JoinNotification.ts create mode 100644 src/messages/messagetypes/StorageAppliedCommit.ts diff --git a/README.md b/README.md index c919c75..1c8e614 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -Here's the combined README for the Hamok library: +![Logo](assets/logo-readme.png) # Hamok Library @@ -28,6 +28,7 @@ yarn add hamok - [HamokEmitter](#hamokemitter) - [HamokRecord](#hamokrecord) - [User Manual](#user-manual) +- [Important Notes](#important-notes) - [Contributing](#contributing) - [License](#license) ## Quick Start diff --git a/assets/logo-readme.png b/assets/logo-readme.png new file mode 100644 index 0000000000000000000000000000000000000000..0ed8201446718d4a85ecf144064f36beeb0c6451 GIT binary patch literal 2696 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 literal 0 HcmV?d00001 diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..2d71042a718db8136910ef4b8e3c41da6dd896cd GIT binary patch literal 35864 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 + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + diff --git a/docs/index.md b/docs/index.md index 8eb96f7..6986ad5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,5 +1,6 @@ ## User Manual - Hamok | [HamokEmitter](./emitter.md) | [HamokMap](./map.md) | [HamokQueue](./queue.md) | [HamokRecord](./record.md) + +[Hamok](./index.md) | [HamokEmitter](./emitter.md) | [HamokMap](./map.md) | HamokQueue | [HamokRecord](./record.md) | [HamokRemoteMap](./remoteMap.md) ## Table of Contents @@ -7,28 +8,31 @@ 2. [Installation](#installation) 3. [Configuration](#configuration) 4. [API Reference](#api-reference) - - [Properties](#properties) - - [Events](#events) - - [Methods](#methods) + - [Properties](#properties) + - [Events](#events) + - [Methods](#methods) 5. [Use Cases](#use-cases) - - [Executing Tasks on the leader](#executing-tasks-on-the-leader) - - [Creating and Managing Maps](#creating-and-managing-maps) - - [Creating and Managing Records](#creating-and-managing-records) - - [Creating and Managing Queues](#creating-and-managing-queues) - - [Creating and Managing Emitters](#creating-and-managing-emitters) + - [Joining the Grid Using the `join()` Method](#joining-the-grid-using-the-join-method) + - [Executing Tasks on the leader](#executing-tasks-on-the-leader) + - [Creating and Managing Maps](#creating-and-managing-maps) + - [Creating and Managing Records](#creating-and-managing-records) + - [Creating and Managing Queues](#creating-and-managing-queues) + - [Creating and Managing Emitters](#creating-and-managing-emitters) 6. [Snapshots](#snapshots) 7. [Error Handling](#error-handling) 8. [Examples](#examples) 9. [Best Practices](#best-practices) 10. [Troubleshooting](#troubleshooting) -11. [FAQ](#faq) +11. [`HamokMessage` compatibility Table](#hamokmessage-compatibility-table) +12. [FAQ](#faq) ## Introduction -Hamok is a lightweight, distributed object storage library developed using [Raft](https://raft.github.io/) consensus -algorithm. +Hamok is a lightweight, distributed object storage library developed using [Raft](https://raft.github.io/) consensus +algorithm. ## Installation + To install Hamok, ensure you have Node.js installed, and then add Hamok to your project via npm or yarn: ```sh @@ -46,7 +50,7 @@ yarn add hamok To create a new Hamok instance, import the `Hamok` class and instantiate it: ```typescript -import { Hamok } from 'hamok'; +import { Hamok } from "hamok"; const hamok = new Hamok(); ``` @@ -56,37 +60,87 @@ const hamok = new Hamok(); Hamok can be configured using the `HamokConstructorConfig` type. Here is an example configuration: ```typescript -import { Hamok } from 'hamok'; +import { Hamok } from "hamok"; const config = { - - /** - * The unique identifier for the peer in the Raft cluster. - */ - peerId: 'peer-1', - - /** - * The timeout duration in milliseconds for elections. - * If an election has not been completed within this duration, a candidate change state to follower. - */ - electionTimeoutInMs: 3000, - - /** - * The maximum idle time in milliseconds for a follower. - * If the follower is idle for longer than this duration, it considers the leader to be unavailable and starts an election. - */ - followerMaxIdleInMs: 500, - - /** - * The interval in milliseconds at which heartbeats are sent by the leader to maintain authority over followers, - * and sending the logs. - */ - heartbeatInMs: 150, - - /** - * If true, this peer will only be a follower and will never become a candidate or leader. - */ - onlyFollower: false, + /** + * Optional. Indicate if the Hamok should stop automatically when there are no remote peers. + * + * DEFAULT: false + */ + autoStopOnNoRemotePeers: false, + + /** + * Optional. The unique identifier for the peer in the Raft cluster. + * + * DEFAULT: a generated UUID v4 + */ + peerId: "peer-1", + + /** + * Optional. The timeout duration in milliseconds for elections. + * If an election has not been completed within this duration, a candidate change state to follower. + * + * DEFAULT: 3000 + */ + electionTimeoutInMs: 3000, + + /** + * Optional. The maximum idle time in milliseconds for a follower. + * If the follower is idle for longer than this duration, it considers the leader to be unavailable and starts an election. + * + * DEFAULT: 1000 + */ + followerMaxIdleInMs: 500, + + /** + * Optional. The interval in milliseconds at which heartbeats are sent by the leader to maintain authority over followers, + * and sending the logs. + * + * DEFAULT: 100 + */ + heartbeatInMs: 100, + + /** + * If true, this peer will only be a follower and will never become a candidate or leader. + * + * DEFAULT: false + */ + onlyFollower: false, + + /** + * Optional. Indicate if the Hamok should stop automatically when there are no remote peers. + * + * DEFAULT: false + */ + autoStopOnNoRemotePeers: false, + + /** + * Specifies the expiration time for RAFT logs, after which they will be removed from the locally stored logs. + * If this is set, a newly joined peer must sync up to the point where they can catch up with the logs that the leader provides, + * possibly using snapshots. This option is only applicable if `raftLogs` is not provided as a configuration option; + * in that case, the provided `raftLogs` implementation will be used, and this option will have no effect. + * + * DEFAULT: 0 (no expiration) + */ + logEntriesExpirationTimeInMs: 5 * 60 * 1000, // 5 minutes + + /** + * An implementation of the `RaftLogs` interface to store RAFT logs in this instance. + * + * DEFAULT: `MemoryStoredRaftLogs` + */ + raftLogs: createMyCustomRaftLogsStorage(), + + /** + * Optional. A custom appData object to be used by the application utilizes Hamok. + * + * DEFAULT: an empty record + */ + appData: { + foo: 1, + bar: "str", + }, }; const hamok = new Hamok(config); @@ -97,36 +151,47 @@ const hamok = new Hamok(config); ### Properties - `config`: `HamokConfig` + - The configuration object for the Hamok instance. - `raft`: `RaftEngine` + - The Raft engine instance used by Hamok for distributed consensus. - `records`: `Map>` + - A map of records managed by Hamok. - `maps`: `Map>` + - A map of maps managed by Hamok. - `queues`: `Map>` + - A map of queues managed by Hamok. - `emitters`: `Map>` + - A map of emitters managed by Hamok. - `grid`: `HamokGrid` + - The grid instance used for message routing and handling within Hamok. - `localPeerId`: `string` + - The local peer ID of the Hamok instance. - `remotePeerIds`: `ReadonlySet` + - A read-only set of remote peer IDs connected to the Hamok instance. - `leader`: `boolean` + - A boolean indicating if the current instance is the leader. - `state`: `RaftStateName` + - The current state of the Raft engine. - `run`: `boolean` @@ -148,67 +213,154 @@ Hamok emits various events that can be listened to for handling specific actions - `commit`: Emitted when a commit occurs. - `heartbeat`: Emitted during heartbeats. - `error`: Emitted when an error occurs. -- `hello-notification`: Emitted when a hello notification is received. - `no-heartbeat-from`: Emitted when no heartbeat is received from a peer. - ### Methods - **constructor**(`providedConfig?: Partial`): + - Creates a new Hamok instance with the provided configuration. - **start**(): `void` + - Starts the Hamok instance and the Raft engine. - **stop**(): `void` + - Stops the Hamok instance and the Raft engine. - **addRemotePeerId**(`remoteEndpointId: string`): `void` + - Adds a remote peer ID to the Raft engine. - **removeRemotePeerId**(`remoteEndpointId: string`): `void` + - Removes a remote peer ID from the Raft engine. - **export**(): `HamokSnapshot` + - Exports the current state of Hamok as a snapshot. - **import**(`snapshot: HamokSnapshot`): `void` + - Imports a snapshot to restore the state of Hamok. - **waitUntilCommitHead**(): `Promise` + - Waits until the commit head is reached. - **createMap**<`K, V`>(`options: HamokMapBuilderConfig`): `HamokMap` + - Creates a new map with the provided options. - **createRecord**<`T extends HamokRecordObject`>(`options: HamokRecordBuilderConfig`): `HamokRecord` + - Creates a new record with the provided options. - **createQueue**<`T`>(`options: HamokQueueBuilderConfig`): `HamokQueue` + - Creates a new queue with the provided options. - **createEmitter**<`T extends HamokEmitterEventMap`>(`options: HamokEmitterBuilderConfig`): `HamokEmitter` + - Creates a new emitter with the provided options. - **submit**(`entry: HamokMessage`): `Promise` + - Submits a message to the Raft engine. - **accept**(`message: HamokMessage`): `void` + - Accepts a message and processes it according to its type and protocol. -- **fetchRemotePeers**(`options?: { customRequest?: string, timeoutInMs?: number }`): `Promise` +- **fetchRemotePeers**(`timeout?: number, customRequest?: HamokHelloNotificationCustomRequestType`): `Promise` + - Fetches remote peers with optional custom requests and timeout. +- **join**(`params: HamokJoinProcessParams`): `Promise` + - Runs a join process with the provided parameters. See [here](#use-the-join-method) for more details. + ## Use cases +Here is a revised version of your text with improvements for clarity, grammar, and consistency: + +--- + +### Joining the Grid Using the `join()` Method + +Hamok provides an automated process to join a network of instances by connecting to remote peers. This feature simplifies integrating a new Hamok instance into an existing network. + +The automated join process consists of two phases: + +1. **Discover Remote Endpoints**: Add these endpoints to the local Hamok instance's list of remote peers. +2. **Notify Remote Peers**: Inform them about the local peer so they can add it to their lists. + +The first phase is executed by the `fetchRemotePeers` method, which is called by the `join` method. This method sends a `HelloNotification` message to remote peers. Each remote peer responds with an `EndpointStateNotification` message, which includes all the peers known to them. The local peer waits for these notifications within a specified timeout and then evaluates the responses. If no remote peers are received and the local instance does not have a remote peer, the process is either retried or an exception is raised. Additionally, the `HelloNotification` message can include a custom request, such as requesting a snapshot from the remote peers, which can be applied to the local instance if provided. + +In the second phase, a `JoinNotification` message is sent to remote peers, instructing them to add the local peer to their remote peer lists. + +Below is an example of using the `join` method: + +```typescript +await hamok.join({ + /** + * Timeout in milliseconds for fetching remote peers. + * + * DEFAULT: 5000 + */ + fetchRemotePeerTimeoutInMs: 3000, + + /** + * The maximum number of retries for fetching remote peers. + * -1 - means infinite retries + * 0 - means no retries + * + * DEFAULT: 3 + */ + maxRetry: 3, + + /** + * Indicates if remote peers should be automatically removed if no heartbeat is received. + * + * DEFAULT: true + */ + removeRemotePeersOnNoHeartbeat: true, + + /** + * Indicates if a snapshot should be requested from the remote peers. + * If provided, the best possible snapshot is selected amongst the provided ones and + * imported into the local peer before it joins to the grid. + * + * DEFAULT: true + */ + requestSnapshot: true, + + /** + * Indicates if the start() method should be called automatically after the join process is completed. + * + * if startAfterJoin is true the method promise is only resolved if a leader is elected or assigned. + * + * DEFAULT: true + */ + startAfterJoin: true, +}); +``` + +In the above example, the method attempts to fetch remote peers three times, each with a timeout of 3000 milliseconds. If remote peers are not fetched within the given timeout, the process is retried. If the maximum number of retries is reached and the remote peers are still not fetched, an error is raised, indicating that joining is not possible. + +Once remote peers are fetched, the local peer selects the best snapshot from the remote peers (based on the highest raft terms and commit index) and applies it to the local instance. + +After the snapshot is applied and the remote peers are added to the local instance, the local peer sends a `JoinNotification` message to remote peers to add the local peer to their remote peer lists. + +If `startAfterJoin` is set to true, the `start` method is automatically called once the join process is completed. + ### Executing Tasks on the leader ```typescript -hamok.on('heartbeat', () => { - if (!hamok.leader) return; - - // Execute tasks only on the leader +hamok.on("heartbeat", () => { + if (!hamok.leader) return; + // Execute tasks only on the leader }); ``` @@ -218,16 +370,16 @@ Hamok provides the `createMap` method to create and manage distributed maps. ```typescript const mapConfig = { - mapId: 'exampleMap', + mapId: "exampleMap", }; const map = hamokInstance.createMap(mapConfig); // Adding an entry to the map -await map.set('key', 1); +await map.set("key", 1); // Retrieving an entry from the map -const value = map.get('key'); +const value = map.get("key"); ``` ### Creating and Managing Records @@ -236,20 +388,20 @@ Hamok provides the `createRecord` method to create and manage distributed record ```typescript const recordConfig = { - recordId: 'exampleRecord', + recordId: "exampleRecord", }; type MyRecord = { - field1: string, - field2: number, -} + field1: string; + field2: number; +}; const myRecord = hamok.createRecord(recordConfig); // Setting a value in the record -await myRecord.set('field', 1); +await myRecord.set("field", 1); // Getting a value from the record -const value = exampleRecord.get('field'); +const value = exampleRecord.get("field"); ``` ### Creating and Managing Queues @@ -258,14 +410,14 @@ Hamok provides the `createQueue` method to create and manage distributed queues. ```typescript const queueConfig = { - queueId: 'exampleQueue', + queueId: "exampleQueue", requestTimeoutInMs: 5000, }; const queue = hamokInstance.createQueue(queueConfig); // Adding an item to the queue -await queue.push('item'); +await queue.push("item"); // Removing an item from the queue const item = queue.dequeue(); @@ -277,22 +429,21 @@ Hamok provides the `createEmitter` method to create and manage distributed emitt ```typescript type EventMap = { - 'event': [data: string], -} + event: [data: string]; +}; const emitter = hamok.createEmitter({ - emitterId: 'exampleEmitter', + emitterId: "exampleEmitter", }); -await emitter.subscribe('event', () => { - console.log('Event received'); +await emitter.subscribe("event", () => { + console.log("Event received"); }); // Emitting an event -emitter.emit('event'); +emitter.emit("event"); ``` - ## Snapshots Hamok supports exporting and importing snapshots for persistence and recovery. @@ -300,7 +451,6 @@ Snapshots are used to store the state of a Hamok instance, including the Raft lo It is designed to trim the logs and store the state of the instance along with the commit index a snapshot represents. When you use snapshots you can start a new instance from the snapshot and apply only the logs after the snapshot. - ### Exporting a Snapshot ```typescript @@ -318,35 +468,53 @@ hamok.import(snapshot); Hamok emits an `error` event when an error occurs. Listen for this event to handle errors. ```typescript -hamok.on('error', (error) => { - console.error('An error occurred:', error); +hamok.on("error", (error) => { + console.error("An error occurred:", error); }); ``` ## Examples - - [election and reelection](https://github.com/balazskreith/hamok-ts/blob/main/examples/src/common-reelection-example.ts) - - [Import and export snapshots](https://github.com/balazskreith/hamok-ts/blob/main/examples/src/common-import-export-example.ts) - - [Waiting for at least peers](https://github.com/balazskreith/hamok-ts/blob/main/examples/src/common-waiting-example.ts) - - [Use helper method to discover/add/remove remote peers](https://github.com/balazskreith/hamok-ts/blob/main/examples/src/common-discovery-example.ts) + +- [election and reelection](https://github.com/balazskreith/hamok-ts/blob/main/examples/src/common-reelection-example.ts) +- [Import and export snapshots](https://github.com/balazskreith/hamok-ts/blob/main/examples/src/common-import-export-example.ts) +- [Waiting for at least peers](https://github.com/balazskreith/hamok-ts/blob/main/examples/src/common-waiting-example.ts) +- [Use helper method to discover/add/remove remote peers](https://github.com/balazskreith/hamok-ts/blob/main/examples/src/common-discovery-example.ts) ## Best Practices + - Ensure to handle the `error` event to catch and respond to any issues. - Regularly export snapshots to persist the state of your Hamok instance. - Properly configure timeouts and periods to match your application’s requirements. -- +- ## Troubleshooting + If you encounter issues with Hamok, consider the following steps: + - Check the configuration for any incorrect settings. - Ensure that network connectivity is stable if using remote peers. - Review logs for any error messages or warnings. - Consult the Hamok documentation and community forums for additional support. +Here is the updated markdown compatibility table for the `HamokMessage` schema with version 2.0.0 removed: + +## `HamokMessage` compatibility Table + +| Version | 2.1.0 | 2.2.0 | 2.3.0 | +| ------- | ----- | ----- | ----- | +| 2.1.0 | ✔️ | ✔️ | ❌ | +| 2.2.0 | ✔️ | ✔️ | ❌ | +| 2.3.0 | ❌ | ❌ | ✔️ | + +- ✔️ Compatible +- ❌ Not Compatible + ## FAQ ### How do I start the Hamok instance? Use the `start` method to start the instance: + ```typescript hamok.start(); ``` @@ -354,6 +522,7 @@ hamok.start(); ### How do I stop the Hamok instance? Use the `stop` method to stop the instance: + ```typescript hamok.stop(); ``` @@ -361,39 +530,53 @@ hamok.stop(); ### How do I add a remote peer? Use the `addRemotePeerId` method to add a remote peer: + ```typescript -hamok.addRemotePeerId('remotePeerId'); +hamok.addRemotePeerId("remotePeerId"); ``` ### How do I remove a remote peer? Use the `removeRemotePeerId` method to remove a remote peer: + ```typescript -hamok.removeRemotePeerId('remotePeerId'); +hamok.removeRemotePeerId("remotePeerId"); ``` ### How can I subscribe to events from the Hamok instance? ```typescript -hamok.on('started', () => console.log('Hamok instance started')); -hamok.on('stopped', () => console.log('Hamok instance stopped')); -hamok.on('follower', () => console.log('Instance is now a follower')); -hamok.on('leader', () => console.log('Instance is now the leader')); -hamok.on('message', (message) => console.log('Message received:', message)); -hamok.on('remote-peer-joined', (peerId) => console.log('Remote peer joined:', peerId)); -hamok.on('remote-peer-left', (peerId) => console.log('Remote peer left:', peerId)); -hamok.on('leader-changed', (leaderId) => console.log('Leader changed:', leaderId)); -hamok.on('state-changed', (state) => console.log('State changed:', state)); -hamok.on('commit', (commitIndex) => console.log('Commit occurred:', commitIndex)); -hamok.on('heartbeat', () => console.log('Heartbeat received')); -hamok.on('error', (error) => console.error('An error occurred:', error)); -hamok.on('hello-notification', (peerId) => console.log('Hello notification received from:', peerId)); -hamok.on('no-heartbeat-from', (peerId) => console.log('No heartbeat received from:', peerId)); +hamok.on("started", () => console.log("Hamok instance started")); +hamok.on("stopped", () => console.log("Hamok instance stopped")); +hamok.on("follower", () => console.log("Instance is now a follower")); +hamok.on("leader", () => console.log("Instance is now the leader")); +hamok.on("message", (message) => console.log("Message received:", message)); +hamok.on("remote-peer-joined", (peerId) => + console.log("Remote peer joined:", peerId) +); +hamok.on("remote-peer-left", (peerId) => + console.log("Remote peer left:", peerId) +); +hamok.on("leader-changed", (leaderId) => + console.log("Leader changed:", leaderId) +); +hamok.on("state-changed", (state) => console.log("State changed:", state)); +hamok.on("commit", (commitIndex) => + console.log("Commit occurred:", commitIndex) +); +hamok.on("heartbeat", () => console.log("Heartbeat received")); +hamok.on("error", (error) => console.error("An error occurred:", error)); +hamok.on("hello-notification", (peerId) => + console.log("Hello notification received from:", peerId) +); +hamok.on("no-heartbeat-from", (peerId) => + console.log("No heartbeat received from:", peerId) +); ``` ### What is stored in Raft logs? -`HamokMessage`s. Every operation on a map, record, queue, or emitter is represented as a `HamokMessage` and +`HamokMessage`s. Every operation on a map, record, queue, or emitter is represented as a `HamokMessage` and every mutation request is stored in the Raft logs. The logs store the history of all operations, even the unsuccessful ones. Every instance every map, record, queue, or emitter receives the messages and goes through exactly the same sequence of operations. @@ -403,14 +586,14 @@ See below. ### Can I overflow the memory with logs? -Yes you can. The logs are stored in memory and can grow indefinitely. To prevent memory overflow, -either explicitly remove logs or set the expiration time for logs. Additionally you can use snapshots -to store the state of the instance along with the commitIndex a snapshot represents. +Yes you can. The logs are stored in memory and can grow indefinitely. To prevent memory overflow, +either explicitly remove logs or set the expiration time for logs. Additionally you can use snapshots +to store the state of the instance along with the commitIndex a snapshot represents. Therefore any new instance can start from the snapshot and apply only the logs after the snapshot. ### If I export a snapshot do I have to delete the logs? -Yes, if you don't have an expiration time set for logs, +Yes, if you don't have an expiration time set for logs, you should delete the logs after exporting a snapshot. ### What is the difference between a map and a record? @@ -419,16 +602,30 @@ A map is a key-value store, while a record is a single object with multiple fiel ### Is this an attempt to replace Redis? -No. Hamok primary purpose is to give the RAFT consensus algorithm to your service cluster, -so you can manage a leader within a cluster and share data atomically. -It is more suitable for configuration sharing, leader election, and other small but significant +No. Hamok primary purpose is to give the RAFT consensus algorithm to your service cluster, +so you can manage a leader within a cluster and share data atomically. +It is more suitable for configuration sharing, leader election, and other small but significant signals and data sharing, rather than acting as a full-fledged large and fast data storing and retrieving service. In general, if you just want to share key-value map or queue between two instance and you need it fast use Redis. If you need to apply distributed lock to access a key in redis, Hamok can come into the picture as RAFT gives you atomicity. -Hamok can also be used to elect a leader in the cluster giving some special management job to one instance amongst the replicated many. +Hamok can also be used to elect a leader in the cluster giving some special management job to one instance amongst the replicated many. ### What if the import/export is too large? -Well, I have not designed my neat lightweight distributed object storage to store billions of entries, but in this case -contact me and we can discuss the possibility of adding a feature to export the snapshot in chunks. \ No newline at end of file +Well, I have not designed my neat lightweight distributed object storage to store billions of entries, but in this case +contact me and we can discuss the possibility of adding a feature to export the snapshot in chunks. + +### How can I access `appData` of Hamok? + +```typescript +import { Hamok } from "hamok"; + +const hamok = new Hamok({ + appData: { + foo: 1, + }, +}); + +console.log("foo is", hamok.appData.foo); +``` diff --git a/docs/map.md b/docs/map.md index ba0be58..d0c9ad2 100644 --- a/docs/map.md +++ b/docs/map.md @@ -1,30 +1,30 @@ ## User Manual - [Hamok](./index.md) | [HamokEmitter](./emitter.md) | HamokMap | [HamokQueue](./queue.md) | [HamokRecord](./record.md) + +[Hamok](./index.md) | [HamokEmitter](./emitter.md) | [HamokMap](./map.md) | HamokQueue | [HamokRecord](./record.md) | [HamokRemoteMap](./remoteMap.md) ## Table of Contents -* [Overview](#overview) -* [Configuration](#configuration) -* [API Reference](#api-reference) - * [Properties](#properties) - * [Events](#events) - * [Methods](#methods) -* [Examples](#examples) -* [FAQ](#faq) + +- [Overview](#overview) +- [Configuration](#configuration) +- [API Reference](#api-reference) + - [Properties](#properties) + - [Events](#events) + - [Methods](#methods) +- [Examples](#examples) +- [FAQ](#faq) ## Overview `HamokMap` is a class that provides a replicated storage solution across instances, allowing for key-value pair manipulation with event-driven notifications. - ### Create a HamokMap instance You need a Hamok to create a Map. Here is how you can create a HamokMap instance: ```typescript const map = hamok.createMap({ - mapId: 'exampleMap', + mapId: "exampleMap", }); - ``` ## Configuration @@ -40,7 +40,7 @@ const map = hamok.createMap({ /** * Optional. The timeout duration in milliseconds for requests. - * + * * DEFAULT: 5000 */ requestTimeoutInMs: 5000, @@ -48,35 +48,35 @@ const map = hamok.createMap({ /** * Optional. The maximum waiting time in milliseconds for a message to be sent. * The storage holds back the message sending if Hamok is not connected to a grid or not part of a network. - * + * * DEFAULT: 10x requestTimeoutInMs */ maxMessageWaitingTimeInMs: 50000, /** * Optional. The maximum number of keys allowed in request or response messages. - * + * * DEFAULT: 0 means infinity */ maxOutboundMessageKeys: 1000, /** * Optional. The maximum number of values allowed in request or response messages. - * + * * DEFAULT: 0 means infinity */ maxOutboundMessageValues: 100, /** * Optional. A base map to be used as the initial state of the map. - * + * * DEFAULT: a new and empty BaseMap instance */ baseMap: new BaseMap(), /** * Optional. A codec for encoding and decoding keys in the map. - * + * * DEFAULT: JSON codec */ keyCodec: { @@ -86,11 +86,11 @@ const map = hamok.createMap({ /** * Optional. A codec for encoding and decoding values in the map. - * + * * DEFAULT: JSON codec */ valueCodec?: { - encode: (key: V) => Buffer.from(JSON.stringify(key)), + encode: (value: V) => Buffer.from(JSON.stringify(value)), decode: (data: Uint8Array) => JSON.parse(Buffer.from(data).toString()), } @@ -102,7 +102,6 @@ const map = hamok.createMap({ }); ``` - ## API Reference `HamokMap` A class representing a distributed map with various methods for manipulating and accessing the stored entries. @@ -115,7 +114,6 @@ A class representing a distributed map with various methods for manipulating and - `isEmpty`: `boolean` - Indicates whether the map is empty. - `equalValues`: `(a: V, b: V) => boolean` - A function to compare values for equality. - ### Events The `HamokMap` class extends `EventEmitter` and emits the following events: @@ -129,51 +127,67 @@ The `HamokMap` class extends `EventEmitter` and emits the following events: ### Methods - **close**(): `void` + - Closes the map and releases any held resources. - **keys**(): `IterableIterator` + - Returns an iterator over the keys in the map. - **clear**(): `Promise` + - Clears all entries in the map. - **get**(`key: K`): `V | undefined` + - Retrieves the value associated with the specified key. - **getAll**(`keys: IterableIterator | K[]`): `ReadonlyMap` + - Retrieves all values associated with the specified keys. - **set**(`key: K`, `value: V`): `Promise` + - Sets the value for the specified key. - **setAll**(`entries: ReadonlyMap`): `Promise>` + - Sets multiple entries in the map. - **insert**(`key: K`, `value: V`): `Promise` + - Inserts the specified entry into the map. - **insertAll**(`entries: ReadonlyMap | [K, V][]`): `Promise>` + - Inserts multiple entries into the map. - **delete**(`key: K`): `Promise` + - Deletes the entry associated with the specified key. - **deleteAll**(`keys: ReadonlySet | K[]`): `Promise>` + - Deletes multiple entries from the map. - **remove**(`key: K`): `Promise` + - Removes the entry associated with the specified key. - **removeAll**(`keys: ReadonlySet | K[]`): `Promise>` + - Removes multiple entries from the map. - **updateIf**(`key: K`, `value: V`, `oldValue: V`): `Promise` + - Updates the entry if the current value matches the specified old value. - **export**(): `HamokMapSnapshot` + - Exports the map data as a snapshot. - **import**(`data: HamokMapSnapshot`, `eventing?: boolean`): `void` + - Imports data from a snapshot. - **[Symbol.iterator]**(): `IterableIterator<[K, V]>` @@ -184,11 +198,11 @@ The `HamokMap` class extends `EventEmitter` and emits the following events: ```typescript const map = new HamokMap(connection, baseMap); -map.set('key', 'value').then((oldValue) => { +map.set("key", "value").then((oldValue) => { console.log(`Old value: ${oldValue}`); }); -const value = map.get('key'); +const value = map.get("key"); console.log(`Value: ${value}`); for (const [key, value] of map) { @@ -200,9 +214,9 @@ map.close(); ## Examples - - [use insert](https://github.com/balazskreith/hamok-ts/blob/main/examples/src/map-insert-get-example.ts) - - [use events](https://github.com/balazskreith/hamok-ts/blob/main/examples/src/map-events-example.ts) - - [use updateIf](https://github.com/balazskreith/hamok-ts/blob/main/examples/src/map-update-if-example.ts) +- [use insert](https://github.com/balazskreith/hamok-ts/blob/main/examples/src/map-insert-get-example.ts) +- [use events](https://github.com/balazskreith/hamok-ts/blob/main/examples/src/map-events-example.ts) +- [use updateIf](https://github.com/balazskreith/hamok-ts/blob/main/examples/src/map-update-if-example.ts) ## FAQ @@ -212,7 +226,7 @@ To create a HamokMap instance, you need a Hamok instance. Here is an example: ```typescript const map = hamok.createMap({ - mapId: 'exampleMap', + mapId: "exampleMap", }); ``` @@ -221,11 +235,13 @@ const map = hamok.createMap({ You can listen to HamokMap events using the `on` method. Here is an example: ```typescript -map.on('insert', (key, value) => console.log(`Inserted: ${key} -> ${value}`)); -map.on('update', (key, oldValue, newValue) => console.log(`Updated: ${key} from ${oldValue} to ${newValue}`)); -map.on('remove', (key, value) => console.log(`Removed: ${key} -> ${value}`)); -map.on('clear', () => console.log('Map cleared')); -map.on('close', () => console.log('Map closed')); +map.on("insert", (key, value) => console.log(`Inserted: ${key} -> ${value}`)); +map.on("update", (key, oldValue, newValue) => + console.log(`Updated: ${key} from ${oldValue} to ${newValue}`) +); +map.on("remove", (key, value) => console.log(`Removed: ${key} -> ${value}`)); +map.on("clear", () => console.log("Map cleared")); +map.on("close", () => console.log("Map closed")); ``` ### How do I close a HamokMap instance? @@ -238,8 +254,8 @@ map.close(); #### And what does that do? -The `close` method closes the map and releases any held resources. -It deletes the map from the Hamok instance and stops all event emissions. +The `close` method closes the map and releases any held resources. +It deletes the map from the Hamok instance and stops all event emissions. You cannot mutate the map after it is closed. ### How do I clear all entries in a HamokMap? @@ -255,7 +271,7 @@ await map.clear(); To retrieve a value for a given key, use the `get` method: ```typescript -const value = map.get('key1'); +const value = map.get("key1"); console.log(value); ``` @@ -264,7 +280,7 @@ console.log(value); To set a value for a given key, use the `set` method: ```typescript -const oldValue = await map.set('key1', 'value1'); +const oldValue = await map.set("key1", "value1"); console.log(oldValue); ``` @@ -273,8 +289,13 @@ console.log(oldValue); To insert a value for a given key, use the `insert` method: ```typescript -const existingValue = await map.insert('key1', 'value1'); -console.log(existingValue ? 'Insert failed, because the map already has a value for the key: ' + existingValue : 'Insert successful'); +const existingValue = await map.insert("key1", "value1"); +console.log( + existingValue + ? "Insert failed, because the map already has a value for the key: " + + existingValue + : "Insert successful" +); ``` ### How do I delete an entry for a given key? @@ -282,8 +303,8 @@ console.log(existingValue ? 'Insert failed, because the map already has a value To delete an entry for a given key, use the `delete` method: ```typescript -const success = await map.delete('key1'); -console.log('Deleted', success ? 'successfully' : 'failed'); +const success = await map.delete("key1"); +console.log("Deleted", success ? "successfully" : "failed"); ``` ### What is the difference between the `set` and `insert` methods? @@ -292,11 +313,12 @@ The `set` method updates the value for a given key, regardless of whether the ke The `insert` method, however, only adds a new key-value pair if the key does not already exist. ```typescript -map.on('insert', (key, value) => console.log(`Inserted: ${key} -> ${value}`)); -map.on('update', (key, oldValue, newValue) => console.log(`Updated: ${key} from ${oldValue} to ${newValue}`)); -await map.set('key', 'value'); // Updates or inserts the key-value pair -await map.set('key', 'new-value'); // Updates or inserts the key-value pair - +map.on("insert", (key, value) => console.log(`Inserted: ${key} -> ${value}`)); +map.on("update", (key, oldValue, newValue) => + console.log(`Updated: ${key} from ${oldValue} to ${newValue}`) +); +await map.set("key", "value"); // Updates or inserts the key-value pair +await map.set("key", "new-value"); // Updates or inserts the key-value pair ``` ### What is the difference between delete and remove methods? @@ -306,9 +328,9 @@ The `remove` method removes the entry associated with the specified key return t ### How many entries can be pushed as batch to `setAll`, `insertAll`, `deleteAll`, and `removeAll` methods? -As many as you wish, just take care of the memory usage. +As many as you wish, just take care of the memory usage. If you go in this way of large maps and batches, you should configure the `maxOutboundMessageKeys` and `maxOutboundMessageValues` options. -In that way, you can control the maximum number of keys and values allowed in request or response messages +In that way, you can control the maximum number of keys and values allowed in request or response messages preventing the communication channel to be bloated. ### How do I retrieve multiple values for multiple keys? @@ -316,19 +338,19 @@ preventing the communication channel to be bloated. To retrieve multiple values for multiple keys, use the `getAll` method: ```typescript -const keys = ['key1', 'key2']; +const keys = ["key1", "key2"]; const values = map.getAll(keys); console.log(values); ``` -### Should I export the map? +### Should I export the map? As you wish, but I designed the `export` and `import` methods to be used by `Hamok` to export and import the whole state of the map. ### Can I change the value of entries in the map when iterating or modify the baseMap? -You can, but you should not! THe whole purpose of the HamokMap is to give a wrapper -to mutate the baseMap in a safe way. If you use the method designed for mutating the baseMap and +You can, but you should not! THe whole purpose of the HamokMap is to give a wrapper +to mutate the baseMap in a safe way. If you use the method designed for mutating the baseMap and keep entries consistent then you have a consistent map. If you change the baseMap directly, you can break the consistency of the map. But noone stops you from doing that. diff --git a/docs/queue.md b/docs/queue.md index eb8a824..5543ea9 100644 --- a/docs/queue.md +++ b/docs/queue.md @@ -1,20 +1,21 @@ ## User Manual - [Hamok](./index.md) | [HamokEmitter](./emitter.md) | [HamokMap](./map.md) | HamokQueue | [HamokRecord](./record.md) + +[Hamok](./index.md) | [HamokEmitter](./emitter.md) | [HamokMap](./map.md) | HamokQueue | [HamokRecord](./record.md) | [HamokRemoteMap](./remoteMap.md) ## Table of Contents -* [Overview](#overview) -* [Configuration](#configuration) -* [API Reference](#api-reference) - * [Properties](#properties) - * [Events](#events) - * [Methods](#methods) -* [Examples](#examples) -* [FAQ](#faq) +- [Overview](#overview) +- [Configuration](#configuration) +- [API Reference](#api-reference) + - [Properties](#properties) + - [Events](#events) + - [Methods](#methods) +- [Examples](#examples) +- [FAQ](#faq) ## Overview -`HamokQueue` is a class that implements a replicated queue with event-driven notifications. +`HamokQueue` is a class that implements a replicated queue with event-driven notifications. It supports typical queue operations like push, pop, and peek. ### Create a HamokQueue instance @@ -23,7 +24,7 @@ You need a `Hamok` to create a `HamokQueue` instance. Here is how you can create ```typescript const queue = hamok.createQueue({ - queueId: 'exampleQueue', + queueId: "exampleQueue", }); ``` @@ -33,69 +34,68 @@ You can pass the following configuration options at the time of creating a `Hamo ```typescript const queue = hamok.createQueue({ - /** - * The unique identifier for the queue. - */ - queueId: 'queue1', - - /** - * Optional. The timeout duration in milliseconds for requests. - * - * DEFAULT: 5000 - */ - requestTimeoutInMs: 5000, - - /** - * Optional. The maximum waiting time in milliseconds for a message to be sent. - * The storage holds back the message sending if Hamok is not connected to a grid or not part of a network. - * - * DEFAULT: 10x requestTimeoutInMs - */ - maxMessageWaitingTimeInMs: 50000, - - /** - * Optional. The maximum number of keys allowed in request or response messages. - * - * DEFAULT: 0 means infinity - */ - maxOutboundMessageKeys: 1000, - - /** - * Optional. The maximum number of values allowed in request or response messages. - * - * DEFAULT: 0 means infinity - */ - maxOutboundMessageValues: 100, - - /** - * Optional. A base map to be used as the initial state of the map. - * - * DEFAULT: a new and empty BaseMap instance - */ - baseMap: new BaseMap(), - - /** - * Optional. The length of byte array used for queue keys. - * This also affects the maximum number of items ever pushed into the queue. - * Can be 2, 4, or 8 bytes. - * - * Default is 4, which allows for 4.3 billion items in the queue during it's lifetime. - */ - lengthOfBytesQueueKeys: 4, - - /** - * Optional. A codec for encoding and decoding items in the queue. - * - * DEFAULT: JSON codec - */ - codec: { - encode: (item: T) => Buffer.from(JSON.stringify(item)), - decode: (data: Uint8Array) => JSON.parse(Buffer.from(data).toString()), - }, + /** + * The unique identifier for the queue. + */ + queueId: "queue1", + + /** + * Optional. The timeout duration in milliseconds for requests. + * + * DEFAULT: 5000 + */ + requestTimeoutInMs: 5000, + + /** + * Optional. The maximum waiting time in milliseconds for a message to be sent. + * The storage holds back the message sending if Hamok is not connected to a grid or not part of a network. + * + * DEFAULT: 10x requestTimeoutInMs + */ + maxMessageWaitingTimeInMs: 50000, + + /** + * Optional. The maximum number of keys allowed in request or response messages. + * + * DEFAULT: 0 means infinity + */ + maxOutboundMessageKeys: 1000, + + /** + * Optional. The maximum number of values allowed in request or response messages. + * + * DEFAULT: 0 means infinity + */ + maxOutboundMessageValues: 100, + + /** + * Optional. A base map to be used as the initial state of the map. + * + * DEFAULT: a new and empty BaseMap instance + */ + baseMap: new BaseMap(), + + /** + * Optional. The length of byte array used for queue keys. + * This also affects the maximum number of items ever pushed into the queue. + * Can be 2, 4, or 8 bytes. + * + * Default is 4, which allows for 4.3 billion items in the queue during it's lifetime. + */ + lengthOfBytesQueueKeys: 4, + + /** + * Optional. A codec for encoding and decoding items in the queue. + * + * DEFAULT: JSON codec + */ + codec: { + encode: (item: T) => Buffer.from(JSON.stringify(item)), + decode: (data: Uint8Array) => JSON.parse(Buffer.from(data).toString()), + }, }); ``` - ## API Reference ### `HamokQueue` Class @@ -133,22 +133,25 @@ A class for managing a distributed queue with event-driven capabilities. ```typescript const queue = new HamokQueue(connection, baseMap); -queue.on('add', (value) => { +queue.on("add", (value) => { console.log(`Added value: ${value}`); }); -queue.on('remove', (value) => { +queue.on("remove", (value) => { console.log(`Removed value: ${value}`); }); -queue.push('item1', 'item2').then(() => { - return queue.pop(); -}).then((value) => { - console.log(`Popped value: ${value}`); -}); +queue + .push("item1", "item2") + .then(() => { + return queue.pop(); + }) + .then((value) => { + console.log(`Popped value: ${value}`); + }); queue.clear().then(() => { - console.log('Queue cleared'); + console.log("Queue cleared"); }); queue.close(); @@ -156,8 +159,8 @@ queue.close(); ## Examples - - [push() and pop()](https://github.com/balazskreith/hamok-ts/blob/main/examples/src/queue-push-pop-example.ts) - - [events from the queue](https://github.com/balazskreith/hamok-ts/blob/main/examples/src/queue-events-example.ts) +- [push() and pop()](https://github.com/balazskreith/hamok-ts/blob/main/examples/src/queue-push-pop-example.ts) +- [events from the queue](https://github.com/balazskreith/hamok-ts/blob/main/examples/src/queue-events-example.ts) ## FAQ @@ -167,11 +170,10 @@ The `push` method is used to add one or more items to the end of the queue progr ```typescript // Listening to the add event means any instance anywhere added an item to the queue -queue.on('add', (item) => console.log(`Added to the queue: ${item}`)); +queue.on("add", (item) => console.log(`Added to the queue: ${item}`)); // Using push method to add items to the queue -await queue.push('item1', 'item2'); - +await queue.push("item1", "item2"); ``` ### What is the difference between the `remove` event and the `pop` method? @@ -180,12 +182,11 @@ The `pop` method is used to remove and return the item at the front of the queue ```typescript // Listening to the remove event so means that any instance anywhere removed an item from the queue -queue.on('remove', (item) => console.log(`Removed from the queue: ${item}`)) +queue.on("remove", (item) => console.log(`Removed from the queue: ${item}`)); // Using pop method to remove an item from the queue const item = await queue.pop(); -console.log('Popped item:', item); -; +console.log("Popped item:", item); ``` ### Can I use HamokQueue for real-time messaging? @@ -198,9 +199,9 @@ You can check if the queue is empty by using the `empty` property of the `HamokQ ```typescript if (queue.empty) { - console.log('The queue is empty'); + console.log("The queue is empty"); } else { - console.log('The queue is not empty'); + console.log("The queue is not empty"); } ``` @@ -210,6 +211,6 @@ Yes, you can iterate over the items in the queue using the iterator provided by ```typescript for (const item of queue) { - console.log('Iterated item:', item); + console.log("Iterated item:", item); } -``` \ No newline at end of file +``` diff --git a/docs/record.md b/docs/record.md index e9c5f34..71f6a1e 100644 --- a/docs/record.md +++ b/docs/record.md @@ -1,16 +1,17 @@ ## User Manual - [Hamok](./index.md) | [HamokEmitter](./emitter.md) | [HamokMap](./map.md) | [HamokQueue](./queue.md) | HamokRecord + +[Hamok](./index.md) | [HamokEmitter](./emitter.md) | [HamokMap](./map.md) | HamokQueue | [HamokRecord](./record.md) | [HamokRemoteMap](./remoteMap.md) ## Table of Contents -* [Overview](#overview) -* [Configuration](#configuration) -* [API Reference](#api-reference) - * [Properties](#properties) - * [Events](#events) - * [Methods](#methods) -* [Examples](#examples) -* [FAQ](#faq) +- [Overview](#overview) +- [Configuration](#configuration) +- [API Reference](#api-reference) + - [Properties](#properties) + - [Events](#events) + - [Methods](#methods) +- [Examples](#examples) +- [FAQ](#faq) ## Overview @@ -21,8 +22,8 @@ The `HamokRecord` class is designed to manage replicated storage with event-driv You need a `Hamok` to create a `HamokRecord` instance. Here is how you can create a `HamokRecord` instance: ```typescript -const queue = hamok.createRecord<{ foo: string, bar: number }>({ - recordId: 'exampleRecord', +const queue = hamok.createRecord<{ foo: string; bar: number }>({ + recordId: "exampleRecord", }); ``` @@ -31,67 +32,66 @@ const queue = hamok.createRecord<{ foo: string, bar: number }>({ You can pass the following configuration options at the time of creating a `HamokRecord`: ```typescript -const record = hamok.createRecord<{ foo: string, bar: number }>({ - /** - * The unique identifier for the record. - */ - recordId: 'record1', - - /** - * Optional. The timeout duration in milliseconds for requests. - * - * DEFAULT: 5000 - */ - requestTimeoutInMs: 5000, - - /** - * Optional. The maximum waiting time in milliseconds for a message to be sent. - * The storage holds back the message sending if Hamok is not connected to a grid or not part of a network. - * - * DEFAULT: 10x requestTimeoutInMs - */ - maxMessageWaitingTimeInMs: 50000, - - /** - * Optional. The maximum number of keys allowed in request or response messages. - * - * DEFAULT: 0 means infinity - */ - maxOutboundMessageKeys: 1000, - - /** - * Optional. The maximum number of values allowed in request or response messages. - * - * DEFAULT: 0 means infinity - */ - maxOutboundMessageValues: 100, - - /** - * Optional. A base map to be used as the initial state of the map. - * - * DEFAULT: a new and empty BaseMap instance - */ - baseMap: new BaseMap(), - - /** - * Optional. A function to determine equality between two values. - * Used for custom equality checking. - */ - equalValues: (a: V, b: V) => a === b, - - /** - * Optional. The codec for encoding and decoding payloads. - */ - payloadsCodec: new Map([ - ['foo', myCodec] - ]), - - /** - * Optional. The initial object for the record. - */ - initialObject: { foo: 'value1', bar: 42 } +const record = hamok.createRecord<{ foo: string; bar: number }>({ + /** + * The unique identifier for the record. + */ + recordId: "record1", + + /** + * Optional. The timeout duration in milliseconds for requests. + * + * DEFAULT: 5000 + */ + requestTimeoutInMs: 5000, + + /** + * Optional. The maximum waiting time in milliseconds for a message to be sent. + * The storage holds back the message sending if Hamok is not connected to a grid or not part of a network. + * + * DEFAULT: 10x requestTimeoutInMs + */ + maxMessageWaitingTimeInMs: 50000, + + /** + * Optional. The maximum number of keys allowed in request or response messages. + * + * DEFAULT: 0 means infinity + */ + maxOutboundMessageKeys: 1000, + + /** + * Optional. The maximum number of values allowed in request or response messages. + * + * DEFAULT: 0 means infinity + */ + maxOutboundMessageValues: 100, + + /** + * Optional. A base map to be used as the initial state of the map. + * + * DEFAULT: a new and empty BaseMap instance + */ + baseMap: new BaseMap(), + + /** + * Optional. A function to determine equality between two values. + * Used for custom equality checking. + */ + equalValues: (a: V, b: V) => a === b, + + /** + * Optional. The codec for encoding and decoding payloads. + */ + payloadsCodec: new Map([["foo", myCodec]]), + + /** + * Optional. The initial object for the record. + */ + initialObject: { foo: "value1", bar: 42 }, }); ``` + ## API Reference ### `HamokRecord` Class @@ -129,30 +129,28 @@ A class for managing distributed records with event-driven capabilities. ```typescript const record = new HamokRecord(connection, { - equalValues: (a, b) => a === b, - payloadsCodec: new Map([ - ['key1', myCodec] - ]), - initalObject: { key1: 'value1' } + equalValues: (a, b) => a === b, + payloadsCodec: new Map([["key1", myCodec]]), + initalObject: { key1: "value1" }, }); -record.on('insert', (payload) => console.log('Inserted:', payload)); -record.on('update', (payload) => console.log('Updated:', payload)); -record.on('remove', (payload) => console.log('Removed:', payload)); -record.on('clear', () => console.log('Cleared all entries')); -record.on('close', () => console.log('Record closed')); +record.on("insert", (payload) => console.log("Inserted:", payload)); +record.on("update", (payload) => console.log("Updated:", payload)); +record.on("remove", (payload) => console.log("Removed:", payload)); +record.on("clear", () => console.log("Cleared all entries")); +record.on("close", () => console.log("Record closed")); -await record.set('key2', 'value2'); -await record.updateIf('key2', 'newValue2', 'value2'); -await record.delete('key2'); +await record.set("key2", "value2"); +await record.updateIf("key2", "newValue2", "value2"); +await record.delete("key2"); await record.clear(); record.close(); ``` ## Examples - - [use events](https://github.com/balazskreith/hamok-ts/blob/main/examples/src/record-events-example.ts) - - [use insert and get](https://github.com/balazskreith/hamok-ts/blob/main/examples/src/record-insert-get-example.ts) +- [use events](https://github.com/balazskreith/hamok-ts/blob/main/examples/src/record-events-example.ts) +- [use insert and get](https://github.com/balazskreith/hamok-ts/blob/main/examples/src/record-insert-get-example.ts) ## FAQ @@ -161,8 +159,8 @@ record.close(); The `set` method updates the value for a given key, regardless of whether the key already exists. The `insert` method, however, only adds a new key-value pair if the key does not already exist. ```typescript -await record.set('key', 'value'); // Updates or inserts the key-value pair -await record.insert('key', 'value'); // Inserts only if the key does not exist +await record.set("key", "value"); // Updates or inserts the key-value pair +await record.insert("key", "value"); // Inserts only if the key does not exist ``` ### How does the `updateIf` method work? @@ -170,11 +168,11 @@ await record.insert('key', 'value'); // Inserts only if the key does not exist The `updateIf` method updates the value for a given key only if the current value matches the specified old value. This is useful for ensuring atomic updates based on the current state of the record. ```typescript -const success = await record.updateIf('key', 'newValue', 'currentValue'); +const success = await record.updateIf("key", "newValue", "currentValue"); if (success) { - console.log('Value updated successfully'); + console.log("Value updated successfully"); } else { - console.log('Value update failed'); + console.log("Value update failed"); } ``` @@ -184,7 +182,7 @@ You can check if the `HamokRecord` instance is closed by using the `closed` prop ```typescript if (record.closed) { - console.log('The record is closed'); + console.log("The record is closed"); } ``` @@ -194,9 +192,9 @@ Performing operations on a closed `HamokRecord` will throw an error. Ensure that ```typescript if (!record.closed) { - await record.set('key', 'value'); + await record.set("key", "value"); } else { - console.log('Cannot perform operations on a closed record'); + console.log("Cannot perform operations on a closed record"); } ``` @@ -205,11 +203,11 @@ if (!record.closed) { You can listen for events on the `HamokRecord` by using the `on` method to register event listeners. ```typescript -record.on('insert', (payload) => console.log('Inserted:', payload)); -record.on('update', (payload) => console.log('Updated:', payload)); -record.on('remove', (payload) => console.log('Removed:', payload)); -record.on('clear', () => console.log('Cleared all entries')); -record.on('close', () => console.log('Record closed')); +record.on("insert", (payload) => console.log("Inserted:", payload)); +record.on("update", (payload) => console.log("Updated:", payload)); +record.on("remove", (payload) => console.log("Removed:", payload)); +record.on("clear", () => console.log("Cleared all entries")); +record.on("close", () => console.log("Record closed")); ``` -This documentation provides an overview of the `HamokRecord` class, its methods, properties, events, and common usage patterns. For more detailed examples and use cases, refer to the respective sections in the documentation. \ No newline at end of file +This documentation provides an overview of the `HamokRecord` class, its methods, properties, events, and common usage patterns. For more detailed examples and use cases, refer to the respective sections in the documentation. diff --git a/docs/remoteMap.md b/docs/remoteMap.md new file mode 100644 index 0000000..79fa71a --- /dev/null +++ b/docs/remoteMap.md @@ -0,0 +1,310 @@ +## User Manual + +[Hamok](./index.md) | [HamokEmitter](./emitter.md) | [HamokMap](./map.md) | HamokQueue | [HamokRecord](./record.md) | [HamokRemoteMap](./remoteMap.md) + +## Table of Contents + +- [Overview](#overview) +- [Configuration](#configuration) +- [API Reference](#api-reference) + - [Properties](#properties) + - [Events](#events) + - [Methods](#methods) +- [Examples](#examples) +- [FAQ](#faq) + +## Overview + +`HamokRemoteMap` is a class that provides a distributed storage solution, enabling key-value pair manipulation across multiple instances with event-driven notifications. The primary distinction between `HamokMap` and `HamokRemoteMap` lies in the underlying storage mechanism. `HamokRemoteMap` leverages a `RemoteMap` as its base for storing key-value pairs, which can reside in a remote location (e.g., Redis, a database, etc.). Hamok is then used solely to ensure operational consistency across distributed instances. + +This design is ideal for scenarios where a large number of key-value pairs need to be managed by multiple instances. Instead of introducing a distributed locking mechanism, `HamokRemoteMap` utilizes the RAFT consensus algorithm to guarantee consistent operation execution, ensuring consistency and fault tolerance in distributed systems. + +## Configuration + +```typescript +const config: HamokRemoteMapBuilderConfig = { + /** + * The unique identifier for the map. + */ + mapId: "remoteMapId", + + /** + * Optional. The timeout duration in milliseconds for requests. + */ + requestTimeoutInMs: 5000, + + /** + * Optional. The maximum waiting time in milliseconds for a message to be sent. + * The storage holds back the message sending if Hamok is not connected to a grid or not part of a network. + */ + maxMessageWaitingTimeInMs: 30000, + + /** + * Optional. A codec for encoding and decoding keys in the map. + * + * DEFAULT: JSON codec + */ + keyCodec: { + encode: (key: number) => (new DataView(new ArrayBuffer(4))).setInt32(0, key)), + decode: (data: Uint8Array) => (new DataView(data)).getInt32(0), + }, + + /** + * Optional. A codec for encoding and decoding values in the map. + * + * DEFAULT: JSON Codec + */ + valueCodec: valueCodec?: { + encode: (value: V) => Buffer.from(JSON.stringify(value)), + decode: (data: Uint8Array) => JSON.parse(Buffer.from(data).toString()), + }, + + /** + * Optional. The maximum number of keys allowed in request or response messages. + * + * DEFAULT: 0 means Infinity + */ + maxOutboundMessageKeys: 1000, + + /** + * Optional. The maximum number of values allowed in request or response messages. + * + * DEFAULT: 0 means Infinity + */ + maxOutboundMessageValues: 1000, + + /** + * The remote map to be used to store the data. + */ + remoteMap: createMyRemoteMap(), + + /** + * Flag indicate if the events should be emitted by the event emitter or not. + * It also reduces the communication overhead if not needed, as for emitting events + * the leader should send a message to all followers to emit an event. + * In such case when it's not necessary (like cache maintenance) it can be disabled. + * + * DEFAULT: false + */ + noEvents: false, + + /** + * Optional. A function to determine equality between two values. + * Used for custom equality checking. + */ + equalValues: (a: V, b: V) => a === b, +}; + +const remoteMap = hamok.createRemoteMap(config); +``` + +## API Reference + +### Properties + +- **`id: string`**: Returns the unique identifier of the storage. +- **`closed: boolean`**: Indicates whether the storage is closed. + +### Events + +`HamokRemoteMap` extends `EventEmitter` and emits the following events: + +- **`insert(key: K, value: V)`**: Emitted when a new entry is inserted. +- **`update(key: K, oldValue: V, newValue: V)`**: Emitted when an entry is updated. +- **`remove(key: K, value: V)`**: Emitted when an entry is removed. +- **`clear()`**: Emitted when the storage is cleared. +- **`close()`**: Emitted when the storage is closed. + +### Event Handling + +You can listen to these events using the standard `EventEmitter` API: + +```typescript +remoteMap.on("insert", (key, value) => { + console.log(`Inserted: ${key} = ${value}`); +}); + +remoteMap.on("update", (key, oldValue, newValue) => { + console.log(`Updated: ${key} from ${oldValue} to ${newValue}`); +}); + +remoteMap.on("remove", (key, value) => { + console.log(`Removed: ${key} = ${value}`); +}); +``` + +### Methods + +- **`close(): void`** + Closes the storage, disconnecting it from the network and releasing all resources. + +- **`size(): Promise`** + Returns the number of entries in the storage. + +- **`isEmpty(): Promise`** + Returns `true` if the storage is empty, `false` otherwise. + +- **`keys(): AsyncIterableIterator`** + Returns an iterator for the keys in the storage. + +- **`clear(): Promise`** + Clears all entries from the storage. + +- **`get(key: K): Promise`** + Retrieves the value associated with the specified key. + +- **`getAll(keys: IterableIterator | K[]): Promise>`** + Retrieves all values associated with the specified keys. + +- **`set(key: K, value: V): Promise`** + Sets a key-value pair in the storage. If the key already exists, the value is updated. + +- **`setAll(entries: ReadonlyMap): Promise>`** + Sets multiple key-value pairs in the storage. + +- **`insert(key: K, value: V): Promise`** + Inserts a key-value pair into the storage. If the key already exists, it will not be updated. + +- **`insertAll(entries: ReadonlyMap | [K, V][]): Promise>`** + Inserts multiple key-value pairs into the storage. + +- **`delete(key: K): Promise`** + Deletes a key-value pair from the storage by key. + +- **`deleteAll(keys: ReadonlySet | K[]): Promise>`** + Deletes multiple key-value pairs from the storage by their keys. + +- **`remove(key: K): Promise`** + Removes a key-value pair from the storage by key. + +- **`removeAll(keys: ReadonlySet | K[]): Promise>`** + Removes multiple key-value pairs from the storage by their keys. + +- **`updateIf(key: K, value: V, oldValue: V): Promise`** + Updates a key-value pair in the storage if the current value matches the specified `oldValue`. + +- **`iterator(): AsyncIterableIterator<[K, V]>`** + Returns an iterator for the key-value pairs in the storage. + +## Examples + +- [use redis](https://github.com/balazskreith/hamok-ts/blob/main/examples/src/redis-remote-map-example.ts) + +### Add Redis as RemoteMap + +```typescript +import { RemoteMap } from "hamok/lib/collections/RemoteMap"; +import Redis from "ioredis"; + +const publisher = new Redis(); +const subscriber = new Redis(); + +type CachedItem = { + id: string; + value: string; +}; + +function createRemoteMap(mapId: string): RemoteMap { + return { + async set(key, value, callback) { + const oldValue = await publisher.hget(mapId, key); + await publisher.hset(mapId, key, JSON.stringify(value)); + callback?.(oldValue ? JSON.parse(oldValue) : undefined); + }, + async setAll(entries, callback) { + const inserted: [string, CachedItem][] = []; + const updated: [string, CachedItem, CachedItem][] = []; + + for (const [key, value] of entries) { + const oldValue = await publisher.hget(mapId, key); + if (oldValue) { + updated.push([key, JSON.parse(oldValue), value]); + } else { + inserted.push([key, value]); + } + await publisher.hset(mapId, key, JSON.stringify(value)); + } + + callback?.({ inserted, updated }); + }, + iterator() { + async function* asyncIterator() { + const keys = await publisher.hkeys(mapId); + for (const key of keys) { + const value = await publisher.hget(mapId, key); + yield [key, value ? JSON.parse(value) : undefined] as [ + string, + CachedItem + ]; + } + } + + return asyncIterator(); + }, + async get(key) { + const value = await publisher.hget(mapId, key); + return value ? JSON.parse(value) : undefined; + }, + async keys() { + return (await publisher.hkeys(mapId)).values(); + }, + async getAll(keys) { + const iteratedKeys = [...keys]; + const values = await Promise.all( + iteratedKeys.map((key) => publisher.hget(mapId, key)) + ); + const entries = iteratedKeys + .map((key, index) => [ + key, + values[index] ? JSON.parse(values[index]) : undefined, + ]) + .filter(([, value]) => value !== undefined); + + return new Map(entries as [string, CachedItem][]); + }, + async remove(key) { + const value = await publisher.hget(mapId, key); + await publisher.hdel(mapId, key); + + return value ? JSON.parse(value) : undefined; + }, + async removeAll(keys) { + const iteratedKeys = [...keys]; + const values = await Promise.all( + iteratedKeys.map((key) => publisher.hget(mapId, key)) + ); + const entries = iteratedKeys + .map((key, index) => [ + key, + values[index] ? JSON.parse(values[index]) : undefined, + ]) + .filter(([, value]) => value !== undefined); + await publisher.hdel(mapId, ...iteratedKeys); + + return new Map(entries as [string, CachedItem][]); + }, + async clear() { + return publisher.del(mapId).then(() => void 0); + }, + async size() { + return publisher.hlen(mapId); + }, + }; +} +``` + +Note that the serialization and deserialization methods for keys and values differ between Hamok instances and between an instance and a `RemoteMap`. This is because Hamok requires binary serialization and deserialization for its messages, while the requirements for `RemoteMap` are unknown and left to the developer's discretion. In the example above, we used simple JSON serialization and deserialization for keys and values. + +## FAQ + +### **How does `HamokRemoteMap` ensure data consistency?** + +`HamokRemoteMap` uses Hamok which uses the Raft consensus algorithm to manage and replicate logs across distributed nodes, ensuring that all nodes agree on the operation order of the key-value store has to execute. + +### **Can I use custom functions to compare values in `HamokRemoteMap`?** + +Yes, you can provide a custom equality function when initializing `HamokRemoteMap` to define how values should be compared. + +### **What happens when the storage is closed?** + +When `HamokRemoteMap` is closed, it disconnects from the network, releases resources, and stops accepting or processing any new operations. All listeners are removed, and the `close` event is emitted. diff --git a/examples/package.json b/examples/package.json index 94b803b..47241e1 100644 --- a/examples/package.json +++ b/examples/package.json @@ -1,6 +1,6 @@ { "name": "hamok-example", - "version": "2.2.0", + "version": "2.3.0", "description": "Example for Hamok", "main": "main.js", "scripts": { @@ -18,8 +18,11 @@ "dev:queue:2": "nodemon -x ts-node src/queue-push-pop-example.ts | pino-pretty", "dev:common:1": "nodemon -x ts-node src/common-waiting-example.ts | pino-pretty", "dev:common:2": "nodemon -x ts-node src/common-import-export-example.ts | pino-pretty", - "dev:common:3": "nodemon -x ts-node src/common-discovery-example.ts | pino-pretty", + "dev:common:3": "nodemon -x ts-node src/common-join-example.ts | pino-pretty", "dev:common:4": "nodemon -x ts-node src/common-reelection-example.ts | pino-pretty", + "dev:common:5": "nodemon -x ts-node src/common-waiting-example-2.ts | pino-pretty", + + "dev:redis:1": "nodemon -x ts-node src/redis-remote-map-example.ts | pino-pretty", "build": "tsc", "test": "jest --config jest.config.js", "lint": "eslint --ext .ts src" @@ -45,6 +48,7 @@ "homepage": "https://github.com/hamok-dev/hamok-ts#readme", "dependencies": { "pino": "^9.3.2", + "ioredis": "^5.4.1", "hamok": "file:../" }, "devDependencies": { diff --git a/examples/src/common-discovery-example-2.ts b/examples/src/common-discovery-example-2.ts deleted file mode 100644 index 3281d32..0000000 --- a/examples/src/common-discovery-example-2.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { Hamok, HamokEventMap, HamokFetchRemotePeersResponse, HamokMessage, setHamokLogLevel } from 'hamok'; -import EventEmitter from 'events'; -import * as pino from 'pino'; - -const logger = pino.pino({ - name: 'common-waiting-example', - level: 'debug', -}); - -const pubSubServer = new class extends EventEmitter<{ message: [HamokMessage] }> { - private _servers = new Map(); - public add(server: Hamok) { - server.on('message', (message) => this.emit('message', message)); - this.on('message', (message) => server.accept(message)); - this._servers.set(server.localPeerId, server); - } - public remove(server: Hamok) { - server.removeAllListeners('message'); - this.off('message', (message) => server.accept(message)); - this._servers.delete(server.localPeerId); - } -}; - -export async function run() { - - const server_1 = new Hamok(); - const server_2 = new Hamok(); - - // by having the communication channel we assume we can inquery remote endpoints - pubSubServer.add(server_1); - pubSubServer.add(server_2); - - server_1.on('hello-notification', createHelloListener(server_1)); - server_1.on('no-heartbeat-from', createNoHeartbeatFromListener(server_1)); - server_2.on('hello-notification', createHelloListener(server_2)); - server_2.on('no-heartbeat-from', createNoHeartbeatFromListener(server_2)); - - // we fetch the remote endpoints - await server_1.fetchRemotePeers().then((response) => fetchRemoteEndpointHandler(server_1, response)); - await server_2.fetchRemotePeers().then((response) => fetchRemoteEndpointHandler(server_2, response)); - - server_1.start(); - server_2.start(); - - await Promise.all([ - new Promise(resolve => server_1.once('leader-changed', resolve)), - new Promise(resolve => server_2.once('leader-changed', resolve)), - ]); - - logger.info('Leader changed'); - - // add new Hamok to the grid - const server_3 = new Hamok(); - pubSubServer.add(server_3); - - server_3.on('hello-notification', createHelloListener(server_3)); - server_3.on('no-heartbeat-from', createNoHeartbeatFromListener(server_3)); - await server_3.fetchRemotePeers().then((response) => fetchRemoteEndpointHandler(server_3, response)); - - await new Promise(resolve => { - server_3.once('leader-changed', resolve) - server_3.start(); - }); - - logger.info('Leader changed'); - - await Promise.all([ - new Promise(resolve => server_2.once('no-heartbeat-from', resolve)), - new Promise(resolve => server_3.once('no-heartbeat-from', resolve)), - Promise.resolve(server_1.stop()), - Promise.resolve(pubSubServer.remove(server_1)), - ]); - - logger.info('Server_1 stopped'); - - server_2.stop(); - server_3.stop(); -} - -function createHelloListener(server: Hamok): (...args: HamokEventMap['hello-notification']) => void { - return (remotePeerId, request) => { - logger.info('%s received hello from %s, customRequest: %s', server.localPeerId, remotePeerId, request?.customData); - server.addRemotePeerId(remotePeerId); - - // IMPORTANT! if the notification holds a request, we must call the callback - if (request) request.callback('Hello from server'); - }; -} - -function createNoHeartbeatFromListener(server: Hamok): (...args: HamokEventMap['no-heartbeat-from']) => void { - return (remotePeerId) => { - logger.info('%s received no heartbeat from %s', server.localPeerId, remotePeerId); - server.removeRemotePeerId(remotePeerId); - }; -} - -function fetchRemoteEndpointHandler(server: Hamok, response: HamokFetchRemotePeersResponse): void { - logger.info('Adding remote peers to %s: %o', server.localPeerId, response.remotePeers); - response.remotePeers.forEach(remotePeerId => server.addRemotePeerId(remotePeerId)); -} - -if (require.main === module) { - logger.info('Running from module file'); - setHamokLogLevel('info'); - run(); -} diff --git a/examples/src/common-discovery-example.ts b/examples/src/common-discovery-example.ts deleted file mode 100644 index 6fbb52e..0000000 --- a/examples/src/common-discovery-example.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { Hamok, HamokEventMap, HamokFetchRemotePeersResponse, setHamokLogLevel } from 'hamok'; -import * as pino from 'pino'; - -const logger = pino.pino({ - name: 'common-waiting-example', - level: 'debug', -}); - -export async function run() { - - const server_1 = new Hamok(); - const server_2 = new Hamok(); - - // by having the communication channel we assume we can inquery remote endpoints - server_1.on('message', server_2.accept.bind(server_2)); - server_2.on('message', server_1.accept.bind(server_1)); - - server_1.on('hello-notification', createHelloListener(server_1)); - server_1.on('no-heartbeat-from', createNoHeartbeatFromListener(server_1)); - server_2.on('hello-notification', createHelloListener(server_2)); - server_2.on('no-heartbeat-from', createNoHeartbeatFromListener(server_2)); - - // we fetch the remote endpoints - await server_1.fetchRemotePeers().then((response) => fetchRemoteEndpointHandler(server_1, response)); - await server_2.fetchRemotePeers().then((response) => fetchRemoteEndpointHandler(server_2, response)); - - server_1.start(); - server_2.start(); - - await Promise.all([ - new Promise(resolve => server_1.once('leader-changed', resolve)), - new Promise(resolve => server_2.once('leader-changed', resolve)), - ]); - - logger.info('Leader changed'); - - // add new Hamok to the grid - const server_3 = new Hamok(); - server_3.on('message', server_1.accept.bind(server_1)); - server_3.on('message', server_2.accept.bind(server_2)); - server_1.on('message', server_3.accept.bind(server_3)); - server_2.on('message', server_3.accept.bind(server_3)); - - server_3.on('hello-notification', createHelloListener(server_3)); - server_3.on('no-heartbeat-from', createNoHeartbeatFromListener(server_3)); - await server_3.fetchRemotePeers().then((response) => fetchRemoteEndpointHandler(server_3, response)); - - await new Promise(resolve => { - server_3.once('leader-changed', resolve) - server_3.start(); - }); - - logger.info('Leader changed'); - - await Promise.all([ - new Promise(resolve => server_2.once('no-heartbeat-from', resolve)), - new Promise(resolve => server_3.once('no-heartbeat-from', resolve)), - Promise.resolve(server_1.stop()) - ]); - - logger.info('Server_1 stopped'); - - server_2.stop(); - server_3.stop(); -} - -function createHelloListener(server: Hamok): (...args: HamokEventMap['hello-notification']) => void { - return (remotePeerId, request) => { - logger.info('%s received hello from %s, customRequest: %s', server.localPeerId, remotePeerId, request?.customData); - server.addRemotePeerId(remotePeerId); - - // IMPORTANT! if the notification holds a request, we must call the callback - if (request) request.callback('Hello from server'); - }; -} - -function createNoHeartbeatFromListener(server: Hamok): (...args: HamokEventMap['no-heartbeat-from']) => void { - return (remotePeerId) => { - logger.info('%s received no heartbeat from %s', server.localPeerId, remotePeerId); - server.removeRemotePeerId(remotePeerId); - }; -} - -function fetchRemoteEndpointHandler(server: Hamok, response: HamokFetchRemotePeersResponse): void { - logger.info('Adding remote peers to %s: %o', server.localPeerId, response.remotePeers); - response.remotePeers.forEach(remotePeerId => server.addRemotePeerId(remotePeerId)); -} - -if (require.main === module) { - logger.info('Running from module file'); - setHamokLogLevel('info'); - run(); -} diff --git a/examples/src/common-join-example.ts b/examples/src/common-join-example.ts new file mode 100644 index 0000000..0fdc4c1 --- /dev/null +++ b/examples/src/common-join-example.ts @@ -0,0 +1,50 @@ +import { Hamok, HamokEventMap, HamokFetchRemotePeersResponse, setHamokLogLevel } from 'hamok'; +import * as pino from 'pino'; + +const logger = pino.pino({ + name: 'common-join-example', + level: 'debug', +}); + +export async function run() { + + const server_1 = new Hamok(); + const server_2 = new Hamok(); + + // by having the communication channel we assume we can inquery remote endpoints + server_1.on('message', server_2.accept.bind(server_2)); + server_2.on('message', server_1.accept.bind(server_1)); + + await Promise.all([ + server_1.join(), + server_2.join(), + ]); + + logger.info('Server 1 and Server 2 joined'); + + // add new Hamok to the grid + const server_3 = new Hamok(); + server_3.on('message', server_1.accept.bind(server_1)); + server_3.on('message', server_2.accept.bind(server_2)); + server_1.on('message', server_3.accept.bind(server_3)); + server_2.on('message', server_3.accept.bind(server_3)); + + await server_3.join(); + + logger.info('Server 3 joined, let\'s stop server_1 %s', server_1.localPeerId); + + await Promise.all([ + new Promise(resolve => server_2.once('no-heartbeat-from', peerId => (logger.info('Server_2 no-heartbeat-from %s', peerId), resolve()))), + new Promise(resolve => server_3.once('no-heartbeat-from', peerId => (logger.info('Server_3 no-heartbeat-from %s', peerId), resolve()))), + Promise.resolve(server_1.stop()) + ]); + + server_2.stop(); + server_3.stop(); +} + +if (require.main === module) { + logger.info('Running from module file'); + setHamokLogLevel('info'); + run(); +} diff --git a/examples/src/redis-job-executing-example.ts b/examples/src/redis-job-executing-example.ts new file mode 100644 index 0000000..e3181cd --- /dev/null +++ b/examples/src/redis-job-executing-example.ts @@ -0,0 +1,223 @@ +import { Hamok, HamokMessage, setHamokLogLevel } from 'hamok'; +import Redis from 'ioredis'; +import * as pino from 'pino'; + +const logger = pino.pino({ + name: 'redis-job-executing-example', + level: 'debug', +});; + +type Job = { + id: string; + state: 'pending' | 'running' | 'completed' | 'failed'; + startedBy?: string; + startedAt?: number; + endedAt?: number; + result?: unknown; + error?: string; +} +type AppData = { + picking: boolean; + ongoingJob: null | string; + seekNextJob(): Job | undefined; + pickJob(newJob: Job): Promise; +} +const server_1 = new Hamok<{ ongoingJob: null | string }>({ + appData: { + ongoingJob: null, + } +}); +const server_2 = new Hamok<{ ongoingJob: null | string }>({ + appData: { + ongoingJob: null, + } +}); +const publisher = new Redis(); +const subscriber = new Redis(); +const jobs_1 = server_1.createMap({ + mapId: 'jobs', +}); +const jobs_2 = server_2.createMap({ + mapId: 'jobs', +}); + +let ongoingJob: Job | null = null; + +function seekNextJob(): Job | undefined { + logger.info('Seeking next job to start.'); + for (const [ , job ] of jobs) { + if (job.state === 'pending') return job; + } +} + +async function pickJob(scheduledJob: Job): Promise { + if (ongoingJob) return; + if (scheduledJob.state !== 'pending') return; + + logger.info(`Picking job "${scheduledJob.id}".`); + + const startedJob: Job = { + ...scheduledJob, + state: 'running', + startedAt: Date.now(), + startedBy: hamok.localPeerId, + }; + const started = await jobs.updateIf(scheduledJob.id, startedJob, scheduledJob); + + if (!started) { + logger.info(`Job "${scheduledJob.id}" has been already started, we will not start it again.`); + const nextJob = seekNextJob(); + + if (nextJob) { + logger.info(`Found next job "${nextJob.id}" to start.`); + await pickJob(nextJob); + } else { + logger.info('No more jobs to start.'); + } + + return; + } + + ongoingJob = scheduledJob; + const executionTime = 20000 + Math.floor(Math.random() * 20000); + + logger.log(`Starting job "${scheduledJob.id}" with execution time of ${executionTime}ms.`); + setTimeout(async () => { + ongoingJob = null; + try { + const endedJob: Job = { + ...startedJob, + state: 'completed', + }; + const ended = await jobs.updateIf(scheduledJob.id, endedJob, startedJob); + + // await jobs.delete(scheduledJob.id); + + if (!ended) { + return logger.log(`Job "${scheduledJob.id}" has been canceled.`); + } + + logger.log(`Job "${scheduledJob.id}" has been completed.`); + } catch (err) { + return logger.error(`Failed to update job "${scheduledJob.id}": ${err}`); + } + + const nextJob = seekNextJob(); + + if (nextJob) { + logger.info(`Found next job "${nextJob.id}" to start.`); + await pickJob(nextJob); + } else { + logger.info('No more jobs to start.'); + } + }, executionTime); +} + +async function rescheduleJob(job: Job): Promise { + const rescheduledJob: Job = { + ...job, + state: 'pending', + startedBy: undefined, + startedAt: undefined, + endedAt: undefined, + result: undefined, + error: undefined, + }; + + const rescheduled = await jobs.updateIf(job.id, rescheduledJob, job); + + if (!rescheduled) { + logger.warn(`Job "${job.id}" has been not rescheduled.`); + } else { + logger.log(`Job "${job.id}" has been rescheduled.`); + } +} + +async function start() { + setHamokLogLevel('warn'); + const executor = new ConcurrentExecutor(1); + + hamok.on('leader', async () => { + logger.log('This instance is now the leader, will check if there are running jobs belongs to a "dead" instance.'); + const reschedules: Promise[] = []; + + for (const [ , job ] of jobs) { + if (job.state !== 'running') continue; + if (hamok.remotePeerIds.has(job.startedBy ?? '')) continue; + + reschedules.push(rescheduleJob(job)); + } + + if (0 < reschedules.length) await Promise.all(reschedules); + }); + hamok.on('remote-peer-left', async (remotePeerId: string) => { + if (!hamok.leader) return; + logger.info(`Remote peer "${remotePeerId}" has left and this instance is the leader, will reschedule the job assigned to the dead peer.`); + + const reschedules: Promise[] = []; + + for (const [ , job ] of jobs) { + if (job.state !== 'running') continue; + if (job.startedBy !== remotePeerId) continue; + + reschedules.push(rescheduleJob(job)); + } + + if (0 < reschedules.length) await Promise.all(reschedules); + }); + jobs.on('insert', (jobId, job) => executor.execute(() => pickJob(job).catch(() => void 0))); + jobs.on('update', (jobId, oldValue, newValue) => executor.execute(() => pickJob(newValue).catch(() => void 0))); + + subscriber.subscribe('hamok-channel', (err, count) => { + if (err) { + logger.error('Failed to subscribe: %s', err.message); + } + logger.log(`Subscribed to hamok-channel. This client is currently subscribed to ${count} channels.`); + }); + subscriber.on('messageBuffer', (channel, buffer) => { + hamok.accept(HamokMessage.fromBinary(buffer)); + }); + hamok.on('message', (message) => publisher.publish('hamok-channel', Buffer.from(message.toBinary()))); + + await hamok.join(); + + if (process.argv.includes('--add-jobs')) { + setInterval(async () => { + const job: Job = { + id: Math.random().toString(36), + state: 'pending', + }; + + logger.info(`Scheduling job "${job.id}".`); + + const existingJob = await jobs.insert(job.id, job); + + if (existingJob) { + logger.warn(`Job "${job.id}" has been already scheduled.`); + } + + }, 10000); + + setInterval(() => { + logger.info('Listing actual jobs'); + for (const [ , actualJob ] of jobs) { + if (actualJob.state !== 'pending' && actualJob.state !== 'running') continue; + + logger.info(`Job "${actualJob.id}" is in state "${actualJob.state}", started by ${actualJob.startedBy}.`); + } + }, 5000); + } + + logger.info('Started the application, local peer id is %s.', hamok.localPeerId); +} + +function stop() { + hamok.stop(); + publisher.disconnect(); + subscriber.disconnect(); + process.exit(0); +} + +process.on('SiGINT', stop); + +start().catch(stop); \ No newline at end of file diff --git a/examples/src/redis-remote-map-example.ts b/examples/src/redis-remote-map-example.ts new file mode 100644 index 0000000..bb3509a --- /dev/null +++ b/examples/src/redis-remote-map-example.ts @@ -0,0 +1,158 @@ +import { Hamok, HamokMessage, setHamokLogLevel } from 'hamok'; +import { RemoteMap } from 'hamok/lib/collections/RemoteMap'; +import Redis from 'ioredis'; +import * as pino from 'pino'; + +const logger = pino.pino({ + name: 'redis-remote-map-example', + level: 'debug', +});; + +type CachedItem = { + id: string; + value: string +} + +const server_1 = new Hamok(); +const server_2 = new Hamok(); +const publisher = new Redis(); +const subscriber = new Redis(); +const mapId = 'cached-items' + Math.random(); +const cache_1 = server_1.createRemoteMap({ + mapId, + remoteMap: createRemoteMap(mapId), +}); +const cache_2 = server_2.createRemoteMap({ + mapId, + remoteMap: createRemoteMap(mapId), +}); + + +async function run() { + setHamokLogLevel('warn'); + // let's just clear the map on start + await subscriber.subscribe('hamok-channel'); + + subscriber.on('messageBuffer', (channel, buffer) => { + server_1.accept(HamokMessage.fromBinary(buffer)); + server_2.accept(HamokMessage.fromBinary(buffer)); + }); + + server_1.on('message', message => publisher.publish('hamok-channel', Buffer.from(message.toBinary()))); + server_2.on('message', message => publisher.publish('hamok-channel', Buffer.from(message.toBinary()))); + + cache_1 + .on('clear', () => logger.info('Cache on server_1 is cleared')) + .on('update', (key, oldValue, newValue) => logger.info(`Cache on server_1 is updated for ${key}, from ${oldValue.value} => ${newValue.value}`)) + .on('insert', (key, value) => logger.info(`Cache on server_1 is inserted for ${key}, with value ${value.value}`)) + .on('remove', (key, value) => logger.info(`Cache on server_1 is removed for ${key}, with value ${value.value}`)) + .on('close', () => logger.info('Cache on server_1 is closed')); + + cache_2 + .on('clear', () => logger.info('Cache on server_2 is cleared')) + .on('update', (key, oldValue, newValue) => logger.info(`Cache on server_2 is updated for ${key}, from ${oldValue.value} => ${newValue.value}`)) + .on('insert', (key, value) => logger.info(`Cache on server_2 is inserted for ${key}, with value ${value.value}`)) + .on('remove', (key, value) => logger.info(`Cache on server_2 is removed for ${key}, with value ${value.value}`)) + .on('close', () => logger.info('Cache on server_2 is closed')); + + await Promise.all([ + server_1.join(), + server_2.join(), + ]); + + logger.info('Servers joined'); + + await cache_1.set('foo', { id: 'foo', value: 'bar' }); + await cache_2.set('foo', { id: 'foo', value: 'baz' }); + + logger.info(`Getting cache on server_1 for foo: %o`, await cache_1.get('foo')); + + const removedValue = await cache_1.remove('foo'); + + logger.info(`Removed value on server_1 for foo: %o`, removedValue); + + await cache_2.set('foo', { id: 'foo', value: 'bazz' }); + + await cache_1.clear(); + await cache_2.clear(); + + server_1.stop(); + server_2.stop(); +} + +if (require.main === module) { + logger.info('Running from module file'); + setHamokLogLevel('info'); + run(); +} + +function createRemoteMap(mapId: string): RemoteMap { + return { + async set(key, value, callback) { + const oldValue = await publisher.hget(mapId, key); + await publisher.hset(mapId, key, JSON.stringify(value)); + callback?.(oldValue ? JSON.parse(oldValue) : undefined); + }, + async setAll(entries, callback) { + const inserted: [string, CachedItem][] = []; + const updated: [string, CachedItem, CachedItem][] = []; + + for (const [key, value] of entries) { + const oldValue = await publisher.hget(mapId, key); + if (oldValue) { + updated.push([key, JSON.parse(oldValue), value]); + } else { + inserted.push([key, value]); + } + await publisher.hset(mapId, key, JSON.stringify(value)); + } + + callback?.({ inserted, updated }); + }, + iterator() { + async function* asyncIterator() { + const keys = await publisher.hkeys(mapId); + for (const key of keys) { + const value = await publisher.hget(mapId, key); + yield [key, value ? JSON.parse(value) : undefined] as [string, CachedItem]; + } + } + + return asyncIterator(); + }, + async get(key) { + const value = await publisher.hget(mapId, key); + return value ? JSON.parse(value) : undefined; + }, + async keys() { + return (await publisher.hkeys(mapId)).values(); + }, + async getAll(keys) { + const iteratedKeys = [...keys] + const values = await Promise.all(iteratedKeys.map((key) => publisher.hget(mapId, key))); + const entries = iteratedKeys.map((key, index) => [key, values[index] ? JSON.parse(values[index]) : undefined]).filter(([, value]) => value !== undefined); + + return new Map(entries as [string, CachedItem][]); + }, + async remove(key) { + const value = await publisher.hget(mapId, key); + await publisher.hdel(mapId, key); + + return value ? JSON.parse(value) : undefined; + }, + async removeAll(keys) { + const iteratedKeys = [...keys]; + const values = await Promise.all(iteratedKeys.map((key) => publisher.hget(mapId, key))); + const entries = iteratedKeys.map((key, index) => [key, values[index] ? JSON.parse(values[index]) : undefined]).filter(([, value]) => value !== undefined); + await publisher.hdel(mapId, ...iteratedKeys); + + return new Map(entries as [string, CachedItem][]); + }, + async clear() { + return publisher.del(mapId).then(() => void 0); + }, + async size() { + return publisher.hlen(mapId); + }, + } +} \ No newline at end of file diff --git a/examples/src/run-all.ts b/examples/src/run-all.ts index edd5003..e0bcdc0 100644 --- a/examples/src/run-all.ts +++ b/examples/src/run-all.ts @@ -1,6 +1,6 @@ import { setHamokLogLevel } from 'hamok'; import { run as reelection } from './common-reelection-example'; -import { run as discovery } from './common-discovery-example'; +import { run as discovery } from './common-join-example'; import { run as mapUpdateIf } from './map-update-if-example'; import { run as mapInsert } from './map-insert-get-example' import { run as queuePushPop } from './queue-push-pop-example'; @@ -33,9 +33,12 @@ async function run() { ['commonWaiting', commonWaiting], ['reelection', reelection], ['discovery', discovery], - ]); + if (process.argv.includes('--include-redis')) { + // empty + } + for (const [name, fn] of map) { logger.info([ '', diff --git a/examples/yarn.lock b/examples/yarn.lock index 550fbf1..e5d36ab 100644 --- a/examples/yarn.lock +++ b/examples/yarn.lock @@ -331,6 +331,11 @@ resolved "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz" integrity sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA== +"@ioredis/commands@^1.1.1": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.2.0.tgz#6d61b3097470af1fdbbe622795b8921d42018e11" + integrity sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg== + "@istanbuljs/load-nyc-config@^1.0.0": version "1.1.0" resolved "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz" @@ -1173,6 +1178,11 @@ cliui@^8.0.1: strip-ansi "^6.0.1" wrap-ansi "^7.0.0" +cluster-key-slot@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz#88ddaa46906e303b5de30d3153b7d9fe0a0c19ac" + integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA== + co@^4.6.0: version "4.6.0" resolved "https://registry.npmjs.org/co/-/co-4.6.0.tgz" @@ -1276,6 +1286,11 @@ deepmerge@^4.2.2: resolved "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz" integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== +denque@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/denque/-/denque-2.1.0.tgz#e93e1a6569fb5e66f16a3c2a2964617d349d6ab1" + integrity sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw== + detect-newline@^3.0.0: version "3.1.0" resolved "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz" @@ -1699,7 +1714,7 @@ graphemer@^1.4.0: integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== "hamok@file:..": - version "2.1.0" + version "2.2.0" dependencies: "@bufbuild/protobuf" "^1.10.0" pino "^9.3.2" @@ -1786,6 +1801,21 @@ inherits@2: resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== +ioredis@^5.4.1: + version "5.4.1" + resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-5.4.1.tgz#1c56b70b759f01465913887375ed809134296f40" + integrity sha512-2YZsvl7jopIa1gaePkeMtd9rAcSjOOjPtpcLlOeusyO+XH2SK5ZcT+UCrElPP+WVIInh2TzeI4XW9ENaSLVVHA== + dependencies: + "@ioredis/commands" "^1.1.1" + cluster-key-slot "^1.1.0" + debug "^4.3.4" + denque "^2.1.0" + lodash.defaults "^4.2.0" + lodash.isarguments "^3.1.0" + redis-errors "^1.2.0" + redis-parser "^3.0.0" + standard-as-callback "^2.1.0" + is-arrayish@^0.2.1: version "0.2.1" resolved "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz" @@ -2367,6 +2397,16 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" +lodash.defaults@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" + integrity sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ== + +lodash.isarguments@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a" + integrity sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg== + lodash.memoize@4.x: version "4.1.2" resolved "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz" @@ -2776,6 +2816,18 @@ real-require@^0.2.0: resolved "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz" integrity sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg== +redis-errors@^1.0.0, redis-errors@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/redis-errors/-/redis-errors-1.2.0.tgz#eb62d2adb15e4eaf4610c04afe1529384250abad" + integrity sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w== + +redis-parser@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-3.0.0.tgz#b66d828cdcafe6b4b8a428a7def4c6bcac31c8b4" + integrity sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A== + dependencies: + redis-errors "^1.0.0" + require-directory@^2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz" @@ -2927,6 +2979,11 @@ stack-utils@^2.0.3: dependencies: escape-string-regexp "^2.0.0" +standard-as-callback@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/standard-as-callback/-/standard-as-callback-2.1.0.tgz#8953fc05359868a77b5b9739a665c5977bb7df45" + integrity sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A== + string-length@^4.0.1: version "4.0.2" resolved "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz" diff --git a/package.json b/package.json index 504abfa..e9c8ec2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hamok", - "version": "2.2.0", + "version": "2.3.0", "description": "Lightweight Distributed Object Storage on RAFT consensus algorithm", "main": "lib/index.js", "types": "lib/index.d.ts", diff --git a/schema/hamokMessage.proto b/schema/hamokMessage.proto index ba2aaca..d50f446 100644 --- a/schema/hamokMessage.proto +++ b/schema/hamokMessage.proto @@ -22,6 +22,12 @@ message HamokMessage { */ ONGOING_REQUESTS_NOTIFICATION = 3; + /** + * Join notification is sent by a new endpoint to every other endpoint + * in order to join the grid + */ + JOIN_NOTIFICATION = 4; + /** * Raft Vote request is sent by a raccoon made itself a candidate * in order to be a leader of the cluster @@ -133,6 +139,11 @@ message HamokMessage { */ REMOVE_ENTRIES_NOTIFICATION = 46; + /** + * Notification about the removed entries + */ + ENTRIES_REMOVED_NOTIFICATION = 47; + /** * Insert item(s) only if they don't exist. if they @@ -152,6 +163,11 @@ message HamokMessage { * Notification about an insert operation. */ INSERT_ENTRIES_NOTIFICATION = 54; + + /** + * Notification about the inserted entries + */ + ENTRIES_INSERTED_NOTIFICATION = 55; /** @@ -167,6 +183,16 @@ message HamokMessage { */ UPDATE_ENTRIES_NOTIFICATION = 58; + /** + * Notification about the updated entries + */ + ENTRY_UPDATED_NOTIFICATION = 59; + + /** + * Notification about the applied commit + */ + STORAGE_APPLIED_COMMIT_NOTIFICATION = 60; + } enum MessageProtocol { diff --git a/schema/hamokMessage_pb.ts b/schema/hamokMessage_pb.ts index f0df892..2738117 100644 --- a/schema/hamokMessage_pb.ts +++ b/schema/hamokMessage_pb.ts @@ -231,6 +231,15 @@ export enum HamokMessage_MessageType { */ ONGOING_REQUESTS_NOTIFICATION = 3, + /** + * * + * Join notification is sent by a new endpoint to every other endpoint + * in order to join the grid + * + * @generated from enum value: JOIN_NOTIFICATION = 4; + */ + JOIN_NOTIFICATION = 4, + /** * * * Raft Vote request is sent by a raccoon made itself a candidate @@ -408,6 +417,14 @@ export enum HamokMessage_MessageType { */ REMOVE_ENTRIES_NOTIFICATION = 46, + /** + * * + * Notification about the removed entries + * + * @generated from enum value: ENTRIES_REMOVED_NOTIFICATION = 47; + */ + ENTRIES_REMOVED_NOTIFICATION = 47, + /** * * * Insert item(s) only if they don't exist. if they @@ -438,6 +455,14 @@ export enum HamokMessage_MessageType { */ INSERT_ENTRIES_NOTIFICATION = 54, + /** + * * + * Notification about the inserted entries + * + * @generated from enum value: ENTRIES_INSERTED_NOTIFICATION = 55; + */ + ENTRIES_INSERTED_NOTIFICATION = 55, + /** * * * Request an update from a remote storage @@ -461,12 +486,29 @@ export enum HamokMessage_MessageType { * @generated from enum value: UPDATE_ENTRIES_NOTIFICATION = 58; */ UPDATE_ENTRIES_NOTIFICATION = 58, + + /** + * * + * Notification about the updated entries + * + * @generated from enum value: ENTRY_UPDATED_NOTIFICATION = 59; + */ + ENTRY_UPDATED_NOTIFICATION = 59, + + /** + * * + * Notification about the applied commit + * + * @generated from enum value: STORAGE_APPLIED_COMMIT_NOTIFICATION = 60; + */ + STORAGE_APPLIED_COMMIT_NOTIFICATION = 60, } // Retrieve enum metadata with: proto2.getEnumType(HamokMessage_MessageType) proto2.util.setEnumType(HamokMessage_MessageType, "io.github.hamok.dev.schema.HamokMessage.MessageType", [ { no: 1, name: "HELLO_NOTIFICATION" }, { no: 2, name: "ENDPOINT_STATES_NOTIFICATION" }, { no: 3, name: "ONGOING_REQUESTS_NOTIFICATION" }, + { no: 4, name: "JOIN_NOTIFICATION" }, { no: 12, name: "RAFT_VOTE_REQUEST" }, { no: 13, name: "RAFT_VOTE_RESPONSE" }, { no: 16, name: "RAFT_APPEND_ENTRIES_REQUEST_CHUNK" }, @@ -488,12 +530,16 @@ proto2.util.setEnumType(HamokMessage_MessageType, "io.github.hamok.dev.schema.Ha { no: 44, name: "REMOVE_ENTRIES_REQUEST" }, { no: 45, name: "REMOVE_ENTRIES_RESPONSE" }, { no: 46, name: "REMOVE_ENTRIES_NOTIFICATION" }, + { no: 47, name: "ENTRIES_REMOVED_NOTIFICATION" }, { no: 52, name: "INSERT_ENTRIES_REQUEST" }, { no: 53, name: "INSERT_ENTRIES_RESPONSE" }, { no: 54, name: "INSERT_ENTRIES_NOTIFICATION" }, + { no: 55, name: "ENTRIES_INSERTED_NOTIFICATION" }, { no: 56, name: "UPDATE_ENTRIES_REQUEST" }, { no: 57, name: "UPDATE_ENTRIES_RESPONSE" }, { no: 58, name: "UPDATE_ENTRIES_NOTIFICATION" }, + { no: 59, name: "ENTRY_UPDATED_NOTIFICATION" }, + { no: 60, name: "STORAGE_APPLIED_COMMIT_NOTIFICATION" }, ]); /** diff --git a/src/Hamok.ts b/src/Hamok.ts index 4d4ecbc..30183b9 100644 --- a/src/Hamok.ts +++ b/src/Hamok.ts @@ -9,7 +9,7 @@ import { RaftStateName } from './raft/RaftState'; import { HamokMap } from './collections/HamokMap'; import { HamokConnection } from './collections/HamokConnection'; import { OngoingRequestsNotifier } from './messages/OngoingRequestsNotifier'; -import { createHamokJsonBinaryCodec, createNumberToUint8ArrayCodec, createStrToUint8ArrayCodec, HamokCodec } from './common/HamokCodec'; +import { createHamokJsonBinaryCodec, createHamokJsonStringCodec, createNumberToUint8ArrayCodec, createStrToUint8ArrayCodec, HamokCodec } from './common/HamokCodec'; import { StorageCodec } from './messages/StorageCodec'; import { BaseMap, MemoryBaseMap } from './collections/BaseMap'; import { createLogger } from './common/logger'; @@ -24,18 +24,72 @@ import { RaftLogs } from './raft/RaftLogs'; import { HamokRecord, HamokRecordObject } from './collections/HamokRecord'; import { HelloNotification } from './messages/messagetypes/HelloNotification'; import { EndpointStatesNotification } from './messages/messagetypes/EndpointNotification'; +import { JoinNotification } from './messages/messagetypes/JoinNotification'; +import { RemoteMap } from './collections/RemoteMap'; +import { HamokRemoteMap } from './collections/HamokRemoteMap'; const logger = createLogger('Hamok'); -export type HamokConfig = { - // empty - raftLogs?: RaftLogs, +type HamokHelloNotificationCustomRequestType = 'snapshot'; + +export type HamokJoinProcessParams = { + + /** + * Timeout in milliseconds for fetching the remote peers. + * + * DEFAULT: 5000 + */ + fetchRemotePeerTimeoutInMs?: number, + + /** + * The maximum number of retries for fetching the remote peers. + * -1 - means infinite retries + * 0 - means no retries + * + * DEFAULT: 3 + */ + maxRetry?: number; + + /** + * Indicate if the remote peers automatically should be removed if no heartbeat is received. + * + * DEFAULT: true + */ + removeRemotePeersOnNoHeartbeat?: boolean, + + /** + * indicates if the snapshot should be requested from the remote peers, + * and if it is provided then it is used in local + * + * DEFAULT: true + */ + requestSnapshot?: boolean, + + /** + * indicates if the start() method should be called automatically after the join process is completed + * + * DEFAULT: false + */ + startAfterJoin?: boolean, +} + +export type HamokConfig = Record> = { + + /** + * Indicate if the Hamok should stop automatically when there are no remote peers. + */ + autoStopOnNoRemotePeers?: boolean, + + /** + * A custom appData object to be used by the application utilizes Hamok. + */ + appData: AppData, } /** * Configuration settings for the Hamok constructor, extending RaftEngineConfig and HamokConfig. */ -export type HamokConstructorConfig = RaftEngineConfig & HamokConfig & { +export type HamokConstructorConfig = Record> = RaftEngineConfig & HamokConfig & { /** * Optional. The expiration time in milliseconds for log entries. @@ -57,6 +111,18 @@ export type HamokConstructorConfig = RaftEngineConfig & HamokConfig & { * automatically stopping notifications for explicitly postponed requests. */ ongoingRequestsSendingPeriodInMs: number; + + /** + * Optional. A custom implementation of RaftLogs to store log entries. + */ + raftLogs?: RaftLogs, + + /** + * Optional. In case snapshots are requested by a join operation this is the codec to encode and decode the snapshot. + * + * DEFAULT: JSON codec + */ + snapshotCodec?: HamokCodec, } /** @@ -161,6 +227,25 @@ export type HamokMapBuilderConfig = { equalValues?: (a: V, b: V) => boolean, } +/** + * Configuration settings for building a Hamok remote map. + */ +export type HamokRemoteMapBuilderConfig = Omit, 'baseMap'> & { + + /** + * The remote map to be used to store the data. + */ + remoteMap: RemoteMap, + + /** + * Flag indicate if the events should be emitted by the event emitter or not. + * It also reduces the communication overhead if not needed, as for emitting events + * the leader should send a message to all followers to emit an event. + * In such case when it's not necessary (like cache maintenance) it can be disabled. + */ + noEvents?: boolean, +} + /** * Configuration settings for building a Hamok queue. */ @@ -256,6 +341,7 @@ export type HamokEventMap = { started: [], stopped: [], follower: [], + candidate: [], leader: [], message: [message: HamokMessage] 'remote-peer-joined': [peerId: string], @@ -265,15 +351,23 @@ export type HamokEventMap = { commit: [commitIndex: number, message: HamokMessage], heartbeat: [], error: [error: Error], - 'hello-notification': [remotePeerId: string, request: { - customData: string, - callback: (response: string) => void, - } | undefined], + // 'hello-notification': [remotePeerId: string, request: { + // customData: string, + // callback: (response: string) => void, + // } | undefined], 'no-heartbeat-from': [remotePeerId: string], } -export class Hamok extends EventEmitter { - public readonly config: HamokConfig; +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export declare interface Hamok { + on(event: U, listener: (...args: HamokEventMap[U]) => void): this; + once(event: U, listener: (...args: HamokEventMap[U]) => void): this; + emit(event: U, ...args: HamokEventMap[U]): boolean; +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export class Hamok = Record> extends EventEmitter { + public readonly config: HamokConfig; public readonly raft: RaftEngine; // eslint-disable-next-line @typescript-eslint/no-explicit-any public readonly records = new Map>(); @@ -283,14 +377,19 @@ export class Hamok extends EventEmitter { public readonly queues = new Map>(); // eslint-disable-next-line @typescript-eslint/no-explicit-any public readonly emitters = new Map>(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public readonly remoteMaps = new Map>(); + private _joining?: Promise; private _raftTimer?: ReturnType; private _remoteStateRequest?: { timer: ReturnType, responses: EndpointStatesNotification[] }; private readonly _remoteHeartbeats = new Map>(); private readonly _codec = new HamokGridCodec(); public readonly grid: HamokGrid; - public constructor(providedConfig?: Partial) { + private readonly _snapshotCodec: HamokCodec; + + public constructor(providedConfig?: Partial>) { super(); this.setMaxListeners(Infinity); this._emitMessage = this._emitMessage.bind(this); @@ -298,8 +397,10 @@ export class Hamok extends EventEmitter { this._acceptCommit = this._acceptCommit.bind(this); this._emitRemotePeerRemoved = this._emitRemotePeerRemoved.bind(this); + this._snapshotCodec = providedConfig?.snapshotCodec ?? createHamokJsonStringCodec(); + const raftLogs = providedConfig?.raftLogs ?? new MemoryStoredRaftLogs({ - expirationTimeInMs: 0, + expirationTimeInMs: providedConfig?.logEntriesExpirationTimeInMs ?? 0, memorySizeHighWaterMark: 0, }); @@ -316,6 +417,7 @@ export class Hamok extends EventEmitter { ); this.config = { + appData: providedConfig?.appData ?? ({} as AppData), }; this.grid = new HamokGrid( @@ -334,6 +436,10 @@ export class Hamok extends EventEmitter { ); } + public get appData(): AppData { + return this.config.appData; + } + public get localPeerId(): string { return this.raft.localPeerId; } @@ -356,7 +462,7 @@ export class Hamok extends EventEmitter { public start(): void { if (this._raftTimer) { - return; + return logger.debug('Hamok is already running'); } const raftEngine = this.raft; @@ -376,7 +482,7 @@ export class Hamok extends EventEmitter { this.emit('started'); } - + public stop() { if (!this._raftTimer) { return; @@ -401,8 +507,43 @@ export class Hamok extends EventEmitter { this.emit('stopped'); } + public get stats() { + const numberOfPendingRequests = this.grid.pendingRequests.size; + const numberOfOngoingRequests = this.grid.ongoingRequestsNotifier.activeOngoingRequests.size; + const numberOfRemotePeers = this.raft.remotePeers.size; + const numberOfPendingResponses = this.grid.pendingResponses.size; + const raftLogsBytesInMemory = this.raft.logs.bytesInMemory; + + return { + /** + * Number of requests sent out from the grid, but waiting for response from remote peer + */ + numberOfPendingRequests, + + /** + * Number of requests received by this peer and queued for processing (for example requests to be waited to be committed by the leader) + */ + numberOfOngoingRequests, + + /** + * Number of responses received by this peer and queued for processing as the response were chunked + */ + numberOfPendingResponses, + + /** + * Number of remote peers this peer is connected to + */ + numberOfRemotePeers, + + /** + * Number of bytes used by the raft logs in memory + */ + raftLogsBytesInMemory, + }; + } + private _acceptCommit(commitIndex: number, message: HamokMessage): void { - logger.debug('%s accepted committed message %o', this.localPeerId, message); + logger.trace('%s accepted committed message %o', this.localPeerId, message); // if we put this request to hold when we accepted the submit request we remove the ongoing request notification // so from this moment it is up to the storage / pubsub to accomplish the request @@ -411,10 +552,13 @@ export class Hamok extends EventEmitter { logger.trace('%s Request %s is removed ongoing requests', this.localPeerId, message.requestId); } - this.accept(message); + this.accept(message, commitIndex); } public addRemotePeerId(remoteEndpointId: string): void { + if (remoteEndpointId === this.localPeerId) return; + if (this.raft.remotePeers.has(remoteEndpointId)) return; + this.raft.remotePeers.add(remoteEndpointId); logger.debug('%s added remote peer %s', this.localPeerId, remoteEndpointId); @@ -423,11 +567,17 @@ export class Hamok extends EventEmitter { } public removeRemotePeerId(remoteEndpointId: string): void { - this.raft.remotePeers.delete(remoteEndpointId); + if (!this.raft.remotePeers.delete(remoteEndpointId)) return; logger.debug('%s removed remote peer %s', this.localPeerId, remoteEndpointId); this.emit('remote-peer-left', remoteEndpointId); + + if (this.remotePeerIds.size === 0) { + if (this.config.autoStopOnNoRemotePeers) { + this.stop(); + } + } } public export(): HamokSnapshot { @@ -442,6 +592,7 @@ export class Hamok extends EventEmitter { maps: [ ...this.maps.values() ].map((storage) => storage.export()), queues: [ ...this.queues.values() ].map((queue) => queue.export()), emitters: [ ...this.emitters.values() ].map((emitter) => emitter.export()), + remoteMaps: [ ...this.remoteMaps.values() ].map((storage) => storage.export()), }; for (const storage of this.maps.values()) { @@ -500,6 +651,17 @@ export class Hamok extends EventEmitter { emitter.import(emitterSnapshot); } + for (const remoteMapSnapshot of snapshot.remoteMaps) { + const remoteMap = this.remoteMaps.get(remoteMapSnapshot.mapId); + + if (!remoteMap) { + logger.warn('Cannot import remote map snapshot, because remote map %s is not found. snapshot: %o', remoteMapSnapshot.mapId, remoteMapSnapshot); + continue; + } + + remoteMap.import(remoteMapSnapshot); + } + const oldTerm = this.raft.props.currentTerm; const oldCommitIndex = this.raft.logs.commitIndex; @@ -585,6 +747,57 @@ export class Hamok extends EventEmitter { return storage; } + public createRemoteMap(options: HamokRemoteMapBuilderConfig): HamokRemoteMap { + if (this.remoteMaps.has(options.mapId)) { + throw new Error(`RemoteMap with id ${options.mapId} already exists`); + } + + const storageCodec = new StorageCodec( + options.keyCodec ?? createHamokJsonBinaryCodec(), + options.valueCodec ?? createHamokJsonBinaryCodec(), + ); + const connection = new HamokConnection( + { + requestTimeoutInMs: options.requestTimeoutInMs ?? 5000, + storageId: options.mapId, + neededResponse: 0, + maxOutboundKeys: options.maxOutboundMessageKeys ?? 0, + maxOutboundValues: options.maxOutboundMessageValues ?? 0, + maxMessageWaitingTimeInMs: options.maxMessageWaitingTimeInMs, + submitting: new Set([ + HamokMessageType.CLEAR_ENTRIES_REQUEST, + HamokMessageType.INSERT_ENTRIES_REQUEST, + HamokMessageType.DELETE_ENTRIES_REQUEST, + HamokMessageType.REMOVE_ENTRIES_REQUEST, + HamokMessageType.UPDATE_ENTRIES_REQUEST, + ]) + }, + storageCodec, + this.grid, + ); + const messageListener = (message: HamokMessage, submitting: boolean) => { + if (submitting) return this.submit(message); + else this.emit('message', message); + }; + const storage = new HamokRemoteMap( + connection, + options.remoteMap, + options.equalValues, + ); + + storage.emitEvents = options.noEvents ?? true; + + connection.once('close', () => { + connection.off('message', messageListener); + this.maps.delete(storage.id); + }); + connection.on('message', messageListener); + + this.remoteMaps.set(storage.id, storage); + + return storage; + } + public createRecord(options: HamokRecordBuilderConfig): HamokRecord { if (this.maps.has(options.recordId)) { throw new Error(`Record with id ${options.recordId} already exists`); @@ -783,7 +996,7 @@ export class Hamok extends EventEmitter { } } - public accept(message: HamokMessage) { + public accept(message: HamokMessage, commitIndex?: number): void { if (message.destinationId && message.destinationId !== this.localPeerId) { return logger.trace('%s Received message address is not matching with the local peer %o', this.localPeerId, message); } @@ -812,7 +1025,9 @@ export class Hamok extends EventEmitter { switch (message.type) { case HamokMessageType.RAFT_APPEND_ENTRIES_REQUEST_CHUNK: case HamokMessageType.RAFT_APPEND_ENTRIES_RESPONSE: - this._acceptAppendRequestResponse(message); + case HamokMessageType.RAFT_VOTE_REQUEST: + case HamokMessageType.RAFT_VOTE_RESPONSE: + this._acceptKeepAliveHamokMessage(message); break; } this.raft.transport.receive(message); @@ -825,6 +1040,7 @@ export class Hamok extends EventEmitter { const storage = ( this.records.get(message.storageId ?? '') ?? this.maps.get(message.storageId ?? '') ?? + this.remoteMaps.get(message.storageId ?? '') ?? this.queues.get(message.storageId ?? '') ?? this.emitters.get(message.storageId ?? '') ); @@ -833,7 +1049,7 @@ export class Hamok extends EventEmitter { return logger.trace('Received message for unknown collection %s', message.storageId); } - return storage.connection.accept(message); + return storage.connection.accept(message, commitIndex); } // case HamokMessageProtocol.PUBSUB_COMMUNICATION_PROTOCOL: // this._dispatchToPubSub(message); @@ -842,8 +1058,117 @@ export class Hamok extends EventEmitter { } } - public async fetchRemotePeers(options?: { customRequest?: string, timeoutInMs?: number }): Promise { - const helloMsg = this._codec.encodeHelloNotification(new HelloNotification(this.localPeerId, this.raft.leaderId)); + public async join(params?: HamokJoinProcessParams): Promise { + if (this._joining) return this._joining; + try { + this._joining = this._join({ + startAfterJoin: params?.startAfterJoin ?? true, + fetchRemotePeerTimeoutInMs: params?.fetchRemotePeerTimeoutInMs ?? 5000, + requestSnapshot: params?.requestSnapshot ?? true, + maxRetry: params?.maxRetry ?? 3, + removeRemotePeersOnNoHeartbeat: params?.removeRemotePeersOnNoHeartbeat ?? true, + }); + + await this._joining; + } finally { + this._joining = undefined; + } + } + + private async _join(params: Required, retried = 0): Promise { + const { + startAfterJoin, + fetchRemotePeerTimeoutInMs, + requestSnapshot, + maxRetry, + removeRemotePeersOnNoHeartbeat, + } = params ?? {}; + + logger.debug('Joining the network. startAfterJoin: %s, fetchRemotePeerTimeoutInMs: %s, requestSnapshot: %s, maxRetry: %s, removeRemotePeersOnNoHeartbeat: %s', + startAfterJoin, fetchRemotePeerTimeoutInMs, requestSnapshot, maxRetry, removeRemotePeersOnNoHeartbeat + ); + + const { remotePeers, customResponses } = await this.fetchRemotePeers( + fetchRemotePeerTimeoutInMs, + requestSnapshot ? 'snapshot' : undefined + ); + let bestSnapshot: HamokSnapshot | undefined; + + if (remotePeers.length < 1) { + if (0 <= maxRetry && maxRetry <= retried) throw new Error('No remote peers found'); + + logger.warn('No remote peers found, retrying %s/%s', retried, maxRetry < 0 ? '∞' : maxRetry); + + return this._join(params, retried + 1); + } + + logger.debug('Remote peers found %o', remotePeers); + + if (requestSnapshot) { + for (const serializedSnapshot of customResponses ?? []) { + try { + const snapshot = this._snapshotCodec.decode(serializedSnapshot); + + if (!bestSnapshot) bestSnapshot = snapshot; + + if (bestSnapshot.term < snapshot.term || bestSnapshot.commitIndex < snapshot.commitIndex) { + bestSnapshot = snapshot; + } + } catch (err) { + logger.error('Failed to parse snapshot %o', err); + } + } + } + + logger.debug('Best snapshot %o', bestSnapshot); + + if (bestSnapshot) { + try { + this.import(bestSnapshot); + } catch (err) { + logger.error('Failed to import snapshot %o', err); + } + } + + if (removeRemotePeersOnNoHeartbeat) { + const noHeartbeatListener = (remotePeerId: string) => this.removeRemotePeerId(remotePeerId); + + this.once('stopped', () => { + this.off('no-heartbeat-from', noHeartbeatListener); + }); + this.on('no-heartbeat-from', noHeartbeatListener); + } + + const joinMsg = this._codec.encodeJoinNotification(new JoinNotification(this.localPeerId)); + + // this will trigger the remote endpoint to add this endpoint + this._emitMessage(joinMsg); + + if (startAfterJoin) { + let leaderElected: () => void | undefined; + let noMoreRemotePeers: () => void | undefined; + + return new Promise((resolve, reject) => { + leaderElected = () => (this.raft.leaderId !== undefined ? resolve() : void 0); + noMoreRemotePeers = () => (this.remotePeerIds.size === 0 ? reject(new Error('No remote peers')) : void 0); + + this.on('leader-changed', leaderElected); + this.on('remote-peer-left', noMoreRemotePeers); + this.start(); + + }).finally(() => { + this.off('leader-changed', leaderElected); + this.off('remote-peer-left', noMoreRemotePeers); + }); + } + } + + public async fetchRemotePeers(timeout?: number, customRequest?: HamokHelloNotificationCustomRequestType): Promise { + const helloMsg = this._codec.encodeHelloNotification(new HelloNotification( + this.localPeerId, + this.raft.leaderId, + customRequest + )); return new Promise((resolve) => { const remotePeerIds = new Set(); @@ -865,7 +1190,7 @@ export class Hamok extends EventEmitter { remotePeers: [ ...remotePeerIds ], customResponses: 0 < customResponses.length ? customResponses : undefined, }); - }, options?.timeoutInMs ?? 3000); + }, timeout ?? 5000); this._remoteStateRequest = { timer, @@ -880,35 +1205,38 @@ export class Hamok extends EventEmitter { switch (message.type) { case HamokMessageType.HELLO_NOTIFICATION: { const hello = this._codec.decodeHelloNotification(message); - const customRequest = hello.customData; - let replying: Promise | undefined; - - if (customRequest) { - replying = new Promise((resolve) => { - if (!this.emit('hello-notification', hello.sourcePeerId, { - customData: customRequest, - callback: (response) => resolve(response), - })) { - logger.warn('%s Received hello notification with custom data but no listener is registered %o', this.localPeerId, hello); - resolve(undefined); - } - }); - } else this.emit('hello-notification', hello.sourcePeerId, undefined); - - (replying ?? Promise.resolve(undefined)).then((customResponse) => { - const notification = this._codec.encodeEndpointStateNotification(new EndpointStatesNotification( - this.localPeerId, - hello.sourcePeerId, - this.raft.props.currentTerm, - this.raft.logs.commitIndex, - this.leader ? this.raft.logs.nextIndex : -1, - this.raft.logs.size, - this.raft.remotePeers, - customResponse - )); - - this.emit('message', notification); - }); + let customResponse: string | undefined; + + switch (hello.customData as HamokHelloNotificationCustomRequestType) { + case 'snapshot': { + const snapshot = this.export(); + + customResponse = this._snapshotCodec.encode(snapshot); + } + } + + const notification = this._codec.encodeEndpointStateNotification(new EndpointStatesNotification( + this.localPeerId, + hello.sourcePeerId, + this.raft.props.currentTerm, + this.raft.logs.commitIndex, + this.leader ? this.raft.logs.nextIndex : -1, + this.raft.logs.size, + this.raft.remotePeers, + customResponse + )); + + this.emit('message', notification); + break; + } + case HamokMessageType.JOIN_NOTIFICATION: { + const notification = this._codec.decodeJoinNotification(message); + + if (notification.sourcePeerId !== this.localPeerId) { + this.addRemotePeerId(notification.sourcePeerId); + } else { + logger.warn('%s Received join notification from itself %o', this.localPeerId, notification); + } break; } @@ -969,6 +1297,7 @@ export class Hamok extends EventEmitter { this.maps.values(), this.queues.values(), this.emitters.values(), + this.remoteMaps.values(), ]) { for (const collection of iterator) { collection.connection.emit('leader-changed', leaderId); @@ -1043,15 +1372,27 @@ export class Hamok extends EventEmitter { } } - private _acceptAppendRequestResponse(message: HamokMessage) { + private _acceptKeepAliveHamokMessage(message: HamokMessage) { if (!message.sourceId || message.sourceId === this.localPeerId) return; const remotePeerId = message.sourceId; - clearTimeout(this._remoteHeartbeats.get(message.sourceId)); + this._addNoHeartbeatTimer(remotePeerId); + } + + private _addNoHeartbeatTimer(remotePeerId: string) { + clearTimeout(this._remoteHeartbeats.get(remotePeerId)); + + logger.trace('%s Add no heartbeat timeout for %s', this.localPeerId, remotePeerId); - this._remoteHeartbeats.set(remotePeerId, setTimeout(() => { + const timer = setTimeout(() => { this._remoteHeartbeats.delete(remotePeerId); + + if (this._joining) { + return this._addNoHeartbeatTimer(remotePeerId); + } this.emit('no-heartbeat-from', remotePeerId); - }, this.raft.config.followerMaxIdleInMs)); + }, this.raft.config.electionTimeoutInMs); + + this._remoteHeartbeats.set(remotePeerId, timer); } -} \ No newline at end of file +} diff --git a/src/HamokSnapshot.ts b/src/HamokSnapshot.ts index 5608231..8669737 100644 --- a/src/HamokSnapshot.ts +++ b/src/HamokSnapshot.ts @@ -10,6 +10,11 @@ export type HamokMapSnapshot = { values: Uint8Array[]; } +export type HamokRemoteMapSnapshot = { + mapId: string; + appliedCommitIndex?: number; +} + export type HamokQueueSnapshot = { queueId: string; keys: Uint8Array[]; @@ -33,4 +38,5 @@ export type HamokSnapshot = { maps: HamokMapSnapshot[]; queues: HamokQueueSnapshot[]; emitters: HamokEmitterSnapshot[]; + remoteMaps: HamokRemoteMapSnapshot[]; } \ No newline at end of file diff --git a/src/collections/HamokConnection.ts b/src/collections/HamokConnection.ts index 4f166c0..394f0c1 100644 --- a/src/collections/HamokConnection.ts +++ b/src/collections/HamokConnection.ts @@ -13,13 +13,14 @@ import { ClearEntriesRequest, ClearEntriesNotification, ClearEntriesResponse } f import { DeleteEntriesRequest, DeleteEntriesNotification, DeleteEntriesResponse } from '../messages/messagetypes/DeleteEntries'; import { GetKeysRequest, GetKeysResponse } from '../messages/messagetypes/GetKeys'; import { GetSizeRequest } from '../messages/messagetypes/GetSize'; -import { InsertEntriesRequest, InsertEntriesNotification, InsertEntriesResponse } from '../messages/messagetypes/InsertEntries'; -import { RemoveEntriesRequest, RemoveEntriesNotification, RemoveEntriesResponse } from '../messages/messagetypes/RemoveEntries'; -import { UpdateEntriesRequest, UpdateEntriesNotification, UpdateEntriesResponse } from '../messages/messagetypes/UpdateEntries'; +import { InsertEntriesRequest, InsertEntriesNotification, InsertEntriesResponse, EntriesInsertedNotification } from '../messages/messagetypes/InsertEntries'; +import { RemoveEntriesRequest, RemoveEntriesNotification, RemoveEntriesResponse, EntriesRemovedNotification } from '../messages/messagetypes/RemoveEntries'; +import { UpdateEntriesRequest, UpdateEntriesNotification, UpdateEntriesResponse, EntryUpdatedNotification } from '../messages/messagetypes/UpdateEntries'; import { createResponseChunker, ResponseChunker } from '../messages/ResponseChunker'; import * as Collections from '../common/Collections'; import { HamokGrid } from '../HamokGrid'; import { WaitingQueue } from '../common/WaitingQueue'; +import { StorageAppliedCommitNotification } from '../messages/messagetypes/StorageAppliedCommit'; const logger = createLogger('HamokConnection'); @@ -74,19 +75,23 @@ export type HamokConnectionEventMap = { close: [], OngoingRequestsNotification: [OngoingRequestsNotification]; - ClearEntriesRequest: [ClearEntriesRequest]; + ClearEntriesRequest: [ClearEntriesRequest, commitIndex?: number]; ClearEntriesNotification: [ClearEntriesNotification]; GetEntriesRequest: [GetEntriesRequest]; GetKeysRequest: [GetKeysRequest]; GetSizeRequest: [GetSizeRequest]; - DeleteEntriesRequest: [DeleteEntriesRequest]; + DeleteEntriesRequest: [DeleteEntriesRequest, commitIndex?: number]; DeleteEntriesNotification: [DeleteEntriesNotification]; - RemoveEntriesRequest: [RemoveEntriesRequest]; + RemoveEntriesRequest: [RemoveEntriesRequest, commitIndex?: number]; RemoveEntriesNotification: [RemoveEntriesNotification]; - InsertEntriesRequest: [InsertEntriesRequest]; + EntriesRemovedNotification: [EntriesRemovedNotification]; + InsertEntriesRequest: [InsertEntriesRequest, commitIndex?: number]; InsertEntriesNotification: [InsertEntriesNotification]; - UpdateEntriesRequest: [UpdateEntriesRequest]; + EntriesInsertedNotification: [EntriesInsertedNotification]; + UpdateEntriesRequest: [UpdateEntriesRequest, commitIndex?: number]; UpdateEntriesNotification: [UpdateEntriesNotification]; + EntryUpdatedNotification: [EntryUpdatedNotification]; + StorageAppliedCommitNotification: [StorageAppliedCommitNotification]; } export type HamokConnectionResponseMap = { @@ -99,7 +104,15 @@ export type HamokConnectionResponseMap = { UpdateEntriesResponse: UpdateEntriesResponse; } -export class HamokConnection extends EventEmitter> { +export declare interface HamokConnection { + on>(event: U, listener: (...args: HamokConnectionEventMap[U]) => void): this; + once>(event: U, listener: (...args: HamokConnectionEventMap[U]) => void): this; + off>(event: U, listener: (...args: HamokConnectionEventMap[U]) => void): this; + emit>(event: U, ...args: HamokConnectionEventMap[U]): boolean; +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export class HamokConnection extends EventEmitter { private readonly _responseChunker: ResponseChunker; private _waitingQueue?: WaitingQueue; @@ -162,13 +175,115 @@ export class HamokConnection extends EventEmitter extends EventEmitter this._sendMessage(notification, targetPeerIds)); } + public notifyEntriesRemoved(entries: ReadonlyMap, targetPeerIds?: ReadonlySet | string[] | string) { + Collections.splitMap( + entries, + Math.max(this.config.maxOutboundKeys ?? 0, this.config.maxOutboundValues ?? 0), + () => [ entries ] + ) + .map((batchedEntries) => this.codec.encodeEntriesRemovedNotification(new EntriesRemovedNotification(batchedEntries))) + .forEach((notification) => this._sendMessage(notification, targetPeerIds)); + } + public async requestInsertEntries( entries: ReadonlyMap, targetPeerIds?: ReadonlySet | string[] @@ -370,6 +495,16 @@ export class HamokConnection extends EventEmitter this._sendMessage(notification, targetPeerIds)); } + public notifyEntriesInserted(entries: ReadonlyMap, targetPeerIds?: ReadonlySet | string[] | string) { + Collections.splitMap( + entries, + Math.max(this.config.maxOutboundKeys ?? 0, this.config.maxOutboundValues ?? 0), + () => [ entries ] + ) + .map((batchedEntries) => this.codec.encodeEntriesInsertedNotification(new EntriesInsertedNotification(batchedEntries))) + .forEach((notification) => this._sendMessage(notification, targetPeerIds)); + } + public async requestUpdateEntries( entries: ReadonlyMap, targetPeerIds?: ReadonlySet | string[] | string, @@ -415,6 +550,22 @@ export class HamokConnection extends EventEmitter this._sendMessage(notification, targetPeerIds)); } + public notifyEntryUpdated(key: K, oldValue: V, newValue: V, targetPeerIds?: ReadonlySet | string[] | string) { + const message = this.codec.encodeEntryUpdatedNotification( + new EntryUpdatedNotification(key, newValue, oldValue) + ); + + this._sendMessage(message, targetPeerIds); + } + + public notifyStorageAppliedCommit(commitIndex: number, targetPeerIds?: ReadonlySet | string[] | string) { + const message = this.codec.encodeStorageAppliedCommitNotification( + new StorageAppliedCommitNotification(commitIndex) + ); + + this._sendMessage(message, targetPeerIds); + } + public respond>(type: U, response: HamokConnectionResponseMap[U], targetPeerIds?: string | string[]): void { let message: HamokMessage | undefined; diff --git a/src/collections/HamokMap.ts b/src/collections/HamokMap.ts index 26a074a..287a308 100644 --- a/src/collections/HamokMap.ts +++ b/src/collections/HamokMap.ts @@ -15,10 +15,18 @@ export type HamokMapEventMap = { 'close': [], } +export declare interface HamokMap { + on>(event: U, listener: (...args: HamokMapEventMap[U]) => void): this; + off>(event: U, listener: (...args: HamokMapEventMap[U]) => void): this; + once>(event: U, listener: (...args: HamokMapEventMap[U]) => void): this; + emit>(event: U, ...args: HamokMapEventMap[U]): boolean; +} + /** * Replicated storage replicates all entries on all distributed storages */ -export class HamokMap extends EventEmitter> { +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export class HamokMap extends EventEmitter { private _closed = false; public equalValues: (a: V, b: V) => boolean; diff --git a/src/collections/HamokQueue.ts b/src/collections/HamokQueue.ts index 5639049..0fd6516 100644 --- a/src/collections/HamokQueue.ts +++ b/src/collections/HamokQueue.ts @@ -34,7 +34,15 @@ function *iterator(first: number, last: number, baseMap: BaseMap): } } -export class HamokQueue extends EventEmitter> { +export declare interface HamokQueue { + on>(event: U, listener: (...args: HamokQueueEventMap[U]) => void): this; + off>(event: U, listener: (...args: HamokQueueEventMap[U]) => void): this; + once>(event: U, listener: (...args: HamokQueueEventMap[U]) => void): this; + emit>(event: U, ...args: HamokQueueEventMap[U]): boolean; +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export class HamokQueue extends EventEmitter { private _head = 0; private _tail = 0; private _closed = false; diff --git a/src/collections/HamokRecord.ts b/src/collections/HamokRecord.ts index cefd662..da8b669 100644 --- a/src/collections/HamokRecord.ts +++ b/src/collections/HamokRecord.ts @@ -28,10 +28,18 @@ export type HamokRecordEventMap = { 'close': [], } +export declare interface HamokRecord { + on>(event: U, listener: (...args: HamokRecordEventMap[U]) => void): this; + off>(event: U, listener: (...args: HamokRecordEventMap[U]) => void): this; + once>(event: U, listener: (...args: HamokRecordEventMap[U]) => void): this; + emit>(event: U, ...args: HamokRecordEventMap[U]): boolean; +} + /** * Replicated storage replicates all entries on all distributed storages */ -export class HamokRecord extends EventEmitter> { +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export class HamokRecord extends EventEmitter { private _payloadsCodec?: Map>; private _closed = false; public equalValues: (a: T[K], b: T[K]) => boolean; diff --git a/src/collections/HamokRemoteMap.ts b/src/collections/HamokRemoteMap.ts new file mode 100644 index 0000000..50ec820 --- /dev/null +++ b/src/collections/HamokRemoteMap.ts @@ -0,0 +1,438 @@ +import { EventEmitter } from 'events'; +import { createLogger } from '../common/logger'; +import { HamokConnection } from './HamokConnection'; +import * as Collections from '../common/Collections'; +import { ConcurrentExecutor } from '../common/ConcurrentExecutor'; +import { RemoteMap } from './RemoteMap'; +import { HamokRemoteMapSnapshot } from '../HamokSnapshot'; + +const logger = createLogger('HamokRemoteMap'); + +export type HamokRemoteMapEventMap = { + 'insert': [key: K, value: V], + 'update': [key: K, oldValue: V, newValue: V], + 'remove': [key: K, value: V], + 'clear': [], + 'close': [], +} + +export declare interface HamokRemoteMap { + on>(event: U, listener: (...args: HamokRemoteMapEventMap[U]) => void): this; + off>(event: U, listener: (...args: HamokRemoteMapEventMap[U]) => void): this; + once>(event: U, listener: (...args: HamokRemoteMapEventMap[U]) => void): this; + emit>(event: U, ...args: HamokRemoteMapEventMap[U]): boolean; +} + +/** + * A remote map is a map that is stored on a remote endpoint and magaged by Hamok instances + */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export class HamokRemoteMap extends EventEmitter { + private _closed = false; + public equalValues: (a: V, b: V) => boolean; + private readonly _executor = new ConcurrentExecutor(1); + + public emitEvents = true; + + /** + * The last commit index that was applied to the map + */ + private _appliedCommitIndex?: number; + + /** + * This is for a transitional time when no leader is elected. + */ + private _waitingForLeader?: { + promise: Promise, + resolve: () => void, + }; + + /** + * Whether this endpoint is the leader + */ + private _leader = false; + + private async _executeIfLeader(supplier: () => Promise, commitIndex?: number, onCompleted?: (input: T) => void) { + if (this._closed) throw new Error(`Cannot execute on a closed storage (${this.id})`); + + logger.trace('Executing action for %s, appliedCommitIndex: %d, commitIndex: %d', + this.id, + this._appliedCommitIndex, + commitIndex + ); + + if (commitIndex === undefined) { + return logger.warn('Commit index is undefined for %s', this.id); + } else if (this._waitingForLeader) { + await this._waitingForLeader.promise; + } else if (!this._leader) { + return logger.trace('Not the leader for %s', this.id); + } + + logger.trace('Executing action for %s, commitIndex: %d', this.id, commitIndex); + + try { + const input = await this._executor.execute(supplier); + + if (commitIndex <= (this._appliedCommitIndex ?? -1)) { + logger.warn('Commit index is less than the applied commit index for %s. appliedCommitIndex: %d, commitIndex: %d', this.id, this._appliedCommitIndex, commitIndex); + } + this._appliedCommitIndex = commitIndex; + this.connection.notifyStorageAppliedCommit(this._appliedCommitIndex); + + onCompleted?.(input); + } catch (err) { + logger.error('Error executing on %s', this.id, err); + } + } + + public constructor( + public readonly connection: HamokConnection, + public readonly remoteMap: RemoteMap, + equalValues?: (a: V, b: V) => boolean, + ) { + super(); + this.setMaxListeners(Infinity); + + this.equalValues = equalValues ?? ((a, b) => { + // logger.info('Comparing values: %o (%s), %o (%s)', a, b, JSON.stringify(a), JSON.stringify(b)); + return JSON.stringify(a) === JSON.stringify(b); + }); + + this.connection + .on('StorageAppliedCommitNotification', (notification) => { + if (!this._leader) { + this._appliedCommitIndex = notification.appliedCommitIndex; + } + }) + .on('ClearEntriesRequest', (request, commitIndex) => { + this._executeIfLeader(() => this.remoteMap.clear(), commitIndex, () => { + this.connection.respond( + 'ClearEntriesResponse', + request.createResponse(), + request.sourceEndpointId + ); + + if (this.emitEvents) { + this.connection.notifyClearEntries(this.connection.grid.remotePeerIds); + this.emit('clear'); + } + }); + }) + .on('ClearEntriesNotification', () => this.emit('clear')) + .on('DeleteEntriesRequest', (request, commitIndex) => { + this._executeIfLeader(() => this.remoteMap.removeAll(request.keys.values()), commitIndex, (removedEntries) => { + this.connection.respond( + 'DeleteEntriesResponse', + request.createResponse( + new Set(removedEntries.keys()) + ), + request.sourceEndpointId + ); + + if (this.emitEvents) { + this.connection.notifyEntriesRemoved(removedEntries, this.connection.grid.remotePeerIds); + removedEntries.forEach((v, k) => this.emit('remove', k, v)); + } + }); + }) + .on('InsertEntriesRequest', (request, commitIndex) => { + this._executeIfLeader(async () => { + const existingEntries = await this.remoteMap.getAll(request.entries.keys()); + const insertedEntries = new Map(); + + for (const [ key, value ] of request.entries) { + if (existingEntries.has(key)) { + continue; + } + insertedEntries.set(key, value); + } + + await this.remoteMap.setAll(request.entries); + + return insertedEntries; + }, commitIndex, (insertedEntries) => { + this.connection.respond( + 'InsertEntriesResponse', + request.createResponse(new Map()), + request.sourceEndpointId + ); + + if (this.emitEvents) { + this.connection.notifyEntriesInserted(insertedEntries, this.connection.grid.remotePeerIds); + insertedEntries.forEach((value, key) => this.emit('insert', key, value)); + } + }); + }) + .on('EntriesInsertedNotification', (insertedEntries) => insertedEntries.entries.forEach((v, k) => this.emit('insert', k, v))) + .on('RemoveEntriesRequest', (request, commitIndex) => { + this._executeIfLeader(() => this.remoteMap.removeAll(request.keys.values()), commitIndex, (removedEntries) => { + this.connection.respond( + 'RemoveEntriesResponse', + request.createResponse( + removedEntries + ), + request.sourceEndpointId + ); + + if (this.emitEvents) { + this.connection.notifyEntriesRemoved(removedEntries, this.connection.grid.remotePeerIds); + removedEntries.forEach((v, k) => this.emit('remove', k, v)); + } + }); + }) + .on('EntriesRemovedNotification', (removedEntries) => removedEntries.entries.forEach((v, k) => this.emit('remove', k, v))) + .on('UpdateEntriesRequest', (request, commitIndex) => { + // logger.warn('Accepting UpdateEntriesRequest %s, commitIndex: %d', request.requestId, commitIndex); + this._executeIfLeader(async () => { + logger.trace('%s UpdateEntriesRequest: %o, %s', this.connection.grid.localPeerId, request, [ ...request.entries ].join(', ')); + + const updatedEntries: [K, V, V][] = []; + const insertedEntries: [K, V][] = []; + + if (request.prevValue !== undefined) { + // this is a conditional update + if (request.entries.size !== 1) { + // we let the request to timeout + logger.trace('Conditional update request must have only one entry: %o', request); + + return { insertedEntries, updatedEntries }; + } + const [ key, value ] = [ ...request.entries ][0]; + + const existingValue = await this.remoteMap.get(key); + + logger.trace('Conditional update request: %s, %s, %s, %s', key, value, existingValue, request.prevValue); + + if (existingValue && this.equalValues(existingValue, request.prevValue)) { + this.remoteMap.set(key, value); + updatedEntries.push([ key, existingValue, value ]); + } + } else { + await this.remoteMap.setAll(request.entries, ({ inserted, updated }) => { + insertedEntries.push(...inserted); + updatedEntries.push(...updated); + }); + } + + return { insertedEntries, updatedEntries }; + }, commitIndex, ({ insertedEntries, updatedEntries }) => { + this.connection.respond( + 'UpdateEntriesResponse', + request.createResponse(new Map(updatedEntries.map(([ key, oldValue ]) => [ key, oldValue ]))), + request.sourceEndpointId + ); + + if (this.emitEvents) { + insertedEntries.forEach(([ key, value ]) => this.emit('insert', key, value)); + this.connection.notifyEntriesInserted(new Map(insertedEntries), this.connection.grid.remotePeerIds); + + updatedEntries.forEach(([ key, oldValue, newValue ]) => { + this.emit('update', key, oldValue, newValue); + this.connection.notifyEntryUpdated(key, oldValue, newValue, this.connection.grid.remotePeerIds); + }); + } + }); + + }) + .on('EntryUpdatedNotification', ({ key, newValue, oldValue }) => this.emit('update', key, oldValue, newValue)) + .on('leader-changed', (leaderId) => { + if (leaderId === undefined) { + let resolve: () => void = () => void 0; + const promise = new Promise((_resolve) => { + resolve = () => { + this._waitingForLeader = undefined; + _resolve(); + }; + }); + + return (this._waitingForLeader = { promise, resolve }); + } + this._leader = leaderId === this.connection.grid.localPeerId; + this._waitingForLeader?.resolve(); + }) + .once('close', () => this.close()) + ; + } + + public get id(): string { + return this.connection.config.storageId; + } + + public get closed() { + return this._closed; + } + + public close(): void { + if (this._closed) return; + this._closed = true; + + this.connection.close(); + + if (this.emitEvents) { + this.emit('close'); + } + this.removeAllListeners(); + } + + public size() { + return this.remoteMap.size(); + } + + public async isEmpty(): Promise { + return await this.remoteMap.size() === 0; + } + + public keys() { + return this.remoteMap.keys(); + } + + public async clear(): Promise { + if (this._closed) throw new Error(`Cannot clear a closed storage (${this.id})`); + + return this.connection.requestClearEntries(); + } + + public async get(key: K): Promise { + if (this._closed) throw new Error(`Cannot get entries from a closed storage (${this.id})`); + + return this.remoteMap.get(key); + } + + public async getAll(keys: IterableIterator | K[]): Promise> { + if (this._closed) throw new Error(`Cannot get entries from a closed storage (${this.id})`); + + if (Array.isArray(keys)) return this.remoteMap.getAll(keys.values()); + else return this.remoteMap.getAll(keys); + } + + public async set(key: K, value: V): Promise { + if (this._closed) throw new Error(`Cannot set an entry on a closed storage (${this.id})`); + + const result = await this.setAll( + Collections.mapOf([ key, value ]) + ); + + return result.get(key); + } + + public async setAll(entries: ReadonlyMap): Promise> { + if (this._closed) throw new Error(`Cannot set entries on a closed storage (${this.id})`); + + if (entries.size < 1) { + return Collections.emptyMap(); + } + + return this.connection.requestUpdateEntries(entries); + } + + public async insert(key: K, value: V): Promise { + const result = await this.insertAll( + Collections.mapOf([ key, value ]) + ); + + return result.get(key); + } + + public async insertAll(entries: ReadonlyMap | [K, V][]): Promise> { + if (this._closed) throw new Error(`Cannot insert entries on a closed storage (${this.id})`); + + if (Array.isArray(entries)) { + if (entries.length < 1) return Collections.emptyMap(); + entries = Collections.mapOf(...entries); + } + + if (entries.size < 1) { + return Collections.emptyMap(); + } + + return this.connection.requestInsertEntries(entries); + } + + public async delete(key: K): Promise { + const result = await this.deleteAll( + Collections.setOf(key) + ); + + return result.has(key); + } + + public async deleteAll(keys: ReadonlySet | K[]): Promise> { + if (this._closed) throw new Error(`Cannot delete entries on a closed storage (${this.id})`); + + if (Array.isArray(keys)) { + if (keys.length < 1) return Collections.emptySet(); + keys = Collections.setOf(...keys); + } + if (keys.size < 1) { + return Collections.emptySet(); + } + + return this.connection.requestDeleteEntries(keys); + } + + public async remove(key: K): Promise { + const result = await this.removeAll( + Collections.setOf(key) + ); + + return result.has(key); + } + + public async removeAll(keys: ReadonlySet | K[]): Promise> { + if (this._closed) throw new Error(`Cannot remove entries on a closed storage (${this.id})`); + + if (Array.isArray(keys)) { + if (keys.length < 1) return Collections.emptyMap(); + keys = Collections.setOf(...keys); + } + if (keys.size < 1) { + return Collections.emptyMap(); + } + + return this.connection.requestRemoveEntries(keys); + } + + public async updateIf(key: K, value: V, oldValue: V): Promise { + if (this._closed) throw new Error(`Cannot update an entry on a closed storage (${this.id})`); + + logger.trace('%s UpdateIf: %s, %s, %s', this.connection.grid.localPeerId, key, value, oldValue); + + return (await this.connection.requestUpdateEntries( + Collections.mapOf([ key, value ]), + undefined, + oldValue + )).get(key) !== undefined; + } + + public iterator(): AsyncIterableIterator<[K, V]> { + return this.remoteMap.iterator(); + } + + /** + * Exports the storage data + */ + public export(): HamokRemoteMapSnapshot { + if (this._closed) { + throw new Error(`Cannot export data on a closed storage (${this.id})`); + } + const result: HamokRemoteMapSnapshot = { + mapId: this.id, + appliedCommitIndex: this._appliedCommitIndex, + }; + + return result; + } + + public import(data: HamokRemoteMapSnapshot) { + if (data.mapId !== this.id) { + throw new Error(`Cannot import data from a different storage: ${data.mapId} !== ${this.id}`); + } else if (this.connection.connected) { + throw new Error('Cannot import data while connected'); + } else if (this._closed) { + throw new Error(`Cannot import data on a closed storage (${this.id})`); + } + + this._appliedCommitIndex = data.appliedCommitIndex; + } +} diff --git a/src/collections/RemoteMap.ts b/src/collections/RemoteMap.ts new file mode 100644 index 0000000..151746e --- /dev/null +++ b/src/collections/RemoteMap.ts @@ -0,0 +1,17 @@ +export type RemoteMapUpdateResult = { + inserted: [K, V][], + updated: [K, oldvalue: V, newValue: V][], +} + +export interface RemoteMap { + size(): Promise; + clear(): Promise; + keys(): Promise>; + get(key: K): Promise; + getAll(keys: IterableIterator): Promise>; + set(key: K, value: V, callback?: (oldValue: V | undefined) => void): Promise; + setAll(entries: ReadonlyMap, callback?: (result: RemoteMapUpdateResult) => void): Promise; + remove(key: K): Promise; + removeAll(keys: IterableIterator): Promise>; + iterator(): AsyncIterableIterator<[K, V]>; +} diff --git a/src/common/ConcurrentExecutor.ts b/src/common/ConcurrentExecutor.ts index bb457aa..30b2545 100644 --- a/src/common/ConcurrentExecutor.ts +++ b/src/common/ConcurrentExecutor.ts @@ -5,7 +5,8 @@ type OngoingProcess = () => Promise; const logger = createLogger('ConcurrentExecutor'); export class ConcurrentExecutor { - private _tasks:OngoingProcess[] = []; + private _parked: OngoingProcess[] = []; + private _tasks: OngoingProcess[] = []; private _semaphore: number; public constructor( @@ -18,17 +19,24 @@ export class ConcurrentExecutor { this.postProcess = this.postProcess.bind(this); } - public execute(action: () => Promise) { + public execute(action: () => Promise, park = false): Promise { return new Promise((resolve, reject) => { const task = () => action().then(resolve) .catch(reject); - this._tasks.push(task); + if (park) this._parked.push(task); + else this._tasks.push(task); this._run(); }); } + public flushParkedActions() { + this._tasks.push(...this._parked); + this._parked = []; + this._run(); + } + private postProcess() { ++this._semaphore; diff --git a/src/common/HamokCodec.ts b/src/common/HamokCodec.ts index 66bafa2..03864e4 100644 --- a/src/common/HamokCodec.ts +++ b/src/common/HamokCodec.ts @@ -6,6 +6,10 @@ export interface HamokDecoder { decode(data: R): U; } +const EMPTY_MAP = new Map(); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const EMPTY_SET = new Set(); + export function createHamokCodec(encode: (input: U) => R, decode: (input: R) => U): HamokCodec { return { encode, @@ -33,6 +37,14 @@ export function createHamokJsonBinaryCodec(): HamokCodec { }; } +export function createHamokJsonStringCodec(): HamokCodec { + + return { + encode: (data: T) => JSON.stringify(data), + decode: (data: string) => JSON.parse(data), + }; +} + export interface HamokCodec extends HamokEncoder, HamokDecoder {} /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -74,10 +86,7 @@ export class FacadedHamokCodec implements HamokCodec(keys: ReadonlySet, keyCodec: HamokCodec): Uint8Array[] { - if (keys.size < 1) { - return []; - } +export function encodeCollection(keys: IterableIterator, keyCodec: HamokCodec): Uint8Array[] { const result: Uint8Array[] = []; for (const key of keys) { @@ -89,24 +98,34 @@ export function encodeSet(keys: ReadonlySet, keyCodec: HamokCodec(); - -export function decodeSet(keys: Uint8Array[], keyCodec: HamokCodec): ReadonlySet { - if (keys.length < 1) { - return EMPTY_SET; - } - const result = new Set(); +export function decodeCollection(keys: IterableIterator, keyCodec: HamokCodec): K[] { + const result: K[] = []; - for (let i = 0; i < keys.length; ++i) { - const key = keys[i]; + for (const key of keys) { const decodedKey = keyCodec.decode(key); - result.add(decodedKey); + result.push(decodedKey); } return result; } +export function encodeSet(keys: ReadonlySet, keyCodec: HamokCodec): Uint8Array[] { + if (keys.size < 1) { + return []; + } + + return encodeCollection(keys.values(), keyCodec); +} + +export function decodeSet(keys: Uint8Array[], keyCodec: HamokCodec): ReadonlySet { + if (keys.length < 1) { + return EMPTY_SET; + } + + return new Set(decodeCollection(keys.values(), keyCodec)); +} + export function encodeMap(entries: ReadonlyMap, keyCodec: HamokCodec, valueCodec: HamokCodec): [Uint8Array[], Uint8Array[]] { if (entries.size < 1) { return [ [], [] ]; @@ -125,8 +144,6 @@ export function encodeMap(entries: ReadonlyMap, keyCodec: HamokCodec return [ encodedKeys, encodedValues ]; } -const EMPTY_MAP = new Map(); - export function decodeMap(keys: Uint8Array[], values: Uint8Array[], keyCodec: HamokCodec, valueCodec: HamokCodec): ReadonlyMap { if (keys.length < 1 || values.length < 1) { return EMPTY_MAP; diff --git a/src/index.ts b/src/index.ts index 659fc92..d3504df 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,12 +6,17 @@ export { HamokQueueBuilderConfig, HamokRecordBuilderConfig, HamokFetchRemotePeersResponse, + HamokRemoteMapBuilderConfig, HamokEventMap, HamokMapBuilderConfig, + HamokJoinProcessParams, } from './Hamok'; export { - HamokMap as HamokStorage + HamokMap, } from './collections/HamokMap'; +export { + HamokRemoteMap +} from './collections/HamokRemoteMap'; export { HamokQueue } from './collections/HamokQueue'; @@ -21,11 +26,17 @@ export { export { HamokConnection } from './collections/HamokConnection'; +export { + RaftLogs +} from './raft/RaftLogs'; +export { + MemoryStoredRaftLogs +} from './raft/MemoryStoredRaftLogs'; export { HamokSnapshot, HamokEmitterSnapshot, HamokQueueSnapshot, - HamokMapSnapshot as HamokStorageSnapshot, + HamokMapSnapshot, } from './HamokSnapshot'; export { LogEntry @@ -40,6 +51,9 @@ export { export { BaseMap } from './collections/BaseMap'; +export { + RemoteMap +} from './collections/RemoteMap'; export { HamokCodec, createHamokJsonBinaryCodec, diff --git a/src/messages/HamokGridCodec.ts b/src/messages/HamokGridCodec.ts index a938505..21e3620 100644 --- a/src/messages/HamokGridCodec.ts +++ b/src/messages/HamokGridCodec.ts @@ -7,11 +7,13 @@ import { OngoingRequestsNotification } from './messagetypes/OngoingRequests'; import * as Collections from '../common/Collections'; import { EndpointStatesNotification } from './messagetypes/EndpointNotification'; import { HelloNotification } from './messagetypes/HelloNotification'; +import { JoinNotification } from './messagetypes/JoinNotification'; const logger = createLogger('GridCodec'); type Input = HelloNotification | +JoinNotification | EndpointStatesNotification | OngoingRequestsNotification | StorageSyncRequest | @@ -41,6 +43,8 @@ export class HamokGridCodec implements HamokCodec { switch (input.constructor) { case HelloNotification: return this.encodeHelloNotification(input as HelloNotification); + case JoinNotification: + return this.encodeJoinNotification(input as JoinNotification); case EndpointStatesNotification: return this.encodeEndpointStateNotification(input as EndpointStatesNotification); case OngoingRequestsNotification: @@ -58,6 +62,8 @@ export class HamokGridCodec implements HamokCodec { switch (message.type) { case MessageType.HELLO_NOTIFICATION: return this.decodeHelloNotification(message); + case MessageType.JOIN_NOTIFICATION: + return this.decodeJoinNotification(message); case MessageType.ENDPOINT_STATES_NOTIFICATION: return this.decodeEndpointStateNotification(message); case MessageType.ONGOING_REQUESTS_NOTIFICATION: @@ -97,6 +103,27 @@ export class HamokGridCodec implements HamokCodec { ); } + public encodeJoinNotification(notification: JoinNotification): HamokMessage { + return new HamokMessage({ + // eslint-disable-next-line camelcase + protocol: HamokMessageProtocol.GRID_COMMUNICATION_PROTOCOL, + type: MessageType.JOIN_NOTIFICATION, + sourceId: notification.sourcePeerId, + destinationId: notification.destinationPeerId, + }); + } + + public decodeJoinNotification(message: HamokMessage): JoinNotification { + if (message.type !== MessageType.JOIN_NOTIFICATION) { + throw new Error('decodeJoinNotification(): Message type must be JOIN_NOTIFICATION'); + } + + return new JoinNotification( + message.sourceId!, + message.destinationId, + ); + } + public encodeEndpointStateNotification(notification: EndpointStatesNotification): HamokMessage { const activeEndpointIds = setToArray(notification.activeEndpointIds); diff --git a/src/messages/HamokMessage.ts b/src/messages/HamokMessage.ts index f0df892..2738117 100644 --- a/src/messages/HamokMessage.ts +++ b/src/messages/HamokMessage.ts @@ -231,6 +231,15 @@ export enum HamokMessage_MessageType { */ ONGOING_REQUESTS_NOTIFICATION = 3, + /** + * * + * Join notification is sent by a new endpoint to every other endpoint + * in order to join the grid + * + * @generated from enum value: JOIN_NOTIFICATION = 4; + */ + JOIN_NOTIFICATION = 4, + /** * * * Raft Vote request is sent by a raccoon made itself a candidate @@ -408,6 +417,14 @@ export enum HamokMessage_MessageType { */ REMOVE_ENTRIES_NOTIFICATION = 46, + /** + * * + * Notification about the removed entries + * + * @generated from enum value: ENTRIES_REMOVED_NOTIFICATION = 47; + */ + ENTRIES_REMOVED_NOTIFICATION = 47, + /** * * * Insert item(s) only if they don't exist. if they @@ -438,6 +455,14 @@ export enum HamokMessage_MessageType { */ INSERT_ENTRIES_NOTIFICATION = 54, + /** + * * + * Notification about the inserted entries + * + * @generated from enum value: ENTRIES_INSERTED_NOTIFICATION = 55; + */ + ENTRIES_INSERTED_NOTIFICATION = 55, + /** * * * Request an update from a remote storage @@ -461,12 +486,29 @@ export enum HamokMessage_MessageType { * @generated from enum value: UPDATE_ENTRIES_NOTIFICATION = 58; */ UPDATE_ENTRIES_NOTIFICATION = 58, + + /** + * * + * Notification about the updated entries + * + * @generated from enum value: ENTRY_UPDATED_NOTIFICATION = 59; + */ + ENTRY_UPDATED_NOTIFICATION = 59, + + /** + * * + * Notification about the applied commit + * + * @generated from enum value: STORAGE_APPLIED_COMMIT_NOTIFICATION = 60; + */ + STORAGE_APPLIED_COMMIT_NOTIFICATION = 60, } // Retrieve enum metadata with: proto2.getEnumType(HamokMessage_MessageType) proto2.util.setEnumType(HamokMessage_MessageType, "io.github.hamok.dev.schema.HamokMessage.MessageType", [ { no: 1, name: "HELLO_NOTIFICATION" }, { no: 2, name: "ENDPOINT_STATES_NOTIFICATION" }, { no: 3, name: "ONGOING_REQUESTS_NOTIFICATION" }, + { no: 4, name: "JOIN_NOTIFICATION" }, { no: 12, name: "RAFT_VOTE_REQUEST" }, { no: 13, name: "RAFT_VOTE_RESPONSE" }, { no: 16, name: "RAFT_APPEND_ENTRIES_REQUEST_CHUNK" }, @@ -488,12 +530,16 @@ proto2.util.setEnumType(HamokMessage_MessageType, "io.github.hamok.dev.schema.Ha { no: 44, name: "REMOVE_ENTRIES_REQUEST" }, { no: 45, name: "REMOVE_ENTRIES_RESPONSE" }, { no: 46, name: "REMOVE_ENTRIES_NOTIFICATION" }, + { no: 47, name: "ENTRIES_REMOVED_NOTIFICATION" }, { no: 52, name: "INSERT_ENTRIES_REQUEST" }, { no: 53, name: "INSERT_ENTRIES_RESPONSE" }, { no: 54, name: "INSERT_ENTRIES_NOTIFICATION" }, + { no: 55, name: "ENTRIES_INSERTED_NOTIFICATION" }, { no: 56, name: "UPDATE_ENTRIES_REQUEST" }, { no: 57, name: "UPDATE_ENTRIES_RESPONSE" }, { no: 58, name: "UPDATE_ENTRIES_NOTIFICATION" }, + { no: 59, name: "ENTRY_UPDATED_NOTIFICATION" }, + { no: 60, name: "STORAGE_APPLIED_COMMIT_NOTIFICATION" }, ]); /** diff --git a/src/messages/RaftMessageEmitter.ts b/src/messages/RaftMessageEmitter.ts index eae3be4..60920b2 100644 --- a/src/messages/RaftMessageEmitter.ts +++ b/src/messages/RaftMessageEmitter.ts @@ -21,7 +21,16 @@ type EventMap = { RaftAppendEntriesResponse: [response: RaftAppendEntriesResponse] }; -export class RaftMessageEmitter extends EventEmitter { +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export declare interface RaftMessageEmitter { + on(event: U, listener: (...args: EventMap[U]) => void): this; + once(event: U, listener: (...args: EventMap[U]) => void): this; + off(event: U, listener: (...args: EventMap[U]) => void): this; + emit(event: U, ...args: EventMap[U]): boolean; +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export class RaftMessageEmitter extends EventEmitter { public constructor() { super(); this.setMaxListeners(Infinity); diff --git a/src/messages/StorageCodec.ts b/src/messages/StorageCodec.ts index 0af6c5a..1d35606 100644 --- a/src/messages/StorageCodec.ts +++ b/src/messages/StorageCodec.ts @@ -1,14 +1,15 @@ import { HamokCodec, encodeMap, decodeMap, encodeSet, decodeSet } from '../common/HamokCodec'; -import { UpdateEntriesNotification, UpdateEntriesRequest, UpdateEntriesResponse } from './messagetypes/UpdateEntries'; +import { EntryUpdatedNotification, UpdateEntriesNotification, UpdateEntriesRequest, UpdateEntriesResponse } from './messagetypes/UpdateEntries'; import { HamokMessage as Message, HamokMessage_MessageType as MessageType } from './HamokMessage'; import { ClearEntriesNotification, ClearEntriesRequest, ClearEntriesResponse } from './messagetypes/ClearEntries'; import { GetEntriesRequest, GetEntriesResponse } from './messagetypes/GetEntries'; import { GetKeysRequest, GetKeysResponse } from './messagetypes/GetKeys'; import { DeleteEntriesNotification, DeleteEntriesRequest, DeleteEntriesResponse } from './messagetypes/DeleteEntries'; -import { RemoveEntriesNotification, RemoveEntriesRequest, RemoveEntriesResponse } from './messagetypes/RemoveEntries'; -import { InsertEntriesNotification, InsertEntriesRequest, InsertEntriesResponse } from './messagetypes/InsertEntries'; +import { EntriesRemovedNotification, RemoveEntriesNotification, RemoveEntriesRequest, RemoveEntriesResponse } from './messagetypes/RemoveEntries'; +import { EntriesInsertedNotification, InsertEntriesNotification, InsertEntriesRequest, InsertEntriesResponse } from './messagetypes/InsertEntries'; import { GetSizeRequest, GetSizeResponse } from './messagetypes/GetSize'; import { createLogger } from '../common/logger'; +import { StorageAppliedCommitNotification } from './messagetypes/StorageAppliedCommit'; const logger = createLogger('StorageCodec'); @@ -28,12 +29,16 @@ type Input = RemoveEntriesNotification | RemoveEntriesRequest | RemoveEntriesResponse | + EntriesRemovedNotification | InsertEntriesNotification | InsertEntriesRequest | InsertEntriesResponse | + EntriesInsertedNotification | UpdateEntriesNotification | UpdateEntriesRequest | - UpdateEntriesResponse + UpdateEntriesResponse | + EntryUpdatedNotification | + StorageAppliedCommitNotification ; export type StorageCodecMessageMap = { @@ -52,12 +57,16 @@ export type StorageCodecMessageMap = { RemoveEntriesRequest: RemoveEntriesRequest; RemoveEntriesResponse: RemoveEntriesResponse; RemoveEntriesNotification: RemoveEntriesNotification; + EntriesRemovedNotification: EntriesRemovedNotification; InsertEntriesRequest: InsertEntriesRequest; InsertEntriesResponse: InsertEntriesResponse; InsertEntriesNotification: InsertEntriesNotification; + EntriesInsertedNotification: EntriesInsertedNotification; UpdateEntriesRequest: UpdateEntriesRequest; UpdateEntriesResponse: UpdateEntriesResponse; UpdateEntriesNotification: UpdateEntriesNotification; + EntryUpdatedNotification: EntryUpdatedNotification; + StorageAppliedCommitNotification: StorageAppliedCommitNotification; } export class StorageCodec implements HamokCodec, Message> { @@ -123,6 +132,9 @@ export class StorageCodec implements HamokCodec, Message> { case RemoveEntriesNotification: result = this.encodeDeleteEntriesNotification(input as RemoveEntriesNotification); break; + case EntriesRemovedNotification: + result = this.encodeEntriesRemovedNotification(input as EntriesRemovedNotification); + break; case InsertEntriesRequest: result = this.encodeInsertEntriesRequest(input as InsertEntriesRequest); @@ -133,6 +145,9 @@ export class StorageCodec implements HamokCodec, Message> { case InsertEntriesNotification: result = this.encodeInsertEntriesNotification(input as InsertEntriesNotification); break; + case EntriesInsertedNotification: + result = this.encodeEntriesInsertedNotification(input as EntriesInsertedNotification); + break; case UpdateEntriesRequest: result = this.encodeUpdateEntriesRequest(input as UpdateEntriesRequest); @@ -143,6 +158,13 @@ export class StorageCodec implements HamokCodec, Message> { case UpdateEntriesNotification: result = this.encodeUpdateEntriesNotification(input as UpdateEntriesNotification); break; + case EntryUpdatedNotification: + result = this.encodeEntryUpdatedNotification(input as EntryUpdatedNotification); + break; + + case StorageAppliedCommitNotification: + result = this.encodeStorageAppliedCommitNotification(input as StorageAppliedCommitNotification); + break; default: throw new Error(`Cannot encode input${ input}`); @@ -223,6 +245,10 @@ export class StorageCodec implements HamokCodec, Message> { type = callback ? 'RemoveEntriesNotification' : undefined; result = this.decodeRemoveEntriesNotification(message); break; + case MessageType.ENTRIES_REMOVED_NOTIFICATION: + type = callback ? 'EntriesRemovedNotification' : undefined; + result = this.decodeEntriesRemovedNotification(message); + break; case MessageType.INSERT_ENTRIES_REQUEST: type = callback ? 'InsertEntriesRequest' : undefined; result = this.decodeInsertEntriesRequest(message); @@ -235,6 +261,10 @@ export class StorageCodec implements HamokCodec, Message> { type = callback ? 'InsertEntriesNotification' : undefined; result = this.decodeInsertEntriesNotification(message); break; + case MessageType.ENTRIES_INSERTED_NOTIFICATION: + type = callback ? 'EntriesInsertedNotification' : undefined; + result = this.decodeEntriesInsertedNotification(message); + break; case MessageType.UPDATE_ENTRIES_REQUEST: type = callback ? 'UpdateEntriesRequest' : undefined; result = this.decodeUpdateEntriesRequest(message); @@ -247,6 +277,14 @@ export class StorageCodec implements HamokCodec, Message> { type = callback ? 'UpdateEntriesNotification' : undefined; result = this.decodeUpdateEntriesNotification(message); break; + case MessageType.ENTRY_UPDATED_NOTIFICATION: + type = callback ? 'EntryUpdatedNotification' : undefined; + result = this.decodeEntryUpdatedNotification(message); + break; + case MessageType.STORAGE_APPLIED_COMMIT_NOTIFICATION: + type = callback ? 'StorageAppliedCommitNotification' : undefined; + result = this.decodeStorageAppliedCommitNotification(message); + break; } logger.trace('Decoded message %o', message); @@ -595,6 +633,31 @@ export class StorageCodec implements HamokCodec, Message> { ); } + public encodeEntriesRemovedNotification(notification: EntriesRemovedNotification): Message { + const [ keys, values ] = this.encodeEntries(notification.entries); + + return new Message({ + type: MessageType.ENTRIES_REMOVED_NOTIFICATION, + keys, + values, + sourceId: notification.sourceEndpointId, + destinationId: notification.destinationEndpointId, + }); + } + + public decodeEntriesRemovedNotification(message: Message): EntriesRemovedNotification { + if (message.type !== MessageType.ENTRIES_REMOVED_NOTIFICATION) { + throw new Error('decodeEntriesRemovedNotification(): Message type must be ENTRIES_REMOVED_NOTIFICATION'); + } + const entries = this.decodeEntries(message.keys, message.values); + + return new EntriesRemovedNotification( + entries, + message.sourceId, + message.destinationId, + ); + } + public encodeInsertEntriesRequest(request: InsertEntriesRequest): Message { const [ keys, values ] = this.encodeEntries(request.entries); @@ -670,9 +733,34 @@ export class StorageCodec implements HamokCodec, Message> { ); } + public encodeEntriesInsertedNotification(notification: EntriesInsertedNotification): Message { + const [ keys, values ] = this.encodeEntries(notification.entries); + + return new Message({ + type: MessageType.ENTRIES_INSERTED_NOTIFICATION, + keys, + values, + sourceId: notification.sourceEndpointId, + destinationId: notification.destinationEndpointId, + }); + } + + public decodeEntriesInsertedNotification(message: Message): EntriesInsertedNotification { + if (message.type !== MessageType.ENTRIES_INSERTED_NOTIFICATION) { + throw new Error('decodeEntriesInsertedNotification(): Message type must be ENTRIES_INSERTED_NOTIFICATION'); + } + const entries = this.decodeEntries(message.keys, message.values); + + return new EntriesInsertedNotification( + entries, + message.sourceId, + message.destinationId, + ); + } + public encodeUpdateEntriesRequest(request: UpdateEntriesRequest): Message { const [ keys, values ] = this.encodeEntries(request.entries); - + return new Message({ type: MessageType.UPDATE_ENTRIES_REQUEST, sourceId: request.sourceEndpointId, @@ -747,6 +835,60 @@ export class StorageCodec implements HamokCodec, Message> { ); } + public encodeEntryUpdatedNotification(notification: EntryUpdatedNotification): Message { + return new Message({ + type: MessageType.ENTRY_UPDATED_NOTIFICATION, + keys: [ this.keyCodec.encode(notification.key) ], + values: [ this.valueCodec.encode(notification.newValue) ], + prevValue: this.valueCodec.encode(notification.oldValue), + sourceId: notification.sourceEndpointId, + destinationId: notification.destinationEndpointId, + }); + } + + public decodeEntryUpdatedNotification(message: Message): EntryUpdatedNotification { + if (message.type !== MessageType.ENTRY_UPDATED_NOTIFICATION) { + throw new Error('decodeEntriesUpdatedNotification(): Message type must be ENTRY_UPDATED_NOTIFICATION'); + } else if (message.keys.length < 1) { + throw new Error('decodeEntriesUpdatedNotification(): Message must have at least one key'); + } else if (message.values.length < 1) { + throw new Error('decodeEntriesUpdatedNotification(): Message must have at least one value'); + } else if (message.prevValue === undefined) { + throw new Error('decodeEntriesUpdatedNotification(): Message must have a prevValue'); + } + + const key = this.keyCodec.decode(message.keys[0]); + const newValue = this.valueCodec.decode(message.values[0]); + const oldValue = this.valueCodec.decode(message.prevValue!); + + return new EntryUpdatedNotification( + key, + newValue, + oldValue, + message.sourceId, + message.destinationId, + ); + } + + public encodeStorageAppliedCommitNotification(notification: StorageAppliedCommitNotification): Message { + return new Message({ + type: MessageType.STORAGE_APPLIED_COMMIT_NOTIFICATION, + raftCommitIndex: notification.appliedCommitIndex, + sourceId: notification.sourceEndpointId, + }); + } + + public decodeStorageAppliedCommitNotification(message: Message): StorageAppliedCommitNotification { + if (message.type !== MessageType.STORAGE_APPLIED_COMMIT_NOTIFICATION) { + throw new Error('decodeStorageAppliedCommitNotification(): Message type must be STORAGE_APPLIED_COMMIT_NOTIFICATION'); + } + + return new StorageAppliedCommitNotification( + message.raftCommitIndex!, + message.sourceId, + ); + } + public encodeKeys(keys: ReadonlySet): Uint8Array[] { return encodeSet(keys, this.keyCodec); } diff --git a/src/messages/messagetypes/InsertEntries.ts b/src/messages/messagetypes/InsertEntries.ts index 0978146..e405471 100644 --- a/src/messages/messagetypes/InsertEntries.ts +++ b/src/messages/messagetypes/InsertEntries.ts @@ -50,3 +50,12 @@ export class InsertEntriesNotification { this.destinationEndpointId = destinationEndpointId; } } + +export class EntriesInsertedNotification { + public constructor( + public readonly entries: ReadonlyMap, + public readonly sourceEndpointId?: string, + public readonly destinationEndpointId?: string + ) { + } +} diff --git a/src/messages/messagetypes/JoinNotification.ts b/src/messages/messagetypes/JoinNotification.ts new file mode 100644 index 0000000..76adc40 --- /dev/null +++ b/src/messages/messagetypes/JoinNotification.ts @@ -0,0 +1,8 @@ +export class JoinNotification { + + public constructor( + public readonly sourcePeerId: string, + public readonly destinationPeerId?: string, + ) { + } +} \ No newline at end of file diff --git a/src/messages/messagetypes/RemoveEntries.ts b/src/messages/messagetypes/RemoveEntries.ts index d7c1b1e..3fcf067 100644 --- a/src/messages/messagetypes/RemoveEntries.ts +++ b/src/messages/messagetypes/RemoveEntries.ts @@ -52,3 +52,13 @@ export class RemoveEntriesNotification { this.destinationEndpointId = destinationEndpointId; } } + +export class EntriesRemovedNotification { + public constructor( + public readonly entries: ReadonlyMap, + public readonly sourceEndpointId?: string, + public readonly destinationEndpointId?: string + ) { + // empty + } +} diff --git a/src/messages/messagetypes/StorageAppliedCommit.ts b/src/messages/messagetypes/StorageAppliedCommit.ts new file mode 100644 index 0000000..731f571 --- /dev/null +++ b/src/messages/messagetypes/StorageAppliedCommit.ts @@ -0,0 +1,9 @@ +export class StorageAppliedCommitNotification { + + public constructor( + public readonly appliedCommitIndex: number, + public readonly sourceEndpointId?: string, + ) { + // empty + } +} \ No newline at end of file diff --git a/src/messages/messagetypes/UpdateEntries.ts b/src/messages/messagetypes/UpdateEntries.ts index 4afcad3..5750612 100644 --- a/src/messages/messagetypes/UpdateEntries.ts +++ b/src/messages/messagetypes/UpdateEntries.ts @@ -43,3 +43,15 @@ export class UpdateEntriesNotification { // empty } } + +export class EntryUpdatedNotification { + public constructor( + public readonly key: K, + public readonly newValue: V, + public readonly oldValue: V, + public readonly sourceEndpointId?: string, + public readonly destinationEndpointId?: string, + ) { + // empty + } +} \ No newline at end of file diff --git a/src/raft/MemoryStoredRaftLogs.ts b/src/raft/MemoryStoredRaftLogs.ts index b1385b2..a7d060d 100644 --- a/src/raft/MemoryStoredRaftLogs.ts +++ b/src/raft/MemoryStoredRaftLogs.ts @@ -15,7 +15,16 @@ export type MemoryStoredRaftLogsConfig = { memorySizeHighWaterMark: number; } -export class MemoryStoredRaftLogs extends EventEmitter implements RaftLogs { +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export declare interface MemoryStoredRaftLogs { + on(event: U, listener: (...args: MemoryStoredRaftLogsEventMap[U]) => void): this; + once(event: U, listener: (...args: MemoryStoredRaftLogsEventMap[U]) => void): this; + off(event: U, listener: (...args: MemoryStoredRaftLogsEventMap[U]) => void): this; + emit(event: U, ...args: MemoryStoredRaftLogsEventMap[U]): boolean; +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export class MemoryStoredRaftLogs extends EventEmitter implements RaftLogs { /** * index of highest log entry applied to state @@ -208,11 +217,25 @@ export class MemoryStoredRaftLogs extends EventEmitter { +interface HamokController extends EventEmitter { + on(event: U, listener: (...args: HamokEventMap[U]) => void): this; + once(event: U, listener: (...args: HamokEventMap[U]) => void): this; + off(event: U, listener: (...args: HamokEventMap[U]) => void): this; + emit(event: U, ...args: HamokEventMap[U]): boolean; stop(): void; } @@ -55,6 +59,7 @@ export class RaftEngine { private _leaderId?: string; public readonly remotePeers = new Set(); public readonly transport = new RaftMessageEmitter(); + private _failedElections = 0; public constructor( public readonly config: RaftEngineConfig, @@ -75,6 +80,10 @@ export class RaftEngine { return this._leaderId; } + public get failedElections(): number { + return this._failedElections; + } + public set leaderId(newLeaderId: string | undefined) { if (this._leaderId === newLeaderId) return; const prevLeaderId = this._leaderId; @@ -83,6 +92,10 @@ export class RaftEngine { logger.info(`%s Leader changed from ${prevLeaderId} to ${newLeaderId}`, this.localPeerId); + if (newLeaderId !== undefined) { + this._failedElections = 0; + } + this.events.emit('leader-changed', newLeaderId, prevLeaderId); } @@ -99,6 +112,10 @@ export class RaftEngine { logger.debug(`%s State changed from ${prevState.stateName} to ${newState.stateName}`, this.localPeerId); + if (prevState.stateName === 'candidate' && newState.stateName === 'follower') { + ++this._failedElections; + } + newState.init?.(); this.events.emit('state-changed', newState.stateName); @@ -106,6 +123,7 @@ export class RaftEngine { switch (newState.stateName) { case 'leader': case 'follower': + case 'candidate': this.events.emit(newState.stateName); break; } diff --git a/src/raft/RaftFollowerState.ts b/src/raft/RaftFollowerState.ts index 5af1666..2f33390 100644 --- a/src/raft/RaftFollowerState.ts +++ b/src/raft/RaftFollowerState.ts @@ -112,9 +112,9 @@ export function createRaftFollowerState(context: RaftFollowerStateContext) { } // // we send success and processed response as the problem is not with the request, // // but we do not change our next index because we cannot process it momentary due to not synced endpoint - // const response = requestChunk.createResponse(true, logs.nextIndex, true); + const response = requestChunk.createResponse(true, logs.nextIndex, true); - // return messageEmitter.send(response); + return messageEmitter.send(response); } // if we arrived in this point we know that the sync is possible. diff --git a/src/raft/RaftLeaderState.ts b/src/raft/RaftLeaderState.ts index 7662e98..21acb09 100644 --- a/src/raft/RaftLeaderState.ts +++ b/src/raft/RaftLeaderState.ts @@ -75,7 +75,7 @@ export function createRaftLeaderState(context: RaftLeaderStateContext): RaftStat return follow(); } // now we are talking in my term... - logger.debug('Received RaftAppendEntriesResponse %o', response); + logger.trace('Received RaftAppendEntriesResponse %o', response); // if (localPeerId !== response.sourcePeerId) { // remotePeers.touch(response.sourcePeerId); // } diff --git a/src/raft/RaftLogs.ts b/src/raft/RaftLogs.ts index c2dc544..6167749 100644 --- a/src/raft/RaftLogs.ts +++ b/src/raft/RaftLogs.ts @@ -16,7 +16,7 @@ export interface RaftLogs { compareAndAdd(expectedNextIndex: number, term: number, entry: HamokMessage): boolean; removeUntil(index: number): void; get(index: number): LogEntry | undefined; - collectEntries(startIndex: number): HamokMessage[]; + collectEntries(startIndex: number, endIndex?: number): HamokMessage[]; [Symbol.iterator](): IterableIterator; reset(newCommitIndex: number): void; }