Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions convex.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{
"$schema": "./node_modules/convex/schemas/convex.schema.json",
"functions": "example/convex"
}
8 changes: 6 additions & 2 deletions example/convex/example.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
27 changes: 19 additions & 8 deletions src/client/environment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
38 changes: 30 additions & 8 deletions src/client/environment.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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;
}
Expand Down Expand Up @@ -63,11 +84,12 @@ export function createDeterministicDate(

export function setupEnvironment(
getGenerationState: () => GenerationState,
workflowId: string,
): void {
const global = globalThis as Record<string, unknown>;

// 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;
Expand Down
2 changes: 1 addition & 1 deletion src/client/workflowMutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ export function workflowMutation<ArgsValidator extends PropertyValidators>(
Date.now(),
workpoolOptions,
);
setupEnvironment(executor.getGenerationState.bind(executor));
setupEnvironment(executor.getGenerationState.bind(executor), workflowId);

const handlerWorker = async (): Promise<WorkerResult> => {
let runResult: RunResult;
Expand Down