Skip to content

Commit

Permalink
Add deterministic random functions (#181)
Browse files Browse the repository at this point in the history
* Add deterministic random functions

* Add clone() method to Rand

* Ensure no side effect calls, and use the entire invocation id via hash as seed

* Remove clone
  • Loading branch information
jackkleeman authored Nov 20, 2023
1 parent 2d823c8 commit 7e6b5d4
Show file tree
Hide file tree
Showing 5 changed files with 264 additions and 7 deletions.
25 changes: 25 additions & 0 deletions src/restate_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@ export interface RestateBaseContext {
*/
serviceName: string;

/**
* Deterministic random methods; these are inherently predictable (seeded on the invocation ID, which is not secret)
* and so should not be used for any cryptographic purposes. They are useful for identifiers, idempotency keys,
* and for uniform sampling from a set of options. If a cryptographically secure value is needed, please generate that
* externally and capture the result with a side effect.
*
* Calls to these methods from inside side effects are disallowed and will fail - side effects must be idempotent, and
* these calls are not.
*/
rand: Rand;

/**
* Get/retrieve state from the Restate runtime.
* Note that state objects are serialized with `Buffer.from(JSON.stringify(theObject))`
Expand Down Expand Up @@ -175,6 +186,20 @@ export interface RestateBaseContext {
sleep(millis: number): Promise<void>;
}

export interface Rand {
/**
* Equivalent of JS `Math.random()` but deterministic; seeded by the invocation ID of the current invocation,
* each call will return a new pseudorandom float within the range [0,1)
*/
random(): number

/**
* Using the same random source and seed as random(), produce a UUID version 4 string. This is inherently predictable
* based on the invocation ID and should not be used in cryptographic contexts
*/
uuidv4(): string
}

// ----------------------------------------------------------------------------
// types and functions for the gRPC-based API
// ----------------------------------------------------------------------------
Expand Down
12 changes: 8 additions & 4 deletions src/restate_context_impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
*/

import {
Rand,
RestateGrpcChannel,
RestateGrpcContext,
RpcContext,
Expand Down Expand Up @@ -58,14 +59,15 @@ import { rlog } from "./utils/logger";
import { Client, SendClient } from "./types/router";
import { RpcRequest, RpcResponse } from "./generated/proto/dynrpc";
import { requestFromArgs } from "./utils/assumpsions";
import {RandImpl} from "./utils/rand";

enum CallContexType {
export enum CallContexType {
None,
SideEffect,
OneWayCall,
}

interface CallContext {
export interface CallContext {
type: CallContexType;
delay?: number;
}
Expand All @@ -77,13 +79,14 @@ export class RestateGrpcContextImpl implements RestateGrpcContext {
// we also use this information to ensure we check that only allowed operations are
// used. Within side-effects, no operations are allowed on the RestateContext.
// For example, this is illegal: 'ctx.sideEffect(() => {await ctx.get("my-state")})'
private static callContext = new AsyncLocalStorage<CallContext>();
static callContext = new AsyncLocalStorage<CallContext>();

constructor(
public readonly id: Buffer,
public readonly serviceName: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private readonly stateMachine: StateMachine<any, any>
private readonly stateMachine: StateMachine<any, any>,
public readonly rand: Rand = new RandImpl(id)
) {}

public async get<T>(name: string): Promise<T | null> {
Expand Down Expand Up @@ -484,6 +487,7 @@ export class RpcContextImpl implements RpcContext {
constructor(
private readonly ctx: RestateGrpcContext,
public readonly id: Buffer = ctx.id,
public readonly rand: Rand = ctx.rand,
public readonly serviceName: string = ctx.serviceName
) {}

Expand Down
122 changes: 122 additions & 0 deletions src/utils/rand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/*
* Copyright (c) 2023 - Restate Software, Inc., Restate GmbH
*
* This file is part of the Restate SDK for Node.js/TypeScript,
* which is released under the MIT license.
*
* You can find a copy of the license in file LICENSE in the root
* directory of this repository or package, or at
* https://github.com/restatedev/sdk-typescript/blob/main/LICENSE
*/

//! Some parts copied from https://github.com/uuidjs/uuid/blob/main/src/stringify.js
//! License MIT

import {Rand} from "../restate_context";
import {ErrorCodes, TerminalError} from "../types/errors";
import {CallContexType, RestateGrpcContextImpl} from "../restate_context_impl";
import {createHash} from "crypto";

export class RandImpl implements Rand {
private randstate64: bigint;

constructor(id: Buffer | bigint) {
if (typeof id == "bigint") {
this.randstate64 = id
} else {
// hash the invocation ID, which is known to contain 74 bits of entropy
const hash = createHash('sha256')
.update(id)
.digest();

// seed using first 64 bits of the hash
this.randstate64 = hash.readBigUInt64LE(0);
}
}

static U64_MASK = ((1n << 64n) - 1n)

// splitmix64
// https://prng.di.unimi.it/splitmix64.c - public domain
u64(): bigint {
this.randstate64 = (this.randstate64 + 0x9e3779b97f4a7c15n) & RandImpl.U64_MASK;
let next: bigint = this.randstate64;
next = ((next ^ (next >> 30n)) * 0xbf58476d1ce4e5b9n) & RandImpl.U64_MASK;
next = ((next ^ (next >> 27n)) * 0x94d049bb133111ebn) & RandImpl.U64_MASK;
next = next ^ (next >> 31n);
return next
}

static U53_MASK = ((1n << 53n) - 1n)

checkContext() {
const context = RestateGrpcContextImpl.callContext.getStore();
if (context && context.type === CallContexType.SideEffect) {
throw new TerminalError(
`You may not call methods on Rand from within a side effect.`,
{errorCode: ErrorCodes.INTERNAL}
);
}
}

public random(): number {
this.checkContext()

// first generate a uint in range [0,2^53), which can be mapped 1:1 to a float64 in [0,1)
const u53 = this.u64() & RandImpl.U53_MASK
// then divide by 2^53, which will simply update the exponent
return Number(u53) / 2 ** 53
}

public uuidv4(): string {
this.checkContext()

const buf = Buffer.alloc(16);
buf.writeBigUInt64LE(this.u64(), 0);
buf.writeBigUInt64LE(this.u64(), 8);
// Per 4.4, set bits for version and `clock_seq_hi_and_reserved`
buf[6] = (buf[6] & 0x0f) | 0x40;
buf[8] = (buf[8] & 0x3f) | 0x80;
return uuidStringify(buf)
}
}

const byteToHex: string[] = [];

for (let i = 0; i < 256; ++i) {
byteToHex.push((i + 0x100).toString(16).slice(1));
}

/**
* Convert array of 16 byte values to UUID string format of the form:
* XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
*/
function uuidStringify(arr: Buffer, offset = 0) {
// Note: Be careful editing this code! It's been tuned for performance
// and works in ways you may not expect. See https://github.com/uuidjs/uuid/pull/434
//
// Note to future-self: No, you can't remove the `toLowerCase()` call.
// REF: https://github.com/uuidjs/uuid/pull/677#issuecomment-1757351351
return (
byteToHex[arr[offset + 0]] +
byteToHex[arr[offset + 1]] +
byteToHex[arr[offset + 2]] +
byteToHex[arr[offset + 3]] +
'-' +
byteToHex[arr[offset + 4]] +
byteToHex[arr[offset + 5]] +
'-' +
byteToHex[arr[offset + 6]] +
byteToHex[arr[offset + 7]] +
'-' +
byteToHex[arr[offset + 8]] +
byteToHex[arr[offset + 9]] +
'-' +
byteToHex[arr[offset + 10]] +
byteToHex[arr[offset + 11]] +
byteToHex[arr[offset + 12]] +
byteToHex[arr[offset + 13]] +
byteToHex[arr[offset + 14]] +
byteToHex[arr[offset + 15]]
).toLowerCase();
}
6 changes: 3 additions & 3 deletions test/protoutils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ export function startMessage(
return new Message(
START_MESSAGE_TYPE,
StartMessage.create({
id: Buffer.from("123"),
debugId: "123",
id: Buffer.from("f311f1fdcb9863f0018bd3400ecd7d69b547204e776218b2", "hex"),
debugId: "8xHx_cuYY_AAYvTQA7NfWm1RyBOd2IYsg",
knownEntries: knownEntries, // only used for the Lambda case. For bidi streaming, this will be imputed by the testdriver
stateMap: toStateEntries(state || []),
partialState: partialState !== false,
Expand Down Expand Up @@ -441,7 +441,7 @@ export function getAwakeableId(entryIndex: number): string {
const encodedEntryIndex = Buffer.alloc(4 /* Size of u32 */);
encodedEntryIndex.writeUInt32BE(entryIndex);

return Buffer.concat([Buffer.from("123"), encodedEntryIndex]).toString(
return Buffer.concat([Buffer.from("f311f1fdcb9863f0018bd3400ecd7d69b547204e776218b2", "hex"), encodedEntryIndex]).toString(
"base64url"
);
}
Expand Down
106 changes: 106 additions & 0 deletions test/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
jsonSerialize,
printMessageAsJson,
} from "../src/utils/utils";
import {RandImpl} from "../src/utils/rand";

describe("JSON de-/serialization", () => {
it("should be able to handle bigint", () => {
Expand All @@ -40,3 +41,108 @@ describe("JSON printing", () => {
printMessageAsJson(myType);
});
});

describe("rand", () => {
it("expected u64 output", () => {
const rand = new RandImpl(1477776061723855037n)

const actual: bigint[] = Array.from(Array(50)).map(() => rand.u64())

// These values were produced with the reference implementation:
// http://xoshiro.di.unimi.it/splitmix64.c
const expected = [
1985237415132408290n, 2979275885539914483n, 13511426838097143398n,
8488337342461049707n, 15141737807933549159n, 17093170987380407015n,
16389528042912955399n, 13177319091862933652n, 10841969400225389492n,
17094824097954834098n, 3336622647361835228n, 9678412372263018368n,
11111587619974030187n, 7882215801036322410n, 5709234165213761869n,
7799681907651786826n, 4616320717312661886n, 4251077652075509767n,
7836757050122171900n, 5054003328188417616n, 12919285918354108358n,
16477564761813870717n, 5124667218451240549n, 18099554314556827626n,
7603784838804469118n, 6358551455431362471n, 3037176434532249502n,
3217550417701719149n, 9958699920490216947n, 5965803675992506258n,
12000828378049868312n, 12720568162811471118n, 245696019213873792n,
8351371993958923852n, 14378754021282935786n, 5655432093647472106n,
5508031680350692005n, 8515198786865082103n, 6287793597487164412n,
14963046237722101617n, 3630795823534910476n, 8422285279403485710n,
10554287778700714153n, 10871906555720704584n, 8659066966120258468n,
9420238805069527062n, 10338115333623340156n, 13514802760105037173n,
14635952304031724449n, 15419692541594102413n,
]

expect(actual).toStrictEqual(expected)
});

it("expected random output", () => {
const rand = new RandImpl(1477776061723855037n)

const actual = Array.from(Array(50)).map(() => rand.random())

const expected = [
0.40562876273298465, 0.7660684836915536, 0.06971711937258074,
0.3947558385769815, 0.07059472050725624, 0.7231994044448954,
0.6031395981643762, 0.9763058618887208, 0.7004060411626285,
0.906731546642922, 0.43952875868538, 0.5196257503384771,
0.6340415835012271, 0.10174673747469609, 0.8523223196903388,
0.9386438627277667, 0.5145549414635722, 0.9644288803681328,
0.054811543915718186, 0.10708614869526834, 0.32886882722913735,
0.37717883178926537, 0.9523539466324108, 0.45419354745831453,
0.18970023364060729, 0.9410229083698497, 0.194320746664278,
0.21985566247384514, 0.6377947060954611, 0.3372601480277686,
0.3595979885936371, 0.26676606670221914, 0.27773775899875375,
0.18854749029009943, 0.36237798498734475, 0.8790924571478034,
0.5143591890128688, 0.3769752437815147, 0.0853226020893767,
0.2318451649900749, 0.09931210013144343, 0.06150371552695488,
0.7613300433431692, 0.024097973430863284, 0.3495517557811252,
0.8566018855560766, 0.7613674619014001, 0.4445197536228266,
0.9171235251629818, 0.9297692318571805,
]

expect(actual).toStrictEqual(expected)
});

it("expected uuidv4 output", () => {
const rand = new RandImpl(1477776061723855037n)

const actual = Array.from(Array(50)).map(() => rand.uuidv4())

const expected = [
"e229c82b-e9fa-4c1b-b372-7e0da2835829", "66a67565-1f3b-42bb-abfb-12ffd6a1cc75", "672afbdb-4f42-42d2-a77a-d213732437ed",
"073c216a-eb4c-43e3-9490-76cae53ddfb6", "b4db16ee-b969-4696-b2a6-62e0f1033ded", "dc90869d-9e10-4e2e-809f-7b2ec6a05086",
"6b232e93-114a-449a-aab6-bd5f8241636d", "4d111775-3946-4b4f-8a38-a0da5e093e6c", "7e99b2ec-3b77-4040-87f8-8ff499dcfe3a",
"fcf59123-04c1-416c-9006-50ee3f6d2346", "c6ef33eb-1786-4ab3-bde8-6857d911ace4", "6516e0fb-ae79-4e47-aa67-0ce8c0882efb",
"7ef57039-0612-4669-a787-0713dc1c3e58", "9e6f7b24-e037-462a-ad4c-05be0e09a72c", "f3bd8771-d068-448a-92bf-40cbd5caca52",
"18f216a4-d381-4ba6-8e65-85fd588988b0", "8072f84b-3ae3-4803-8c3e-11bf9408e673", "eaf349b7-9998-4bc7-aa85-338186217c4e",
"a5a2e666-a175-404c-b72e-ee622e102c76", "fcab3277-f6ba-4257-b1c7-2b8d466ba7cf", "0c2cc591-902d-4332-8eaa-d8a3d6f7e174",
"a9e0b3d2-d05c-4892-8822-f91c69c5e096", "a4dbea29-872f-4b78-96f8-845b4869bb82", "7c5ca34b-1f5d-488f-b58d-877d81398ebb",
"a1f35e6f-1359-4dcb-8dea-7467abc0fdd5", "2c922ad0-97d9-4b81-8cb7-c6fdd022a2d2", "c37a7743-597b-4f66-b92c-552efbe4a53d",
"98d33970-1851-4701-82bd-c466db2660cc", "437dfd8f-8fe9-4c12-b031-febcc0f627c8", "7078daa9-2c29-4dba-947d-c26393490f96",
"2f6d71cc-e589-4782-ae12-cd4cdc05a93d", "db2afb9b-65cb-42e9-96a0-09738c04103f", "1bd2b462-d58f-44eb-9618-5e4fda702dd7",
"cacde8a3-4faf-4977-a815-e2f75e74b1f6", "6ba4b284-3f67-4cc1-b6d1-1293c501e04e", "50708cc7-c36a-4e0f-bd7f-7fb0a83198e9",
"84bfad92-28ed-487e-9e31-8a2d6907f1ea", "501fcdb7-acae-42e9-92e5-b925b21aef37", "44535164-06cc-49d6-87c4-07bb9900c1da",
"06a090a6-b3d9-4a08-bc82-82304d67c582", "e71d1508-4620-45e4-bab5-7b0530e4706c", "31f3b847-9dc4-4305-b96b-dc7248a1ebfc",
"db438e58-b8db-4fc8-9631-8b5e79f7e3c9", "8802e117-733d-413d-8081-ae9f1d58fb49", "54e9457d-a1c3-4105-a6ff-bed6d596a04f",
"af8b8a6e-c5e5-41fe-9e0a-fdf327c4fbc8", "554222be-3e89-4789-bf77-5081c63dd859", "18390152-0f87-4d6e-b322-6662e4de6815",
"0e1ff0e7-a682-4ed3-a1ff-43292479de48", "48394cfc-f05e-4726-a4f6-9cb888631043",
]

expect(actual).toStrictEqual(expected)
});

it("clone should not mutate original state", () => {
const rand1 = new RandImpl(1477776061723855037n)

expect(rand1.random()).toStrictEqual(0.40562876273298465)
expect(rand1.random()).toStrictEqual(0.7660684836915536)

const rand2 = rand1.clone()

expect(rand1.random()).toStrictEqual(0.06971711937258074)

expect(rand2.random()).toStrictEqual(0.998171769797398)
expect(rand2.random()).toStrictEqual(0.6733753646859768)
expect(rand2.random()).toStrictEqual(0.9623893622218933)

expect(rand1.random()).toStrictEqual(0.3947558385769815)
});
});

0 comments on commit 7e6b5d4

Please sign in to comment.