brainy/src/storage/sharding.ts
David Snelling 36b7216929 fix(8.0): accept application-supplied entity ids, not just UUIDs
Sharding required a 32-hex UUID and threw "Invalid UUID format" on every
non-UUID id, even though add()'s own docs show `id: "user-12345"` and the u64
id-mapper happily assigns ints to any string. So custom ids mapped fine in
memory then threw on save — breaking documented usage and any 7.x consumer
using friendly ids (`'user-123'`, slugs, emails) on upgrade.

getShardId() (renamed from getShardIdFromUuid) now buckets UUID-format ids by
their first byte (on-disk layout unchanged, so existing data never moves) and
hashes any other id via FNV-1a into the same 256-bucket space. The hash is part
of the on-disk contract and must not change. Verbs stay Brainy-generated UUIDs
by contract; only custom noun ids take the hash path.

Adds getShardId unit coverage: dual scheme, determinism, even distribution
across buckets, and the empty-id guard.
2026-06-16 10:40:40 -07:00

172 lines
4.9 KiB
TypeScript

/**
* Unified id-based sharding for all storage adapters.
*
* Maps any entity id to one of 256 buckets (00-ff) for consistent, predictable
* on-disk layout that scales from hundreds to millions of entities with no
* configuration.
*
* Sharding characteristics:
* - 256 buckets (00-ff)
* - Deterministic (the same id always maps to the same shard)
* - No configuration required
* - Works across the filesystem and memory adapters
* - Efficient for list operations and pagination
*
* Two id shapes are supported, by design:
* - **UUID-format ids** (32 hex chars) shard by their first byte. This is the
* original scheme, kept byte-for-byte so existing on-disk layouts never move.
* - **Application ids** (`'user-123'`, slugs, emails — anything else) shard by a
* stable FNV-1a hash. Requiring callers to mint UUIDs would break the
* documented `add({ id: '…' })` contract; a shard is only a bucket, so any
* deterministic, well-distributed mapping is correct.
*
* The hash is part of the on-disk contract: **never change it** — doing so would
* relocate every application-id record.
*/
/**
* 32-bit FNV-1a hash of a string, returned as a 2-hex-char shard bucket (00-ff).
* Deterministic and uniformly distributed across the 256 buckets.
*
* @param value - The string to hash (an entity id that is not UUID-format).
* @returns A 2-character hex shard id.
*/
function hashToShardId(value: string): string {
let hash = 0x811c9dc5 // FNV offset basis
for (let i = 0; i < value.length; i++) {
hash ^= value.charCodeAt(i)
hash = Math.imul(hash, 0x01000193) // FNV prime
}
return ((hash >>> 0) & 0xff).toString(16).padStart(2, '0')
}
/**
* Resolve the shard bucket (00-ff) for an entity id.
*
* UUID-format ids (32 hex chars, with or without hyphens) shard by their first
* byte — preserving the original on-disk layout. Any other id is hashed (FNV-1a)
* into the same 256-bucket space, so application-supplied ids like `'user-123'`
* work without forcing callers to mint UUIDs.
*
* @param id - The entity id (UUID-format or any application string).
* @returns A 2-character hex shard id (00-ff).
* @throws If `id` is empty.
*
* @example
* ```typescript
* getShardId('ab123456-1234-5678-9abc-def012345678') // 'ab' (UUID first byte)
* getShardId('user-12345') // FNV-1a hash bucket
* ```
*/
export function getShardId(id: string): string {
if (!id) {
throw new Error('id is required for sharding')
}
// UUID-format ids (32 hex chars) keep the original first-byte bucketing so
// existing on-disk data is never relocated.
const normalized = id.toLowerCase().replace(/-/g, '')
if (/^[0-9a-f]{32}$/.test(normalized)) {
return normalized.substring(0, 2)
}
// Any other id (application keys, slugs, emails) is hashed into a bucket.
return hashToShardId(id)
}
/**
* Get all possible shard IDs (00-ff)
*
* Returns array of 256 shard IDs in ascending order.
* Useful for iterating through all shards during pagination.
*
* @returns Array of 256 shard IDs
*
* @example
* ```typescript
* const shards = getAllShardIds()
* // ['00', '01', '02', ..., 'fd', 'fe', 'ff']
*
* for (const shardId of shards) {
* const prefix = `entities/nouns/vectors/${shardId}/`
* // List objects with this prefix
* }
* ```
*/
export function getAllShardIds(): string[] {
const shards: string[] = []
for (let i = 0; i < 256; i++) {
shards.push(i.toString(16).padStart(2, '0'))
}
return shards
}
/**
* Get shard ID for a given index (0-255)
*
* @param index - Shard index (0-255)
* @returns 2-character hex shard ID
*
* @example
* ```typescript
* getShardIdByIndex(0) // '00'
* getShardIdByIndex(15) // '0f'
* getShardIdByIndex(255) // 'ff'
* ```
*/
export function getShardIdByIndex(index: number): string {
if (index < 0 || index > 255) {
throw new Error(`Shard index out of range: ${index} (expected 0-255)`)
}
return index.toString(16).padStart(2, '0')
}
/**
* Get shard index from shard ID (0-255)
*
* @param shardId - 2-character hex shard ID
* @returns Shard index (0-255)
*
* @example
* ```typescript
* getShardIndexFromId('00') // 0
* getShardIndexFromId('0f') // 15
* getShardIndexFromId('ff') // 255
* ```
*/
export function getShardIndexFromId(shardId: string): number {
if (!/^[0-9a-f]{2}$/.test(shardId)) {
throw new Error(`Invalid shard ID: ${shardId} (expected 2 hex chars)`)
}
return parseInt(shardId, 16)
}
/**
* Total number of shards in the system
*/
export const TOTAL_SHARDS = 256
/**
* Shard configuration (read-only)
*/
export const SHARD_CONFIG = {
/**
* Total number of shards (256)
*/
count: TOTAL_SHARDS,
/**
* Number of hex characters used for sharding (2)
*/
prefixLength: 2,
/**
* Sharding method description
*/
method: 'uuid-prefix',
/**
* Whether sharding is always enabled
*/
alwaysEnabled: true
} as const