From f861fa6e1c8d80b74878d996b9391945665e1d62 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:20:17 +0000 Subject: [PATCH] Replace locale-sensitive localeCompare() with ordinal string comparison Replace localeCompare() with < / > operators in lockEntities() and compareVersions() for locale-independent, deterministic ordering that matches the .NET SDK's StringComparer.Ordinal behavior. Fixes #231 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/utils/versioning.util.ts | 8 +++- .../worker/runtime-orchestration-context.ts | 12 ++++-- .../test/entity-locking.spec.ts | 38 +++++++++++++++++++ .../durabletask-js/test/versioning.spec.ts | 19 +++++++++- 4 files changed, 71 insertions(+), 6 deletions(-) diff --git a/packages/durabletask-js/src/utils/versioning.util.ts b/packages/durabletask-js/src/utils/versioning.util.ts index 2e94230..048debc 100644 --- a/packages/durabletask-js/src/utils/versioning.util.ts +++ b/packages/durabletask-js/src/utils/versioning.util.ts @@ -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; } /** diff --git a/packages/durabletask-js/src/worker/runtime-orchestration-context.ts b/packages/durabletask-js/src/worker/runtime-orchestration-context.ts index 2170af7..b6331da 100644 --- a/packages/durabletask-js/src/worker/runtime-orchestration-context.ts +++ b/packages/durabletask-js/src/worker/runtime-orchestration-context.ts @@ -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. + 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[] = []; diff --git a/packages/durabletask-js/test/entity-locking.spec.ts b/packages/durabletask-js/test/entity-locking.spec.ts index e2ea7da..20b3e1e 100644 --- a/packages/durabletask-js/test/entity-locking.spec.ts +++ b/packages/durabletask-js/test/entity-locking.spec.ts @@ -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. + 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(); diff --git a/packages/durabletask-js/test/versioning.spec.ts b/packages/durabletask-js/test/versioning.spec.ts index 9e396c0..c0bea60 100644 --- a/packages/durabletask-js/test/versioning.spec.ts +++ b/packages/durabletask-js/test/versioning.spec.ts @@ -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); @@ -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: + // 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);