Skip to content
Merged
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
6 changes: 3 additions & 3 deletions src/commands/underwriterFamily.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ describe("ipoIssuersByUnderwriterFamilyName", () => {
canonical_underwriter_family_id: "fam-1",
resolver_version: "1.0.0",
display_name: "Goldman Sachs",
normalized_name: "goldman sachs",
normalized_name: "GOLDMAN SACHS",
created_at: new Date().toISOString(),
});
await new UnderwriterLinkRepo().save({
Expand All @@ -47,14 +47,14 @@ describe("ipoIssuersByUnderwriterFamilyName", () => {
canonical_underwriter_family_id: "fam-1",
resolver_version: "1.0.0",
display_name: "Goldman Sachs",
normalized_name: "goldman sachs",
normalized_name: "GOLDMAN SACHS",
created_at: new Date().toISOString(),
});
await families.create({
canonical_underwriter_family_id: "gs-variant",
resolver_version: "1.0.0",
display_name: "Goldman Sachs Group",
normalized_name: "goldman sachs group",
normalized_name: "GOLDMAN SACHS GROUP",
created_at: new Date().toISOString(),
});
// The AI-emitted variant is merged into the canonical family.
Expand Down
32 changes: 32 additions & 0 deletions src/resolver/FamilyResolver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* @license
* Copyright 2026 Steven Roussey <sroussey@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, expect, it } from "bun:test";
import { normalizeFamilyName } from "./FamilyResolver";

describe("normalizeFamilyName", () => {
it("upper-cases the result so existing canonical rows stay reachable", () => {
expect(normalizeFamilyName("Goldman Sachs")).toBe("GOLDMAN SACHS");
expect(normalizeFamilyName("Pershing Square Sponsor")).toBe("PERSHING SQUARE SPONSOR");
});

it("is case-insensitive (folds upper / lower / mixed to the same key)", () => {
const upper = normalizeFamilyName("GOLDMAN SACHS");
const lower = normalizeFamilyName("goldman sachs");
const mixed = normalizeFamilyName("Goldman Sachs");
expect(upper).toBe(lower);
expect(lower).toBe(mixed);
});

it("collapses internal whitespace via normalizeCompanyName", () => {
expect(normalizeFamilyName("Goldman Sachs")).toBe("GOLDMAN SACHS");
});

it("returns '' for empty / whitespace-only / null-normalized input", () => {
expect(normalizeFamilyName("")).toBe("");
expect(normalizeFamilyName(" ")).toBe("");
});
});
32 changes: 25 additions & 7 deletions src/resolver/FamilyResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,19 @@ import { AsyncMutex } from "../util/AsyncMutex";
* The single source of truth for a family natural key (sponsor or underwriter).
* Normalizes the name via {@link normalizeCompanyName} (punctuation/whitespace
* canonicalization plus the suffix handling that helper applies), then
* lower-cases so matching is case-insensitive. Every caller that looks up a
* UPPER-cases so matching is case-insensitive. Every caller that looks up a
* family by name (resolver, CLI query, alias commands) MUST use this so keys
* line up. Returns "" when the name normalizes to nothing.
*
* Case convention is UPPER and is locked in: existing
* canonical_sponsor_family.normalized_name rows were minted under this
* convention, and the alias table keys those canonical_*_id ids directly, so
* changing the fold here would silently orphan rows and operator-installed
* aliases. FamilyResolver.test.ts pins this — do not change without a
* one-time migration.
*/
export function normalizeFamilyName(name: string): string {
return normalizeCompanyName(name)?.toLowerCase() ?? "";
return normalizeCompanyName(name)?.toUpperCase() ?? "";
}

interface FamilyResolverOptions {
Expand All @@ -35,6 +42,17 @@ interface FamilyResolverOptions {
* Shared core for the sponsor-family / underwriter-family resolvers: normalize ->
* find-or-create at the active resolver version (serialized per key) -> alias
* resolve. Name-only analogue of CompanyResolver's normalized-name fallback.
*
* Concurrency: alias resolution runs INSIDE the per-key mutex so a concurrent
* caller that queues behind us cannot observe the freshly-minted candidate
* before the alias rewrite is applied. Without this, two parallel resolves on
* the same family name could split: one returns the alias target, the other
* returns the pre-alias id. Mirrors the {@link PersonResolver} /
* {@link CompanyResolver} fix.
*
* Multi-process callers (workers, separate `sec` invocations) still need a
* backend-level UNIQUE constraint to be race-free — single-process mutexes
* are not visible to other processes.
*/
export class FamilyResolver {
private static readonly _keyMutexes = new Map<string, { mutex: AsyncMutex; refs: number }>();
Expand All @@ -55,12 +73,12 @@ export class FamilyResolver {
}
entry.refs += 1;

let candidateId: string;
let resolvedId: string;
try {
candidateId = await entry.mutex.lock(async () => {
resolvedId = await entry.mutex.lock(async () => {
const existing = await this.opts.findIdByNormalizedName(normalized);
if (existing) return existing;
return this.opts.createFamily(commonName, normalized);
const candidateId = existing ?? (await this.opts.createFamily(commonName, normalized));
return await this.opts.resolveAlias(candidateId);
});
} finally {
entry.refs -= 1;
Expand All @@ -69,6 +87,6 @@ export class FamilyResolver {
}
}

return this.opts.resolveAlias(candidateId);
return resolvedId;
}
}
48 changes: 48 additions & 0 deletions src/resolver/SponsorFamilyResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { beforeEach, describe, expect, it } from "bun:test";
import { resetDependencyInjectionsForTesting } from "../config/TestingDI";
import { CanonicalSponsorFamilyRepo } from "../storage/canonical/CanonicalSponsorFamilyRepo";
import { CanonicalSponsorFamilyAliasRepo } from "../storage/canonical/CanonicalSponsorFamilyAliasRepo";
import type { CanonicalSponsorFamily } from "../storage/canonical/CanonicalSponsorFamilySchema";
import { SponsorFamilyResolver } from "./SponsorFamilyResolver";

describe("SponsorFamilyResolver", () => {
Expand Down Expand Up @@ -37,4 +38,51 @@ describe("SponsorFamilyResolver", () => {
await aliases.add(y, x, "variant", "op");
expect(await resolver.resolve("Acme Sponsors")).toBe(x);
});

it("serialises alias lookup inside the per-key mutex (no overlapping alias.resolve calls)", async () => {
// Pre-fix code ran alias.resolve OUTSIDE the per-key mutex, so two parallel
// resolves on the same family name overlapped in time on alias.resolve.
// Post-fix code runs it INSIDE the mutex so the alias lookups serialise.
// We measure that window directly via an in-flight counter.
const families = new CanonicalSponsorFamilyRepo();
const aliases = new CanonicalSponsorFamilyAliasRepo();
const resolver = new SponsorFamilyResolver({
canonicalSponsorFamilyRepo: families,
canonicalSponsorFamilyAliasRepo: aliases,
activeResolverVersion: "1.0.0",
});

const originalCreate = families.create.bind(families);
let createCount = 0;
families.create = (async (row: CanonicalSponsorFamily) => {
createCount += 1;
return originalCreate(row);
}) as typeof families.create;

let inFlight = 0;
let maxInFlight = 0;
let aliasCallCount = 0;
aliases.resolve = async (id: string) => {
inFlight += 1;
if (inFlight > maxInFlight) maxInFlight = inFlight;
aliasCallCount += 1;
// Sleep long enough that a parallel caller is guaranteed to start
// before this one resolves.
await new Promise((r) => setTimeout(r, 10));
inFlight -= 1;
return id; // alias not installed — we only care about the in-flight window
};

await Promise.all([
resolver.resolve("Pershing Square Sponsor"),
resolver.resolve("Pershing Square Sponsor"),
]);

// Post-fix: alias lookup is inside the mutex → at most one in flight.
// Pre-fix: alias lookup is after mutex.release → both run in parallel.
expect(maxInFlight).toBe(1);
expect(aliasCallCount).toBe(2);
// Exactly one canonical row was minted — find-or-create remains serialised.
expect(createCount).toBe(1);
});
});
8 changes: 4 additions & 4 deletions src/resolver/SponsorFamilyResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ interface SponsorFamilyResolverOptions {
/**
* The single source of truth for the sponsor-family natural key. Delegates to
* {@link normalizeFamilyName} (punctuation/whitespace canonicalization plus the
* suffix handling that helper applies, then lower-casing for case-insensitive
* matching). Every caller that looks up a family by name (resolver, CLI query,
* alias commands) MUST use this so keys line up. Returns "" when the name
* normalizes to nothing.
* suffix handling that helper applies, then UPPER-casing for case-insensitive
* matching against `canonical_sponsor_family.normalized_name`). Every caller
* that looks up a family by name (resolver, CLI query, alias commands) MUST
* use this so keys line up. Returns "" when the name normalizes to nothing.
*/
export function normalizeSponsorFamilyName(name: string): string {
return normalizeFamilyName(name);
Expand Down
48 changes: 48 additions & 0 deletions src/resolver/UnderwriterFamilyResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { beforeEach, describe, expect, it } from "bun:test";
import { resetDependencyInjectionsForTesting } from "../config/TestingDI";
import { CanonicalUnderwriterFamilyRepo } from "../storage/canonical/CanonicalUnderwriterFamilyRepo";
import { CanonicalUnderwriterFamilyAliasRepo } from "../storage/canonical/CanonicalUnderwriterFamilyAliasRepo";
import type { CanonicalUnderwriterFamily } from "../storage/canonical/CanonicalUnderwriterFamilySchema";
import { UnderwriterFamilyResolver } from "./UnderwriterFamilyResolver";

describe("UnderwriterFamilyResolver", () => {
Expand Down Expand Up @@ -37,4 +38,51 @@ describe("UnderwriterFamilyResolver", () => {
await aliases.add(y, x, "variant", "op");
expect(await resolver.resolve("Morgan Stanley & Co.")).toBe(x);
});

it("serialises alias lookup inside the per-key mutex (no overlapping alias.resolve calls)", async () => {
// Pre-fix code ran alias.resolve OUTSIDE the per-key mutex, so two parallel
// resolves on the same family name overlapped in time on alias.resolve.
// Post-fix code runs it INSIDE the mutex so the alias lookups serialise.
// We measure that window directly via an in-flight counter.
const families = new CanonicalUnderwriterFamilyRepo();
const aliases = new CanonicalUnderwriterFamilyAliasRepo();
const resolver = new UnderwriterFamilyResolver({
canonicalUnderwriterFamilyRepo: families,
canonicalUnderwriterFamilyAliasRepo: aliases,
activeResolverVersion: "1.0.0",
});

const originalCreate = families.create.bind(families);
let createCount = 0;
families.create = (async (row: CanonicalUnderwriterFamily) => {
createCount += 1;
return originalCreate(row);
}) as typeof families.create;

let inFlight = 0;
let maxInFlight = 0;
let aliasCallCount = 0;
aliases.resolve = async (id: string) => {
inFlight += 1;
if (inFlight > maxInFlight) maxInFlight = inFlight;
aliasCallCount += 1;
// Sleep long enough that a parallel caller is guaranteed to start
// before this one resolves.
await new Promise((r) => setTimeout(r, 10));
inFlight -= 1;
return id; // alias not installed — we only care about the in-flight window
};

await Promise.all([
resolver.resolve("Goldman Sachs"),
resolver.resolve("Goldman Sachs"),
]);

// Post-fix: alias lookup is inside the mutex → at most one in flight.
// Pre-fix: alias lookup is after mutex.release → both run in parallel.
expect(maxInFlight).toBe(1);
expect(aliasCallCount).toBe(2);
// Exactly one canonical row was minted — find-or-create remains serialised.
expect(createCount).toBe(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ describe("processFormS1 underwriters", () => {
const companies = await new CompanyObservationRepo().listAll();
expect(companies.some((c) => c.name === "Goldman Sachs & Co. LLC")).toBe(true);

const family = await new CanonicalUnderwriterFamilyRepo().findByResolverAndName("1.0.0", "goldman sachs");
const family = await new CanonicalUnderwriterFamilyRepo().findByResolverAndName("1.0.0", "GOLDMAN SACHS");
expect(family).toBeDefined();
const members = await new UnderwriterFamilyMembershipRepo().listCompaniesForFamily(
"1.0.0",
Expand Down
4 changes: 2 additions & 2 deletions src/storage/canonical/CanonicalSponsorFamilyRepo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ describe("CanonicalSponsorFamilyRepo", () => {
canonical_sponsor_family_id: "fam-1",
resolver_version: "1.0.0",
display_name: "Pershing Square Sponsor",
normalized_name: "pershing square sponsor",
normalized_name: "PERSHING SQUARE SPONSOR",
created_at: new Date().toISOString(),
});
const found = await repo.findByResolverAndName("1.0.0", "pershing square sponsor");
const found = await repo.findByResolverAndName("1.0.0", "PERSHING SQUARE SPONSOR");
expect(found?.canonical_sponsor_family_id).toBe("fam-1");
});
});
4 changes: 2 additions & 2 deletions src/storage/canonical/CanonicalUnderwriterFamilyRepo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ describe("CanonicalUnderwriterFamilyRepo", () => {
canonical_underwriter_family_id: "uw-1",
resolver_version: "1.0.0",
display_name: "Goldman Sachs",
normalized_name: "goldman sachs",
normalized_name: "GOLDMAN SACHS",
created_at: new Date().toISOString(),
});
const found = await repo.findByResolverAndName("1.0.0", "goldman sachs");
const found = await repo.findByResolverAndName("1.0.0", "GOLDMAN SACHS");
expect(found?.canonical_underwriter_family_id).toBe("uw-1");
});
});