Skip to content

Commit 13a2a86

Browse files
avifeneshliorsve
andauthored
implementation of cluster scan (#2257)
* implementation of cluster scan Signed-off-by: avifenesh <[email protected]> * fix linter Signed-off-by: avifenesh <[email protected]> * added cluster scan tests Signed-off-by: lior sventitzky <[email protected]> Signed-off-by: avifenesh <[email protected]> * added scan standalone impl Signed-off-by: avifenesh <[email protected]> * fix decoding Signed-off-by: avifenesh <[email protected]> * add standalone scan tests and fix tests Signed-off-by: lior sventitzky <[email protected]> * fix lints Signed-off-by: avifenesh <[email protected]> * round 1 Signed-off-by: avifenesh <[email protected]> * round 2 Signed-off-by: avifenesh <[email protected]> * round 4 Signed-off-by: avifenesh <[email protected]> --------- Signed-off-by: avifenesh <[email protected]> Signed-off-by: lior sventitzky <[email protected]> Co-authored-by: lior sventitzky <[email protected]>
1 parent 747cd14 commit 13a2a86

File tree

8 files changed

+1033
-13
lines changed

8 files changed

+1033
-13
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@
121121
* Java, Node, Python: Add SCRIPT SHOW command (Valkey-8) ([#2171](https://github.com/valkey-io/valkey-glide/pull/2171))
122122
* Java, Node, Python: Change BITCOUNT end param to optional (Valkey-8) ([#2248](https://github.com/valkey-io/valkey-glide/pull/2248))
123123
* Java, Node, Python: Add NOSCORES option to ZSCAN & NOVALUES option to HSCAN (Valkey-8) ([#2174](https://github.com/valkey-io/valkey-glide/pull/2174))
124+
* Node: Add SCAN command ([#2257](https://github.com/valkey-io/valkey-glide/pull/2257))
124125

125126
#### Breaking Changes
126127
* Java: Update INFO command ([#2274](https://github.com/valkey-io/valkey-glide/pull/2274))

node/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0
33
*/
44

5-
export { Script } from "glide-rs";
5+
export { ClusterScanCursor, Script } from "glide-rs";
66
export * from "./src/BaseClient";
77
export * from "./src/Commands";
88
export * from "./src/Errors";

node/rust-client/src/lib.rs

+59-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use tikv_jemallocator::Jemalloc;
99
#[cfg(not(target_env = "msvc"))]
1010
#[global_allocator]
1111
static GLOBAL: Jemalloc = Jemalloc;
12-
12+
pub const FINISHED_SCAN_CURSOR: &str = "finished";
1313
use byteorder::{LittleEndian, WriteBytesExt};
1414
use bytes::Bytes;
1515
use glide_core::start_socket_listener;
@@ -420,3 +420,61 @@ impl Drop for Script {
420420
glide_core::scripts_container::remove_script(&self.hash);
421421
}
422422
}
423+
424+
/// This struct is used to keep track of the cursor of a cluster scan.
425+
/// We want to avoid passing the cursor between layers of the application,
426+
/// So we keep the state in the container and only pass the id of the cursor.
427+
/// The cursor is stored in the container and can be retrieved using the id.
428+
/// The cursor is removed from the container when the object is deleted (dropped).
429+
/// To create a cursor:
430+
/// ```typescript
431+
/// // For a new cursor
432+
/// let cursor = new ClusterScanCursor();
433+
/// // Using an existing id
434+
/// let cursor = new ClusterScanCursor("cursor_id");
435+
/// ```
436+
/// To get the cursor id:
437+
/// ```typescript
438+
/// let cursorId = cursor.getCursor();
439+
/// ```
440+
/// To check if the scan is finished:
441+
/// ```typescript
442+
/// let isFinished = cursor.isFinished(); // true if the scan is finished
443+
/// ```
444+
#[napi]
445+
#[derive(Default)]
446+
pub struct ClusterScanCursor {
447+
cursor: String,
448+
}
449+
450+
#[napi]
451+
impl ClusterScanCursor {
452+
#[napi(constructor)]
453+
#[allow(dead_code)]
454+
pub fn new(new_cursor: Option<String>) -> Self {
455+
match new_cursor {
456+
Some(cursor) => ClusterScanCursor { cursor },
457+
None => ClusterScanCursor::default(),
458+
}
459+
}
460+
461+
/// Returns the cursor id.
462+
#[napi]
463+
#[allow(dead_code)]
464+
pub fn get_cursor(&self) -> String {
465+
self.cursor.clone()
466+
}
467+
468+
#[napi]
469+
#[allow(dead_code)]
470+
/// Returns true if the scan is finished.
471+
pub fn is_finished(&self) -> bool {
472+
self.cursor.eq(FINISHED_SCAN_CURSOR)
473+
}
474+
}
475+
476+
impl Drop for ClusterScanCursor {
477+
fn drop(&mut self) {
478+
glide_core::cluster_scan_container::remove_scan_state_cursor(self.cursor.clone());
479+
}
480+
}

node/src/BaseClient.ts

+50-10
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0
33
*/
44
import {
5+
ClusterScanCursor,
56
DEFAULT_TIMEOUT_IN_MILLISECONDS,
67
Script,
78
StartSocketConnection,
@@ -575,6 +576,24 @@ export interface ScriptOptions {
575576
args?: GlideString[];
576577
}
577578

579+
/**
580+
* Enum of Valkey data types
581+
* `STRING`
582+
* `LIST`
583+
* `SET`
584+
* `ZSET`
585+
* `HASH`
586+
* `STREAM`
587+
*/
588+
export enum ObjectType {
589+
STRING = "String",
590+
LIST = "List",
591+
SET = "Set",
592+
ZSET = "ZSet",
593+
HASH = "Hash",
594+
STREAM = "Stream",
595+
}
596+
578597
function getRequestErrorClass(
579598
type: response.RequestErrorType | null | undefined,
580599
): typeof RequestError {
@@ -686,7 +705,7 @@ export type WritePromiseOptions = RouteOption & DecoderOption;
686705

687706
export class BaseClient {
688707
private socket: net.Socket;
689-
private readonly promiseCallbackFunctions: [
708+
protected readonly promiseCallbackFunctions: [
690709
PromiseFunction,
691710
ErrorFunction,
692711
][] = [];
@@ -695,7 +714,7 @@ export class BaseClient {
695714
private writeInProgress = false;
696715
private remainingReadData: Uint8Array | undefined;
697716
private readonly requestTimeout: number; // Timeout in milliseconds
698-
private isClosed = false;
717+
protected isClosed = false;
699718
protected defaultDecoder = Decoder.String;
700719
private readonly pubsubFutures: [PromiseFunction, ErrorFunction][] = [];
701720
private pendingPushNotification: response.Response[] = [];
@@ -867,7 +886,7 @@ export class BaseClient {
867886
this.defaultDecoder = options?.defaultDecoder ?? Decoder.String;
868887
}
869888

870-
private getCallbackIndex(): number {
889+
protected getCallbackIndex(): number {
871890
return (
872891
this.availableCallbackSlots.pop() ??
873892
this.promiseCallbackFunctions.length
@@ -895,7 +914,8 @@ export class BaseClient {
895914
command:
896915
| command_request.Command
897916
| command_request.Command[]
898-
| command_request.ScriptInvocation,
917+
| command_request.ScriptInvocation
918+
| command_request.ClusterScan,
899919
options: WritePromiseOptions = {},
900920
): Promise<T> {
901921
const route = toProtobufRoute(options?.route);
@@ -914,6 +934,10 @@ export class BaseClient {
914934
(resolveAns: T) => {
915935
try {
916936
if (resolveAns instanceof PointerResponse) {
937+
// valueFromSplitPointer method is used to convert a pointer from a protobuf response into a TypeScript object.
938+
// The protobuf response is received on a socket and the value in the response is a pointer to a Rust object.
939+
// The pointer is a split pointer because JavaScript doesn't support `u64` and pointers in Rust can be `u64`,
940+
// so we represent it with two`u32`(`high` and`low`).
917941
if (typeof resolveAns === "number") {
918942
resolveAns = valueFromSplitPointer(
919943
0,
@@ -929,6 +953,16 @@ export class BaseClient {
929953
}
930954
}
931955

956+
if (command instanceof command_request.ClusterScan) {
957+
const resolveAnsArray = resolveAns as [
958+
ClusterScanCursor,
959+
GlideString[],
960+
];
961+
resolveAnsArray[0] = new ClusterScanCursor(
962+
resolveAnsArray[0].toString(),
963+
);
964+
}
965+
932966
resolve(resolveAns);
933967
} catch (err) {
934968
Logger.log(
@@ -945,12 +979,13 @@ export class BaseClient {
945979
});
946980
}
947981

948-
private writeOrBufferCommandRequest(
982+
protected writeOrBufferCommandRequest(
949983
callbackIdx: number,
950984
command:
951985
| command_request.Command
952986
| command_request.Command[]
953-
| command_request.ScriptInvocation,
987+
| command_request.ScriptInvocation
988+
| command_request.ClusterScan,
954989
route?: command_request.Routes,
955990
) {
956991
const message = Array.isArray(command)
@@ -965,10 +1000,15 @@ export class BaseClient {
9651000
callbackIdx,
9661001
singleCommand: command,
9671002
})
968-
: command_request.CommandRequest.create({
969-
callbackIdx,
970-
scriptInvocation: command,
971-
});
1003+
: command instanceof command_request.ClusterScan
1004+
? command_request.CommandRequest.create({
1005+
callbackIdx,
1006+
clusterScan: command,
1007+
})
1008+
: command_request.CommandRequest.create({
1009+
callbackIdx,
1010+
scriptInvocation: command,
1011+
});
9721012
message.route = route;
9731013

9741014
this.writeOrBufferRequest(

node/src/Commands.ts

+32
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
GlideRecord,
1212
GlideString,
1313
HashDataType,
14+
ObjectType,
1415
SortedSetDataType,
1516
} from "./BaseClient";
1617
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
@@ -1655,6 +1656,26 @@ export function createZMScore(
16551656
return createCommand(RequestType.ZMScore, [key, ...members]);
16561657
}
16571658

1659+
/**
1660+
* @internal
1661+
*/
1662+
export function createScan(
1663+
cursor: GlideString,
1664+
options?: ScanOptions,
1665+
): command_request.Command {
1666+
let args: GlideString[] = [cursor];
1667+
1668+
if (options) {
1669+
args = args.concat(convertBaseScanOptionsToArgsArray(options));
1670+
}
1671+
1672+
if (options?.type) {
1673+
args.push("TYPE", options.type);
1674+
}
1675+
1676+
return createCommand(RequestType.Scan, args);
1677+
}
1678+
16581679
export enum InfBoundary {
16591680
/**
16601681
* Positive infinity bound.
@@ -3810,6 +3831,17 @@ export interface BaseScanOptions {
38103831
readonly count?: number;
38113832
}
38123833

3834+
/**
3835+
* Options for the SCAN command.
3836+
* `match`: The match filter is applied to the result of the command and will only include keys that match the pattern specified.
3837+
* `count`: `COUNT` is a just a hint for the command for how many elements to fetch from the server, the default is 10.
3838+
* `type`: The type of the object to scan.
3839+
* Types are the data types of Valkey: `string`, `list`, `set`, `zset`, `hash`, `stream`.
3840+
*/
3841+
export interface ScanOptions extends BaseScanOptions {
3842+
type?: ObjectType;
3843+
}
3844+
38133845
/**
38143846
* Options specific to the ZSCAN command, extending from the base scan options.
38153847
*/

node/src/GlideClient.ts

+50
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
createPing,
4343
createPublish,
4444
createRandomKey,
45+
createScan,
4546
createScriptExists,
4647
createScriptFlush,
4748
createScriptKill,
@@ -55,6 +56,7 @@ import {
5556
FunctionStatsFullResponse,
5657
InfoOptions,
5758
LolwutOptions,
59+
ScanOptions,
5860
} from "./Commands";
5961
import { connection_request } from "./ProtobufMessage";
6062
import { Transaction } from "./Transaction";
@@ -976,4 +978,52 @@ export class GlideClient extends BaseClient {
976978
decoder: Decoder.String,
977979
});
978980
}
981+
982+
/**
983+
* Incrementally iterate over a collection of keys.
984+
* `SCAN` is a cursor based iterator. This means that at every call of the method,
985+
* the server returns an updated cursor that the user needs to use as the cursor argument in the next call.
986+
* An iteration starts when the cursor is set to "0", and terminates when the cursor returned by the server is "0".
987+
*
988+
* A full iteration always retrieves all the elements that were present
989+
* in the collection from the start to the end of a full iteration.
990+
* Elements that were not constantly present in the collection during a full iteration, may be returned or not.
991+
*
992+
* @see {@link https://valkey.io/commands/scan|valkey.io} for more details.
993+
*
994+
* @param cursor - The `cursor` used for iteration. For the first iteration, the cursor should be set to "0".
995+
* Using a non-zero cursor in the first iteration,
996+
* or an invalid cursor at any iteration, will lead to undefined results.
997+
* Using the same cursor in multiple iterations will, in case nothing changed between the iterations,
998+
* return the same elements multiple times.
999+
* If the the db has changed, it may result in undefined behavior.
1000+
* @param options - (Optional) The options to use for the scan operation, see {@link ScanOptions} and {@link DecoderOption}.
1001+
* @returns A List containing the next cursor value and a list of keys,
1002+
* formatted as [cursor, [key1, key2, ...]]
1003+
*
1004+
* @example
1005+
* ```typescript
1006+
* // Example usage of scan method
1007+
* let result = await client.scan('0');
1008+
* console.log(result); // Output: ['17', ['key1', 'key2', 'key3', 'key4', 'key5', 'set1', 'set2', 'set3']]
1009+
* let firstCursorResult = result[0];
1010+
* result = await client.scan(firstCursorResult);
1011+
* console.log(result); // Output: ['349', ['key4', 'key5', 'set1', 'hash1', 'zset1', 'list1', 'list2',
1012+
* // 'list3', 'zset2', 'zset3', 'zset4', 'zset5', 'zset6']]
1013+
* result = await client.scan(result[0]);
1014+
* console.log(result); // Output: ['0', ['key6', 'key7']]
1015+
*
1016+
* result = await client.scan(firstCursorResult, {match: 'key*', count: 2});
1017+
* console.log(result); // Output: ['6', ['key4', 'key5']]
1018+
*
1019+
* result = await client.scan("0", {type: ObjectType.Set});
1020+
* console.log(result); // Output: ['362', ['set1', 'set2', 'set3']]
1021+
* ```
1022+
*/
1023+
public async scan(
1024+
cursor: GlideString,
1025+
options?: ScanOptions & DecoderOption,
1026+
): Promise<[GlideString, GlideString[]]> {
1027+
return this.createWritePromise(createScan(cursor, options), options);
1028+
}
9791029
}

0 commit comments

Comments
 (0)