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
8 changes: 6 additions & 2 deletions packages/durabletask-js/src/utils/versioning.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,12 @@ export function compareVersions(sourceVersion: string | undefined, otherVersion:
return 0;
}

// Fallback to lexicographic comparison (case-insensitive)
return sourceVersion!.toLowerCase().localeCompare(otherVersion!.toLowerCase());
// Fallback to ordinal comparison (case-insensitive).
// Use < / > operators instead of localeCompare() for locale-independent
// ordering, matching .NET's StringComparer.OrdinalIgnoreCase.
const sourceLower = sourceVersion!.toLowerCase();
const otherLower = otherVersion!.toLowerCase();
return sourceLower < otherLower ? -1 : sourceLower > otherLower ? 1 : 0;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -863,9 +863,15 @@ class RuntimeOrchestrationEntityFeature implements OrchestrationEntityFeature {
throw new Error("Must not enter another critical section from within a critical section.");
}

// Sort entities for deterministic ordering (prevents deadlocks)
// Use the string representation for consistent ordering
const sortedEntities = [...entityIds].sort((a, b) => a.toString().localeCompare(b.toString()));
// Sort entities for deterministic ordering (prevents deadlocks).
// Use ordinal (code-point) comparison for cross-platform consistency,
// matching .NET's StringComparer.Ordinal. localeCompare() is locale-dependent
// and can produce different orderings on different machines/locales.
Comment on lines +866 to +869
const sortedEntities = [...entityIds].sort((a, b) => {
const aStr = a.toString();
const bStr = b.toString();
return aStr < bStr ? -1 : aStr > bStr ? 1 : 0;
});

// Remove duplicates
const uniqueEntities: EntityInstanceId[] = [];
Expand Down
38 changes: 38 additions & 0 deletions packages/durabletask-js/test/entity-locking.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,44 @@ describe("Entity Locking (Critical Sections)", () => {
expect(lockSet[2]).toBe("@counter@c");
});

it("should sort entities by ordinal (code-point) order, not locale order", async () => {
// Arrange
// In many locales, localeCompare() sorts accented characters adjacent to their
// base letters (e.g., "ä" near "a"). Ordinal (code-point) comparison places
// them by their Unicode value instead. This ensures consistent cross-platform
// ordering that matches the .NET SDK's StringComparer.Ordinal.
Comment on lines +154 to +157
const registry = new Registry();

registry.addOrchestrator(async function* testOrchestration(ctx: any) {
// "Z" (U+005A) < "a" (U+0061) in ordinal order,
// but "a" < "Z" in most locale-aware collations
const entityZ = new EntityInstanceId("store", "Z");
const entityA = new EntityInstanceId("store", "a");
yield ctx.entities.lockEntities(entityA, entityZ);
});

const executor = new OrchestrationExecutor(registry);
const newEvents = [
createOrchestratorStartedEvent(),
createExecutionStartedEvent("testOrchestration"),
];

// Act
const result = await executor.execute("test-instance", [], newEvents);

// Assert
const lockAction = result.actions.find((a) => a.getSendentitymessage()?.hasEntitylockrequested());
expect(lockAction).toBeDefined();

const lockEvent = lockAction!.getSendentitymessage()!.getEntitylockrequested()!;
const lockSet = lockEvent.getLocksetList();

// Ordinal order: "@store@Z" (U+005A) comes before "@store@a" (U+0061)
expect(lockSet.length).toBe(2);
expect(lockSet[0]).toBe("@store@Z");
expect(lockSet[1]).toBe("@store@a");
});

it("should remove duplicate entities", async () => {
// Arrange
const registry = new Registry();
Expand Down
19 changes: 18 additions & 1 deletion packages/durabletask-js/test/versioning.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ describe("compareVersions", () => {
});
});

describe("non-semantic version comparison (lexicographic)", () => {
describe("non-semantic version comparison (ordinal)", () => {
it("should compare non-semver strings lexicographically", () => {
expect(compareVersions("alpha", "beta")).toBeLessThan(0);
expect(compareVersions("beta", "alpha")).toBeGreaterThan(0);
Expand All @@ -87,6 +87,23 @@ describe("compareVersions", () => {
expect(compareVersions("Alpha", "BETA")).toBeLessThan(0);
});

it("should use ordinal (code-point) order, not locale order", () => {
// In ordinal comparison, uppercase letters (U+0041-U+005A) come before
// lowercase letters (U+0061-U+007A) after lowercasing they are equal.
// But characters outside ASCII can differ between ordinal and locale:
Comment on lines +91 to +93
// e.g., "z" (U+007A) < "ä" (U+00E4) in ordinal, but many locales
// sort "ä" right after "a" which is before "z".
const result = compareVersions("z-pre", "ä-pre");
// Ordinal: "z" (U+007A) < "ä" (U+00E4), so result should be negative
expect(result).toBeLessThan(0);
});

it("should produce consistent ordinal results for accented characters", () => {
// "é" (U+00E9) vs "f" (U+0066) — ordinal says "f" < "é",
// but French locale sorts "é" right after "e" (before "f")
expect(compareVersions("f-rc1", "é-rc1")).toBeLessThan(0);
});

it("should compare version strings with prefixes", () => {
expect(compareVersions("v1.0", "v2.0")).toBeLessThan(0);
expect(compareVersions("release-1", "release-2")).toBeLessThan(0);
Expand Down
Loading