From dbf1c4e41e2acea92e878330589e601ed68d9577 Mon Sep 17 00:00:00 2001 From: Ian Macartney Date: Fri, 24 Oct 2025 22:41:02 -0700 Subject: [PATCH 1/7] seed Math.random in workflow environment --- src/client/environment.test.ts | 27 +++++++++++++++++------- src/client/environment.ts | 38 +++++++++++++++++++++++++++------- src/client/workflowMutation.ts | 2 +- 3 files changed, 50 insertions(+), 17 deletions(-) diff --git a/src/client/environment.test.ts b/src/client/environment.test.ts index 7e52a1b..4644eb0 100644 --- a/src/client/environment.test.ts +++ b/src/client/environment.test.ts @@ -9,7 +9,7 @@ describe("environment patching units", () => { describe("patchMath", () => { it("should preserve all Math methods except random", () => { const originalMath = Math; - const patchedMath = patchMath(originalMath); + const patchedMath = patchMath(originalMath, "test-seed"); // Should preserve all other methods expect(patchedMath.abs).toBe(originalMath.abs); @@ -19,20 +19,31 @@ describe("environment patching units", () => { expect(patchedMath.E).toBe(originalMath.E); }); - it("should replace Math.random with function that throws", () => { + it("should replace Math.random with deterministic seeded function", () => { const originalMath = Math; - const patchedMath = patchMath(originalMath); - - expect(() => patchedMath.random()).toThrow( - "Math.random() isn't yet supported within workflows", - ); + const patchedMath = patchMath(originalMath, "test-workflow-id"); + + // Should return a number between 0 and 1 + const random1 = patchedMath.random(); + expect(random1).toBeGreaterThanOrEqual(0); + expect(random1).toBeLessThan(1); + + // Should be deterministic - same seed produces same sequence + const patchedMath2 = patchMath(originalMath, "test-workflow-id"); + const random2 = patchedMath2.random(); + expect(random2).toBe(random1); + + // Different seed produces different sequence + const patchedMath3 = patchMath(originalMath, "different-workflow-id"); + const random3 = patchedMath3.random(); + expect(random3).not.toBe(random1); }); it("should not mutate the original Math object", () => { const originalMath = Math; const originalRandom = Math.random; - patchMath(originalMath); + patchMath(originalMath, "test-seed"); // Original Math should be unchanged expect(Math.random).toBe(originalRandom); diff --git a/src/client/environment.ts b/src/client/environment.ts index 04b5638..80900d1 100644 --- a/src/client/environment.ts +++ b/src/client/environment.ts @@ -1,7 +1,29 @@ type GenerationState = { now: number; latest: boolean }; -// Testable unit: patches Math object to restrict non-deterministic functions -export function patchMath(math: typeof Math): typeof Math { +// Simple hash function to convert a string to a 32-bit seed +function hashString(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32-bit integer + } + return hash >>> 0; // Ensure unsigned +} + +// Mulberry32 - a simple, fast seeded PRNG +function createSeededRandom(seed: number): () => number { + return function () { + seed |= 0; + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// Testable unit: patches Math object to use seeded random +export function patchMath(math: typeof Math, seed: string): typeof Math { const patchedMath = Object.create(Object.getPrototypeOf(math)); // Copy all properties from original Math @@ -14,10 +36,9 @@ export function patchMath(math: typeof Math): typeof Math { } } - // Override random to throw - patchedMath.random = () => { - throw new Error("Math.random() isn't yet supported within workflows"); - }; + // Override random to use seeded PRNG + const seededRandom = createSeededRandom(hashString(seed)); + patchedMath.random = seededRandom; return patchedMath; } @@ -63,11 +84,12 @@ export function createDeterministicDate( export function setupEnvironment( getGenerationState: () => GenerationState, + workflowId: string, ): void { const global = globalThis as Record; - // Patch Math - global.Math = patchMath(global.Math as typeof Math); + // Patch Math with seeded random based on workflowId + global.Math = patchMath(global.Math as typeof Math, workflowId); // Patch Date const originalDate = global.Date as typeof Date; diff --git a/src/client/workflowMutation.ts b/src/client/workflowMutation.ts index 34b5136..5afb2a6 100644 --- a/src/client/workflowMutation.ts +++ b/src/client/workflowMutation.ts @@ -128,7 +128,7 @@ export function workflowMutation( Date.now(), workpoolOptions, ); - setupEnvironment(executor.getGenerationState.bind(executor)); + setupEnvironment(executor.getGenerationState.bind(executor), workflowId); const handlerWorker = async (): Promise => { let runResult: RunResult; From a1d87911dfae9779ad748e4e9a7dd3362157dd47 Mon Sep 17 00:00:00 2001 From: Ian Macartney Date: Fri, 24 Oct 2025 23:00:56 -0700 Subject: [PATCH 2/7] update example --- example/convex/example.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/example/convex/example.ts b/example/convex/example.ts index 9c2cc94..0230b0f 100644 --- a/example/convex/example.ts +++ b/example/convex/example.ts @@ -41,8 +41,11 @@ export const exampleWorkflow = workflow.define({ const celsius = weather.temperature; const farenheit = (celsius * 9) / 5 + 32; const { temperature, windSpeed, windGust } = weather; + // Show celsius 50% of the time + const temp = + Math.random() > 0.5 ? `${farenheit.toFixed(1)}°F` : `${temperature}°C`; console.log( - `Weather in ${name}: ${farenheit.toFixed(1)}°F (${temperature}°C), ${windSpeed} km/h, ${windGust} km/h`, + `Weather in ${name}: ${temp}, ${windSpeed} km/h, ${windGust} km/h`, ); console.timeLog("weather", temperature); await step.runMutation(internal.example.updateFlow, { @@ -191,7 +194,8 @@ export const updateFlow = internalMutation({ .withIndex("workflowId", (q) => q.eq("workflowId", args.workflowId)) .first(); if (!flow) { - throw new Error(`Flow not found: ${args.workflowId}`); + console.warn(`Flow not found: ${args.workflowId}`); + return; } await ctx.db.patch(flow._id, { out: args.out, From acd54a85c775a52613c86f94c046376a8c5aec47 Mon Sep 17 00:00:00 2001 From: Ian Macartney Date: Fri, 24 Oct 2025 23:10:45 -0700 Subject: [PATCH 3/7] readme --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f6b4074..0402c02 100644 --- a/README.md +++ b/README.md @@ -116,8 +116,8 @@ is designed to feel like a Convex action but with a few restrictions: 1. The workflow runs in the background, so it can't return a value. 2. The workflow must be _deterministic_, so it should implement most of its logic by calling out to other Convex functions. We restrict access to some - non-deterministic functions like `Math.random()` and `fetch`. Others we - patch, such as `console` for logging and `Date` for time. + non-deterministic functions like `fetch` and `crypto`. Others we patch, such + as `console` for logging, `Math.random()` (seeded PRNG) and `Date` for time. Note: To help avoid type cycles, always annotate the return type of the `handler` with the return type of the workflow. @@ -470,8 +470,10 @@ Here are a few limitations to keep in mind: (including the workflow state overhead). See more about mutation limits here: https://docs.convex.dev/production/state/limits#transactions - We currently do not collect backtraces from within function calls from workflows. -- If you need to use side effects like `fetch` or use randomness, +- If you need to use side effects like `fetch` or use cryptographic randomness, you'll need to do that in a step, not in the workflow definition. +- `Math.random` is deterministic and not suitable for cryptographic use. It is, + however, useful for sharding, jitter, and other pseudo-random applications. - If the implementation of the workflow meaningfully changes (steps added, removed, or reordered) then it will fail with a determinism violation. The implementation should stay stable for the lifetime of active workflows. From bd7b359b1fd3be88afe338e4dc6bf4f31eff427e Mon Sep 17 00:00:00 2001 From: Ian Macartney Date: Fri, 24 Oct 2025 23:11:59 -0700 Subject: [PATCH 4/7] 0.2.8-alpha.10 --- CHANGELOG.md | 1 + package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efbcba4..4f862fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ This helps when you have statically generated types which can't do branded strings. - Adds a /test entrypoint to make testing easier - Exports the `WorkflowCtx` and `WorkflowStep` types +- Support for Math.random via seeded PRNG. ## 0.2.7 diff --git a/package-lock.json b/package-lock.json index 7ac4079..1f71a18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@convex-dev/workflow", - "version": "0.2.8-alpha.9", + "version": "0.2.8-alpha.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@convex-dev/workflow", - "version": "0.2.8-alpha.9", + "version": "0.2.8-alpha.10", "license": "Apache-2.0", "dependencies": { "async-channel": "^0.2.0" diff --git a/package.json b/package.json index 6c5dea1..80898a2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@convex-dev/workflow", - "version": "0.2.8-alpha.9", + "version": "0.2.8-alpha.10", "description": "Convex component for durably executing workflows.", "keywords": [ "convex", From 8e118a086a05f9699e6c4a0198ff24df60696dcd Mon Sep 17 00:00:00 2001 From: Ian Macartney Date: Tue, 28 Oct 2025 15:59:04 -0700 Subject: [PATCH 5/7] better random --- src/client/environment.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/client/environment.ts b/src/client/environment.ts index 80900d1..cd1bcc2 100644 --- a/src/client/environment.ts +++ b/src/client/environment.ts @@ -11,14 +11,14 @@ function hashString(str: string): number { return hash >>> 0; // Ensure unsigned } -// Mulberry32 - a simple, fast seeded PRNG +// A simple, fast seeded PRNG +// https://gist.github.com/tommyettinger/46a874533244883189143505d203312c?permalink_comment_id=4854318#gistcomment-4854318 function createSeededRandom(seed: number): () => number { - return function () { - seed |= 0; - seed = (seed + 0x6d2b79f5) | 0; - let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + return () => { + seed = (seed + 0x9e3779b9) | 0; + let t = Math.imul(seed ^ (seed >>> 16), 0x21f0aaad); + t = Math.imul(t ^ (t >>> 15), 0x735a2d97); + return ((t ^ (t >>> 15)) >>> 0) / 4294967296; }; } From d6ced28acba13c342af30c3ae74c9c3b96455d59 Mon Sep 17 00:00:00 2001 From: Ian Macartney Date: Thu, 30 Oct 2025 01:42:00 -0700 Subject: [PATCH 6/7] reference schema --- convex.json | 1 + 1 file changed, 1 insertion(+) diff --git a/convex.json b/convex.json index 1704a04..a7e9ba3 100644 --- a/convex.json +++ b/convex.json @@ -1,3 +1,4 @@ { + "$schema": "./node_modules/convex/schemas/convex.schema.json", "functions": "example/convex" } From aef205140242005dc95387aa7a3b749e90dcc0b6 Mon Sep 17 00:00:00 2001 From: Ian Macartney Date: Thu, 30 Oct 2025 01:43:25 -0700 Subject: [PATCH 7/7] 0.2.8-alpha.11 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1f71a18..814d171 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@convex-dev/workflow", - "version": "0.2.8-alpha.10", + "version": "0.2.8-alpha.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@convex-dev/workflow", - "version": "0.2.8-alpha.10", + "version": "0.2.8-alpha.11", "license": "Apache-2.0", "dependencies": { "async-channel": "^0.2.0" diff --git a/package.json b/package.json index 80898a2..8c25793 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@convex-dev/workflow", - "version": "0.2.8-alpha.10", + "version": "0.2.8-alpha.11", "description": "Convex component for durably executing workflows.", "keywords": [ "convex",