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
11 changes: 11 additions & 0 deletions .changeset/hash-shadow-null-safe-duplicate-group-tail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@objectstack/driver-sql": patch
---

The hash-shadow NULL-safe durability log now counts its overflow duplicate groups in the same words as the other three reports that render the same rows (#16289)

`formatDuplicateGroups` is module-local in `sql-driver.ts` for one stated reason, quoted from its own docblock: the sites that report a blocked unique "must name the SAME rows in the SAME shape, and a second hand-rolled `.slice(0, 5).join('; ')` is exactly how the plain and the NULL-safe path drifted apart in the first place". Four sites render duplicate groups — the drift entry, the direct arm's plain-unique log, the hash-shadow arm's plain-unique log, and the hash-shadow arm's NULL-safe branch — and the fourth still hand-rolled that exact shape.

So the drift the helper exists to prevent had already recurred, in the overflow tail: the helper writes `; …and N more group(s)`, the hand-rolled copy wrote `; …and N more`. Two durability logs about the same failure class, emitted from the same `catch`, disagreed on how they say "there are more".

What an operator sees: when a hash-shadow NULL-safe unique index is blocked by more than five conflicting groups in one table, the boot-time durability line now ends `; …and N more group(s).` instead of `; …and N more.`. The surrounding ` Conflicting group(s): ….` framing, the five groups rendered in full, their `(key) × N rows` spelling and their order are unchanged, and so is every other line. No behaviour, no data effect, no API movement — the five-then-count rendering is now owned in one place for all four sites.
Original file line number Diff line number Diff line change
Expand Up @@ -232,5 +232,63 @@ declareDialectCell(MYSQL_CELL, 'hash-shadow NULL-safe key (#12998)', (cell) => {
expect(msg).toContain("COALESCE(organization_id, '__global__')");
expect(msg).not.toContain('HASH COLLISION');
}, 60_000);

/**
* The OVERFLOW TAIL — the half of that message no test in this repo could
* see. The report is assembled by the module-local `formatDuplicateGroups`
* (#14902), shared with the drift entry and both plain-unique logs, and it
* renders at most FIVE groups before counting the rest. Every duplicate
* fixture in this package conflicts a single group, so the tail had never
* been rendered by a test at all, and the `Conflicting group(s):`
* assertion above is a PREFIX — green whatever follows it.
*
* #16289 is that hole cashing in: this arm hand-rolled the same
* five-then-count shape the helper exists to own, and its tail read
* `…and N more` where the helper's reads `…and N more group(s)`. Two
* durability logs about the same failure class, emitted from the same
* `catch`, disagreed on how they say "there are more" — the exact drift
* the helper's docblock names, recurring on the one arm left behind.
*
* SIX groups is the smallest fixture in which the tail renders at all,
* and the five-shown count is what makes this a pin on the SHARED
* renderer rather than on one arm's private spelling of it.
*/
it('counts the sixth conflicting group in the shared "more group(s)" tail', async () => {
driver = new SqlDriver(cell.config());
const logs: string[] = [];
(driver as any).logger = {
warn: (msg: string) => logs.push(String(msg)),
error: (msg: string) => logs.push(String(msg)),
};
const bare = orgUniqueOn('os12998_tail');
// Bound to a variable, like the fixture above: `initObjects`' parameter
// type does not declare `indexes`, and an inline literal would be
// rejected by tsc for a key the driver reads regardless (#16570).
const withoutIndex = { ...bare, indexes: [] };
await driver.initObjects([withoutIndex]);
const knex = (driver as any).knex;
// Six DISTINCT values, each doubled under a NULL organization: six
// conflicting groups once the declared key folds NULL into the global
// bucket. Long, like every fixture here, because the over-the-ceiling
// TEXT column is what makes MySQL refuse the direct index and take the
// shadow route into this arm.
const rows = ['a', 'b', 'c', 'd', 'e', 'f'].flatMap((ch) => {
const v = ch.repeat(900);
return [
{ id: `${ch}1`, v, organization_id: null },
{ id: `${ch}2`, v, organization_id: null },
];
});
await knex('os12998_tail').insert(rows);

await expect(driver.initObjects([bare])).resolves.not.toThrow();

const diagnosis = logs.find((l) => l.includes('cannot create hash-shadow unique index'));
expect(diagnosis, 'the degradation must be logged').toBeTruthy();
// Five groups rendered in full …
expect(String(diagnosis).match(/\u00d7 2 rows/g) ?? []).toHaveLength(5);
// … and the sixth counted in the helper's wording, not a second one.
expect(diagnosis).toMatch(/; \u2026and 1 more group\(s\)\./);
}, 60_000);
});
});
8 changes: 1 addition & 7 deletions packages/drivers/driver-sql/src/sql-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11938,13 +11938,7 @@ export class SqlDriver implements IDataDriver {
...nullSafe,
]);
if (duplicates.length > 0) {
const shown = duplicates
.slice(0, 5)
.map((g) => `(${g.key}) × ${g.rows} rows`)
.join('; ');
report = ` Conflicting group(s): ${shown}${
duplicates.length > 5 ? `; …and ${duplicates.length - 5} more` : ''
}.`;
report = ` Conflicting group(s): ${formatDuplicateGroups(duplicates)}.`;
}
} catch {
// The probe is a diagnostic; the refusal below stands without it.
Expand Down
Loading