Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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
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.10",
"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
}

// Mulberry32 - a simple, fast seeded PRNG
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@goffrie does this pass a 10 second gut check by you? I'll admit I didn't come up with this

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Supposedly the author says it's "not great" (ref) but it's not a big deal I suppose 🤷

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll just use what they landed on :P

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
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
Loading