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
30 changes: 30 additions & 0 deletions .changeset/seed-autonumber-read-outage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
"@objectstack/objectql": patch
---

fix(objectql): `seedAutonumber` 把非「表未建」的读故障上抛,不再从 0 重发自增号 (#5979)

引擎在 driver 不自带自增序列时,走 fallback 路径:先从库里读 `MAX(existing)`
给内存计数器播种,再自增发号。这次播种读此前挂在一个裸 `} catch { return 0; }`
后面 —— **所有**失败(连接断开、超时、权限拒绝、查询错误)都被答成跟「表里真的
一行都没有」完全相同的 `0`。

这两件事是相反的事实(ADR-0110 D3),在这里混淆是 #4728 / #4825 / #5108 同族里
代价最高的一支:对一张已经有 N 行的表,一次抖动的读就让序列从 1 重新开始,发出
与既有行**相撞**的自增号。插入是**成功**的,一行日志都没有,而撞号落在业务标识符
上 —— 一个写错了的值,重试修不好,重启也修不好。危险本身早就写在那个读上方的
注释里(#4371:"the catch below would have swallowed the guard's rejection into
'seed from 0', i.e. duplicate autonumbers");当时读被修了,catch 没有。

现在按错误**类型**判别,问的是共享谓词 `isMissingTableError`
(`@objectstack/metadata/errors`,#4825),而不是手抄一份 `code === '42P01'`:

- **表尚未 provision** → 仍旧从 0 起号。库里确实没有行,1 号不会撞到任何东西,
这是唯一良性的失败原因,行为与此前完全一致。
- **其余任何读故障** → 原样上抛,**不发号、不写入**。行没被看见不等于行不存在,
所以引擎拒绝用一份自己从没读到的数据去推算号段。

**行为变化(升级须知)**:自增字段所在对象的写入,在存储读故障期间会**失败**,
而不再像以前那样"成功"并写入一个可能撞号的编号。这是有意的 —— 一次响亮的失败
可以重试,一个静默写错的业务编号不能。故障恢复后下一次插入会重新播种并从真实的
`MAX(existing)` 继续(失败的播种不会污染内存计数器)。
1 change: 1 addition & 0 deletions packages/objectql/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"dependencies": {
"@objectstack/core": "workspace:*",
"@objectstack/formula": "workspace:*",
"@objectstack/metadata": "workspace:*",
"@objectstack/metadata-core": "workspace:*",
"@objectstack/metadata-protocol": "workspace:*",
"@objectstack/spec": "workspace:*",
Expand Down
257 changes: 257 additions & 0 deletions packages/objectql/src/engine-autonumber-seed-outage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #5979 — `seedAutonumber` must not answer a READ OUTAGE with "seed from 0".
*
* The engine's fallback autonumber path seeds its in-memory counter from
* `MAX(existing)` in the store, then increments. That seeding read used to sit
* behind a bare `} catch { return 0; }`: EVERY failure — connection drop,
* timeout, permission denial, query error — was answered with the same `0` a
* genuinely empty table produces.
*
* Those are opposite facts (ADR-0110 D3), and conflating them here is the
* costly half of the #4728 / #4825 / #5108 family. Against a table that already
* holds N rows, one flaky read restarts the sequence at 1 and issues autonumbers
* that COLLIDE with existing ones. The insert SUCCEEDS, nothing is logged, and
* the collision lands in a business identifier — a value written wrong, which no
* retry and no restart repairs. The hazard was already named in the comment
* directly above the read (#4371: "the catch below would have swallowed the
* guard's rejection into 'seed from 0', i.e. duplicate autonumbers"); the read
* was fixed there, the catch was not.
*
* The fix discriminates by error TYPE through the shared `isMissingTableError`
* predicate (`@objectstack/metadata/errors`, #4825) — never a hand-rolled
* `code === '42P01'` copy, which would be the second "which driver errors are
* benign" vocabulary that module exists to retire:
*
* - table never provisioned → seed from 0 (there are genuinely no rows, so
* number 1 collides with nothing);
* - every other read failure → propagate, allocate NOTHING, write NOTHING.
*
* These tests drive a fake DRIVER (not a fake engine), so no engine write-verb
* dispatch contract is involved.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ObjectQL } from './engine';
import { SchemaRegistry } from './registry';
import type { IDataDriver } from '@objectstack/spec/contracts';

vi.mock('./registry', () => {
const instance: any = {
getObject: vi.fn(),
resolveObject: vi.fn((n: string) => instance.getObject(n)),
registerObject: vi.fn(),
getObjectOwner: vi.fn(),
registerNamespace: vi.fn(),
registerKind: vi.fn(),
registerItem: vi.fn(),
registerApp: vi.fn(),
installPackage: vi.fn(),
reset: vi.fn(),
metadata: { get: vi.fn(() => new Map()) },
};
function SchemaRegistry() {
return instance;
}
Object.assign(SchemaRegistry, instance);
return {
SchemaRegistry,
computeFQN: (_ns: string | undefined, name: string) => name,
parseFQN: (fqn: string) => ({ namespace: undefined, shortName: fqn }),
RESERVED_NAMESPACES: new Set(['base', 'system']),
};
});

const DOC_SCHEMA = {
name: 'doc',
fields: {
title: { type: 'text' },
doc_no: { type: 'autonumber', required: true, format: 'D-{0000}' },
},
};

/**
* A driver whose seeding read (`find`) behaves as `findBehaviour` says, and
* which records every row it was asked to create. `create` recording is the
* load-bearing half: the defect's signature is a write that SUCCEEDS with a
* colliding number, so "no row reached the driver" is what proves the fix.
*/
function makeDriver(findBehaviour: () => Promise<any[]>): IDataDriver & {
created: any[];
} {
const created: any[] = [];
const driver: any = {
name: 'memory',
version: '0.0.0',
// No native autonumber → the engine takes the fallback seeding path.
supports: {},
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
checkHealth: vi.fn().mockResolvedValue(true),
execute: vi.fn(),
find: vi.fn(findBehaviour),
findOne: vi.fn(),
create: vi.fn(async (_obj: string, row: any) => {
created.push(row);
return { id: `r${created.length}`, ...row };
}),
update: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
};
driver.created = created;
return driver as any;
}

/** Driver-shaped errors that mean "the table was never provisioned". */
const MISSING_TABLE_ERRORS: Array<[string, () => unknown]> = [
['PostgreSQL 42P01 undefined_table', () => Object.assign(new Error('relation "doc" does not exist'), { code: '42P01' })],
['MySQL ER_NO_SUCH_TABLE', () => Object.assign(new Error("Table 'app.doc' doesn't exist"), { code: 'ER_NO_SUCH_TABLE', errno: 1146 })],
['SQLite message-only', () => new Error('no such table: doc')],
];

/**
* Driver-shaped errors that mean "the rows may well exist — I just could not
* see them". Each is a real outage class the old bare catch answered with 0.
*/
const OUTAGE_ERRORS: Array<[string, () => unknown]> = [
['connection refused', () => Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:5432'), { code: 'ECONNREFUSED' })],
['statement timeout', () => Object.assign(new Error('canceling statement due to statement timeout'), { code: '57014' })],
['permission denied', () => Object.assign(new Error('permission denied for table doc'), { code: '42501' })],
['connection terminated mid-query', () => Object.assign(new Error('Connection terminated unexpectedly'), { code: '08006' })],
];

describe('ObjectQL seedAutonumber — read outage must not restart the sequence (#5979)', () => {
let engine: ObjectQL;

beforeEach(() => {
vi.clearAllMocks();
vi.mocked(SchemaRegistry.getObject).mockReturnValue(DOC_SCHEMA as any);
engine = new ObjectQL();
});

// ---------------------------------------------------------------- benign --

describe('table not provisioned → seed from 0 (benign, unchanged)', () => {
for (const [label, make] of MISSING_TABLE_ERRORS) {
it(`seeds from 0 and issues number 1 — ${label}`, async () => {
const driver = makeDriver(async () => {
throw make();
});
engine.registerDriver(driver, true);
await engine.init();

const result = await engine.insert('doc', { title: 'First' });

// There are genuinely no rows, so 1 collides with nothing.
expect(result.doc_no).toBe('D-0001');
expect(driver.created).toHaveLength(1);
expect(driver.created[0].doc_no).toBe('D-0001');
});
}

it('keeps counting in memory after a benign seed (second insert is 2)', async () => {
const driver = makeDriver(async () => {
throw Object.assign(new Error('no such table: doc'), {});
});
engine.registerDriver(driver, true);
await engine.init();

const a = await engine.insert('doc', { title: 'First' });
const b = await engine.insert('doc', { title: 'Second' });

expect(a.doc_no).toBe('D-0001');
expect(b.doc_no).toBe('D-0002');
});
});

// ---------------------------------------------------------------- outage --

describe('read outage → propagate, allocate nothing, write nothing', () => {
for (const [label, make] of OUTAGE_ERRORS) {
it(`rethrows and writes NOTHING — ${label}`, async () => {
const driver = makeDriver(async () => {
throw make();
});
engine.registerDriver(driver, true);
await engine.init();

await expect(engine.insert('doc', { title: 'First' })).rejects.toThrow();

// The whole point: no row reached the driver, so no autonumber was
// issued from data the engine never read.
expect(driver.create).not.toHaveBeenCalled();
expect(driver.created).toHaveLength(0);
});
}

it('propagates the ORIGINAL driver error, not a synthesized one', async () => {
const original = Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:5432'), {
code: 'ECONNREFUSED',
});
const driver = makeDriver(async () => {
throw original;
});
engine.registerDriver(driver, true);
await engine.init();

// The caller needs the driver's own diagnosis to act on; swallowing it
// into a generic failure would repeat the zero-signal half of the defect.
await expect(engine.insert('doc', { title: 'First' })).rejects.toThrow(/ECONNREFUSED/);
});

/**
* The defect's actual damage, pinned directly: a table already holding
* D-0007 must never be handed D-0001 because one read failed.
*/
it('does NOT restart at 1 against a table that already holds rows', async () => {
let outage = true;
const driver = makeDriver(async () => {
if (outage) throw Object.assign(new Error('Connection terminated unexpectedly'), { code: '08006' });
return [{ id: 'r1', doc_no: 'D-0007' }];
});
engine.registerDriver(driver, true);
await engine.init();

// During the outage the write fails rather than forging a colliding number.
await expect(engine.insert('doc', { title: 'During outage' })).rejects.toThrow();
expect(driver.created).toHaveLength(0);

// Once the store recovers, seeding reads the real max and continues from
// it. This also proves the failed seed poisoned no in-memory counter —
// had the outage cached a 0, this would come back D-0001.
outage = false;
const recovered = await engine.insert('doc', { title: 'After recovery' });
expect(recovered.doc_no).toBe('D-0008');
});
});

// ---------------------------------------------------------------- normal --

describe('normal read path is unchanged', () => {
it('seeds from the max of existing rows', async () => {
const driver = makeDriver(async () => [
{ id: 'r1', doc_no: 'D-0003' },
{ id: 'r2', doc_no: 'D-0011' },
{ id: 'r3', doc_no: 'D-0007' },
]);
engine.registerDriver(driver, true);
await engine.init();

const result = await engine.insert('doc', { title: 'Next' });

expect(result.doc_no).toBe('D-0012');
});

it('seeds from 0 when the table exists and is genuinely empty', async () => {
const driver = makeDriver(async () => []);
engine.registerDriver(driver, true);
await engine.init();

const result = await engine.insert('doc', { title: 'First' });

expect(result.doc_no).toBe('D-0001');
});
});
});
25 changes: 23 additions & 2 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ import {
type DatasourceUnavailableKind,
} from './driver-connect-errors.js';
import { resolveAllowDriverConnectFailure } from '@objectstack/types';
// [#5979] The ONE shared "which read failure is benign?" predicate (#4825
// family). Imported from the leaf `/errors` subpath — which exists precisely
// so a cross-package consumer gets the 40-line predicate without the manager,
// the loaders or the YAML/filesystem machinery behind `@objectstack/metadata`'s
// root entry. Asking the shared predicate rather than hand-rolling a
// `code === '42P01'` test here is load-bearing, not stylistic: a second
// vocabulary of "benign driver error" is the exact debt that module exists to
// retire, and `check:durability-log-level` exempts only this declared name.
import { isMissingTableError } from '@objectstack/metadata/errors';

/**
* Per-row outcome of {@link ObjectQL.insertMany} (framework#3172). One entry
Expand Down Expand Up @@ -2099,8 +2108,20 @@ export class ObjectQL implements IObjectQLEngine {
if (digits) max = Math.max(max, parseInt(digits, 10) || 0);
}
return max;
} catch {
return 0;
} catch (error) {
// [#5979] Discriminate by error TYPE. Seeding from 0 is the truth for
// exactly ONE failure reason — the table has not been provisioned, so
// there are genuinely no rows and number 1 collides with nothing.
if (isMissingTableError(error)) return 0;
// Every other failure (connection drop, timeout, permission denial,
// query error) means the rows may well exist and simply were not seen.
// Answering 0 there restarts the sequence at 1 against a table already
// holding N rows and issues autonumbers that COLLIDE with existing ones
// — a value written wrong, which no retry and no restart repairs. So the
// read failure propagates and the caller allocates nothing: the write
// fails loudly instead of succeeding with a forged business identifier.
// This is the hazard the #4371 comment above the read already named.
throw error;
}
}

Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 0 additions & 9 deletions scripts/durability-read-invention.baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,6 @@
"closes": "Ask `isMissingTableError` (already imported in this file) and rethrow everything else, exactly as `rethrowUnlessMetadataStoreUnprovisioned` a few thousand lines up already does for the overlay reads. Tracked separately — a gate PR does not edit the packages it scans.",
"tracked_by": "#5980"
},
{
"file": "packages/objectql/src/engine.ts",
"fn": "seedAutonumber",
"verdict": "unfixed-degradation",
"invents": "return 0 (catch has no log and no error-type discrimination)",
"why": "The #4825 shape, live, and the costly half of the family: this seeds an autonumber counter from `MAX(existing)` and answers `0` when the read fails. Against a table that already holds rows, the next allocation restarts at 1 and issues autonumbers that COLLIDE with existing ones — a value written wrong, which no retry and no restart repairs, with not one line logged. The code comment directly above the read already names the hazard ('the catch below would have swallowed the guard's rejection into \"seed from 0\", i.e. duplicate autonumbers') — the read was fixed there, the catch was not.",
"closes": "Discriminate by error type: a never-provisioned table genuinely has no rows and may seed from 0; every other failure must propagate so the write does not allocate a number derived from data it never read. Tracked separately — a gate PR does not edit the packages it scans.",
"tracked_by": "#5979"
},
{
"file": "packages/objectql/src/engine.ts",
"fn": "referenceExists",
Expand Down
Loading