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/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. 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" } 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, diff --git a/package-lock.json b/package-lock.json index 7ac4079..814d171 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.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@convex-dev/workflow", - "version": "0.2.8-alpha.9", + "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 6c5dea1..8c25793 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.11", "description": "Convex component for durably executing workflows.", "keywords": [ "convex", 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..cd1bcc2 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 +} + +// A simple, fast seeded PRNG +// https://gist.github.com/tommyettinger/46a874533244883189143505d203312c?permalink_comment_id=4854318#gistcomment-4854318 +function createSeededRandom(seed: number): () => number { + 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; + }; +} + +// 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;