feat(8.0): EntityIdMapper U32 ceiling + EntityIdSpaceExceeded error
The JS fallback EntityIdMapper caps at u32::MAX to match the metadata index's Roaring32 bitmap width. Before this guard, a brain past 4.29 B entities would silently widen `nextId` into JS's safe-integer range, truncating ints inside the bitmaps and zeroing query results — the same silent under-count cortex 3.0's Piece 10 just closed on the native path. Brainy 8.0 surfaces the overflow loudly instead. When `nextId` would exceed `U32_ENTITY_ID_MAX`, `getOrAssign` throws `EntityIdSpaceExceeded` with a message pointing at the cortex 3.0 binary mapper's `idSpace: 'u64'` mode as the migration path (mmap- backed extendible-hash KV, persists the full u64 range losslessly). The existing-UUID lookup path bypasses the guard — only fresh allocations can overflow. Surfaces via `@soulcraft/brainy/internals`: - `EntityIdMapper` (already exported, behavior gated) - `EntityIdSpaceExceeded` (new) - `U32_ENTITY_ID_MAX` (new constant, = 0xFFFF_FFFF) This is the lockstep half of cortex 3.0 / Piece 10 / Step 15. Cortex's `NativeBinaryEntityIdMapperWrapper` ships the U64 binary mapper that takes over above this ceiling; brainy ships the bright-line failure that points consumers at it. New test: tests/unit/utils/entity-id-mapper-u32-ceiling.test.ts (6 assertions covering the constant, the error shape, normal allocations, the boundary case at exactly u32::MAX, the overflow throw, and the existing-uuid lookup bypass).
This commit is contained in:
parent
8f130d3e73
commit
e47fea0917
3 changed files with 158 additions and 2 deletions
|
|
@ -7,7 +7,11 @@ export type { UnifiedCacheConfig, CacheItem } from './utils/unifiedCache.js'
|
||||||
export { prodLog, createModuleLogger } from './utils/logger.js'
|
export { prodLog, createModuleLogger } from './utils/logger.js'
|
||||||
export { FieldTypeInference, FieldType } from './utils/fieldTypeInference.js'
|
export { FieldTypeInference, FieldType } from './utils/fieldTypeInference.js'
|
||||||
export type { FieldTypeInfo } from './utils/fieldTypeInference.js'
|
export type { FieldTypeInfo } from './utils/fieldTypeInference.js'
|
||||||
export { EntityIdMapper } from './utils/entityIdMapper.js'
|
export {
|
||||||
|
EntityIdMapper,
|
||||||
|
EntityIdSpaceExceeded,
|
||||||
|
U32_ENTITY_ID_MAX,
|
||||||
|
} from './utils/entityIdMapper.js'
|
||||||
export type { EntityIdMapperOptions, EntityIdMapperData } from './utils/entityIdMapper.js'
|
export type { EntityIdMapperOptions, EntityIdMapperData } from './utils/entityIdMapper.js'
|
||||||
export { getRecommendedCacheConfig, formatBytes, checkMemoryPressure } from './utils/memoryDetection.js'
|
export { getRecommendedCacheConfig, formatBytes, checkMemoryPressure } from './utils/memoryDetection.js'
|
||||||
export type { MemoryInfo, CacheAllocationStrategy } from './utils/memoryDetection.js'
|
export type { MemoryInfo, CacheAllocationStrategy } from './utils/memoryDetection.js'
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,51 @@
|
||||||
import type { StorageAdapter } from '../coreTypes.js'
|
import type { StorageAdapter } from '../coreTypes.js'
|
||||||
import type { EntityIdMapperProvider } from '../plugin.js'
|
import type { EntityIdMapperProvider } from '../plugin.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The largest entity int the JS fallback `EntityIdMapper` will allocate.
|
||||||
|
* The metadata index's roaring bitmaps are 32-bit-keyed (`Roaring32`), so
|
||||||
|
* allowing the JS counter past this point would silently corrupt every
|
||||||
|
* downstream bitmap. The cortex 3.0 binary mapper supports a U64 IdSpace
|
||||||
|
* for brains above this ceiling — see the
|
||||||
|
* [`EntityIdSpaceExceeded`](#EntityIdSpaceExceeded) message for the
|
||||||
|
* migration pointer.
|
||||||
|
*/
|
||||||
|
export const U32_ENTITY_ID_MAX = 0xffff_ffff
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown by the JS fallback `EntityIdMapper` when `nextId` would exceed
|
||||||
|
* [`U32_ENTITY_ID_MAX`](#U32_ENTITY_ID_MAX). The JS path is the
|
||||||
|
* cortex-free fallback; once a brain has more than ~4.29 B entities,
|
||||||
|
* callers MUST install the cortex 3.0 `NativeBinaryEntityIdMapper` with
|
||||||
|
* `idSpace: 'u64'` (mmap-backed extendible-hash KV; persists the full
|
||||||
|
* u64 range losslessly).
|
||||||
|
*
|
||||||
|
* Brainy 8.0 surfaces this loudly rather than silently widening past
|
||||||
|
* u32::MAX, because the metadata index's roaring bitmaps would silently
|
||||||
|
* truncate entity ids and zero-out query results.
|
||||||
|
*/
|
||||||
|
export class EntityIdSpaceExceeded extends Error {
|
||||||
|
/** The u32 entity-id ceiling. */
|
||||||
|
readonly ceiling: number = U32_ENTITY_ID_MAX
|
||||||
|
/**
|
||||||
|
* The would-be `nextId` value the mapper was about to assign — always
|
||||||
|
* `U32_ENTITY_ID_MAX + 1`.
|
||||||
|
*/
|
||||||
|
readonly attempted: number
|
||||||
|
|
||||||
|
constructor(attempted: number) {
|
||||||
|
super(
|
||||||
|
`EntityIdMapper: nextId ${attempted} would exceed u32::MAX ` +
|
||||||
|
`(${U32_ENTITY_ID_MAX}). The JS fallback mapper caps at u32 to ` +
|
||||||
|
`match the metadata index's Roaring32 bitmap width. For >4.29 B ` +
|
||||||
|
`entities, install @soulcraft/cortex and configure the binary ` +
|
||||||
|
`mapper with idSpace: 'u64' (mmap-backed extendible-hash KV).`,
|
||||||
|
)
|
||||||
|
this.name = 'EntityIdSpaceExceeded'
|
||||||
|
this.attempted = attempted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface EntityIdMapperOptions {
|
export interface EntityIdMapperOptions {
|
||||||
storage: StorageAdapter
|
storage: StorageAdapter
|
||||||
storageKey?: string
|
storageKey?: string
|
||||||
|
|
@ -109,7 +154,13 @@ export class EntityIdMapper implements EntityIdMapperProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get integer ID for UUID, assigning a new ID if not exists
|
* Get integer ID for UUID, assigning a new ID if not exists.
|
||||||
|
*
|
||||||
|
* The JS fallback mapper caps at [`U32_ENTITY_ID_MAX`](#U32_ENTITY_ID_MAX)
|
||||||
|
* to match the metadata index's Roaring32 bitmap width — once `nextId`
|
||||||
|
* would exceed that, throws {@link EntityIdSpaceExceeded} so the caller
|
||||||
|
* loudly migrates to cortex's binary mapper with `idSpace: 'u64'`
|
||||||
|
* rather than silently truncating entity ids.
|
||||||
*/
|
*/
|
||||||
getOrAssign(uuid: string): number {
|
getOrAssign(uuid: string): number {
|
||||||
const existing = this.uuidToInt.get(uuid)
|
const existing = this.uuidToInt.get(uuid)
|
||||||
|
|
@ -118,6 +169,9 @@ export class EntityIdMapper implements EntityIdMapperProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assign new ID
|
// Assign new ID
|
||||||
|
if (this.nextId > U32_ENTITY_ID_MAX) {
|
||||||
|
throw new EntityIdSpaceExceeded(this.nextId)
|
||||||
|
}
|
||||||
const newId = this.nextId++
|
const newId = this.nextId++
|
||||||
this.uuidToInt.set(uuid, newId)
|
this.uuidToInt.set(uuid, newId)
|
||||||
this.intToUuid.set(newId, uuid)
|
this.intToUuid.set(newId, uuid)
|
||||||
|
|
|
||||||
98
tests/unit/utils/entity-id-mapper-u32-ceiling.test.ts
Normal file
98
tests/unit/utils/entity-id-mapper-u32-ceiling.test.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
/**
|
||||||
|
* @description Brainy 8.0 IdSpace contract: the JS fallback
|
||||||
|
* `EntityIdMapper` caps at `u32::MAX` to match the metadata index's
|
||||||
|
* Roaring32 bitmap width. When `nextId` would exceed the ceiling,
|
||||||
|
* `getOrAssign` throws `EntityIdSpaceExceeded` with a message pointing
|
||||||
|
* at the cortex 3.0 binary mapper's `idSpace: 'u64'` mode as the
|
||||||
|
* migration path.
|
||||||
|
*
|
||||||
|
* This is the lockstep counterpart to cortex 3.0's Piece 10 / Step 15
|
||||||
|
* (TS wrapper + napi BigInt siblings + Roaring32-or-Treemap
|
||||||
|
* `PostingList` enum). Without this guard, a JS-only brainy install
|
||||||
|
* past 4.29 B entities would silently truncate entity ids into the
|
||||||
|
* low-32-bit range, zeroing query results and corrupting any
|
||||||
|
* persisted int-keyed structure that consumed the mapper.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
EntityIdMapper,
|
||||||
|
EntityIdSpaceExceeded,
|
||||||
|
U32_ENTITY_ID_MAX,
|
||||||
|
} from '../../../src/utils/entityIdMapper.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construct a fresh in-memory mapper without going through brainy's
|
||||||
|
* full storage adapter — we only exercise the in-RAM ceiling guard
|
||||||
|
* here, so a minimal `getMetadata`-returning shim is enough.
|
||||||
|
*/
|
||||||
|
function makeMapper(): EntityIdMapper {
|
||||||
|
const storage = {
|
||||||
|
getMetadata: async () => null,
|
||||||
|
setMetadata: async () => {},
|
||||||
|
getNouns: async () => ({ items: [], totalCount: 0 }),
|
||||||
|
} as any
|
||||||
|
return new EntityIdMapper({ storage })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('EntityIdMapper U32 IdSpace ceiling (Brainy 8.0)', () => {
|
||||||
|
it('U32_ENTITY_ID_MAX equals 0xFFFF_FFFF (u32 ceiling)', () => {
|
||||||
|
expect(U32_ENTITY_ID_MAX).toBe(0xffff_ffff)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('EntityIdSpaceExceeded is an Error subclass with attempted + ceiling', () => {
|
||||||
|
const err = new EntityIdSpaceExceeded(0xffff_ffff + 1)
|
||||||
|
expect(err).toBeInstanceOf(Error)
|
||||||
|
expect(err.name).toBe('EntityIdSpaceExceeded')
|
||||||
|
expect(err.ceiling).toBe(0xffff_ffff)
|
||||||
|
expect(err.attempted).toBe(0x1_0000_0000)
|
||||||
|
expect(err.message).toMatch(/u32::MAX/)
|
||||||
|
expect(err.message).toMatch(/cortex/)
|
||||||
|
expect(err.message).toMatch(/idSpace: 'u64'/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normal allocations under the ceiling succeed', async () => {
|
||||||
|
const m = makeMapper()
|
||||||
|
await m.init()
|
||||||
|
const a = m.getOrAssign('uuid-a')
|
||||||
|
const b = m.getOrAssign('uuid-b')
|
||||||
|
expect(a).toBe(1)
|
||||||
|
expect(b).toBe(2)
|
||||||
|
expect(a).not.toBe(b)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws EntityIdSpaceExceeded when nextId would exceed u32::MAX', async () => {
|
||||||
|
const m = makeMapper()
|
||||||
|
await m.init()
|
||||||
|
// Reach into the mapper to bump `nextId` past the ceiling without
|
||||||
|
// actually allocating 4.29 B entities at test time. This mirrors
|
||||||
|
// the runtime invariant the guard protects.
|
||||||
|
;(m as any).nextId = U32_ENTITY_ID_MAX + 1
|
||||||
|
expect(() => m.getOrAssign('uuid-overflow')).toThrow(EntityIdSpaceExceeded)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('the last representable u32 int IS allocatable (boundary case)', async () => {
|
||||||
|
const m = makeMapper()
|
||||||
|
await m.init()
|
||||||
|
;(m as any).nextId = U32_ENTITY_ID_MAX
|
||||||
|
// `nextId === U32_ENTITY_ID_MAX` is still within range — the
|
||||||
|
// guard checks `> U32_ENTITY_ID_MAX`, so this last allocation
|
||||||
|
// succeeds.
|
||||||
|
const id = m.getOrAssign('uuid-at-ceiling')
|
||||||
|
expect(id).toBe(U32_ENTITY_ID_MAX)
|
||||||
|
// The NEXT allocation overflows.
|
||||||
|
expect(() => m.getOrAssign('uuid-after-ceiling')).toThrow(
|
||||||
|
EntityIdSpaceExceeded,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('existing uuid lookup never overflows even when nextId is past ceiling', async () => {
|
||||||
|
const m = makeMapper()
|
||||||
|
await m.init()
|
||||||
|
const assigned = m.getOrAssign('uuid-existing')
|
||||||
|
;(m as any).nextId = U32_ENTITY_ID_MAX + 1
|
||||||
|
// Looking up an already-assigned UUID does NOT allocate; the
|
||||||
|
// guard is on the assign path only.
|
||||||
|
expect(m.getOrAssign('uuid-existing')).toBe(assigned)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue