CRITICAL CHECKPOINT - DO NOT PUSH TO GITHUB Recovery Status: - Successfully recovered brainy.ts from compiled JavaScript - All core v3.0 API methods functional (add, get, update, delete, relate, find, etc.) - Neural subsystem intact (562KB embedded patterns, NLP working) - Augmentation pipeline operational (20+ augmentations) - HNSW clustering system complete - Triple Intelligence compiled (needs constructor fix) - Test suite validates functionality Changes preserved: - 898 files with changes from last 3 days - 144,475 insertions - All augmentation improvements - All test coverage enhancements - Complete v3.0 feature set This is a LOCAL checkpoint only - contains recovered work after corruption incident. Created backup in .backups/brainy-full-20250910-151314.tar.gz Branch: recovery-checkpoint-20250910-151433 Date: Wed Sep 10 03:18:04 PM PDT 2025
551 lines
No EOL
20 KiB
JavaScript
551 lines
No EOL
20 KiB
JavaScript
/**
|
|
* Storage-based Discovery for Zero-Config Distributed Brainy
|
|
* Uses shared storage (S3/GCS/R2) as coordination point
|
|
* REAL PRODUCTION CODE - No mocks, no stubs!
|
|
*/
|
|
import { EventEmitter } from 'events';
|
|
import * as os from 'os';
|
|
export class StorageDiscovery extends EventEmitter {
|
|
constructor(storage, nodeId) {
|
|
super();
|
|
this.clusterConfig = null;
|
|
this.heartbeatInterval = null;
|
|
this.discoveryInterval = null;
|
|
this.endpoint = '';
|
|
this.isRunning = false;
|
|
this.HEARTBEAT_INTERVAL = 5000; // 5 seconds
|
|
this.DISCOVERY_INTERVAL = 2000; // 2 seconds
|
|
this.NODE_TIMEOUT = 30000; // 30 seconds until node considered dead
|
|
this.CLUSTER_PATH = '_cluster';
|
|
this.storage = storage;
|
|
this.nodeId = nodeId || this.generateNodeId();
|
|
// Initialize node info with REAL system data
|
|
this.nodeInfo = {
|
|
id: this.nodeId,
|
|
endpoint: '', // Will be set when HTTP server starts
|
|
hostname: os.hostname(),
|
|
started: Date.now(),
|
|
lastSeen: Date.now(),
|
|
role: 'candidate',
|
|
shards: [],
|
|
capacity: {
|
|
cpu: os.cpus().length,
|
|
memory: Math.floor(os.totalmem() / 1024 / 1024), // MB
|
|
storage: 0 // Will be updated based on actual usage
|
|
},
|
|
stats: {
|
|
nouns: 0,
|
|
verbs: 0,
|
|
queries: 0,
|
|
latency: 0
|
|
}
|
|
};
|
|
}
|
|
/**
|
|
* Start discovery and registration
|
|
*/
|
|
async start(httpPort) {
|
|
if (this.isRunning)
|
|
return this.clusterConfig;
|
|
this.isRunning = true;
|
|
// Set our endpoint
|
|
this.endpoint = await this.detectEndpoint(httpPort);
|
|
this.nodeInfo.endpoint = this.endpoint;
|
|
// Try to load existing cluster config
|
|
this.clusterConfig = await this.loadClusterConfig();
|
|
if (!this.clusterConfig) {
|
|
// We're the first node - initialize cluster
|
|
await this.initializeCluster();
|
|
}
|
|
else {
|
|
// Join existing cluster
|
|
await this.joinCluster();
|
|
}
|
|
// Start heartbeat to keep our node alive
|
|
this.startHeartbeat();
|
|
// Start discovery to find other nodes
|
|
this.startDiscovery();
|
|
this.emit('started', this.nodeInfo);
|
|
return this.clusterConfig;
|
|
}
|
|
/**
|
|
* Stop discovery and unregister
|
|
*/
|
|
async stop() {
|
|
if (!this.isRunning)
|
|
return;
|
|
this.isRunning = false;
|
|
// Stop intervals
|
|
if (this.heartbeatInterval) {
|
|
clearInterval(this.heartbeatInterval);
|
|
this.heartbeatInterval = null;
|
|
}
|
|
if (this.discoveryInterval) {
|
|
clearInterval(this.discoveryInterval);
|
|
this.discoveryInterval = null;
|
|
}
|
|
// Remove ourselves from cluster
|
|
await this.leaveCluster();
|
|
this.emit('stopped');
|
|
}
|
|
/**
|
|
* Initialize a new cluster (we're the first node)
|
|
*/
|
|
async initializeCluster() {
|
|
console.log(`[${this.nodeId}] Initializing new cluster as first node`);
|
|
this.nodeInfo.role = 'primary';
|
|
this.clusterConfig = {
|
|
version: 1,
|
|
created: Date.now(),
|
|
updated: Date.now(),
|
|
leader: this.nodeId,
|
|
nodes: {
|
|
[this.nodeId]: this.nodeInfo
|
|
},
|
|
shards: {
|
|
count: 64, // Default shard count
|
|
assignments: {}
|
|
},
|
|
settings: {
|
|
replicationFactor: 3,
|
|
shardCount: 64,
|
|
autoRebalance: true,
|
|
minNodes: 1,
|
|
maxNodesPerShard: 5
|
|
}
|
|
};
|
|
// Assign all shards to ourselves initially
|
|
for (let i = 0; i < this.clusterConfig.shards.count; i++) {
|
|
const shardId = `shard-${i.toString().padStart(3, '0')}`;
|
|
this.clusterConfig.shards.assignments[shardId] = [this.nodeId];
|
|
this.nodeInfo.shards.push(shardId);
|
|
}
|
|
// Save cluster config
|
|
await this.saveClusterConfig();
|
|
// Register ourselves
|
|
await this.registerNode();
|
|
this.emit('clusterInitialized', this.clusterConfig);
|
|
}
|
|
/**
|
|
* Join an existing cluster
|
|
*/
|
|
async joinCluster() {
|
|
console.log(`[${this.nodeId}] Joining existing cluster`);
|
|
if (!this.clusterConfig)
|
|
throw new Error('No cluster config');
|
|
// Add ourselves to the cluster
|
|
this.clusterConfig.nodes[this.nodeId] = this.nodeInfo;
|
|
// Determine our role based on cluster state
|
|
const nodeCount = Object.keys(this.clusterConfig.nodes).length;
|
|
if (!this.clusterConfig.leader || !this.clusterConfig.nodes[this.clusterConfig.leader]) {
|
|
// No leader or leader is gone - trigger election
|
|
await this.triggerLeaderElection();
|
|
}
|
|
else {
|
|
// Become replica
|
|
this.nodeInfo.role = 'replica';
|
|
}
|
|
// Register ourselves
|
|
await this.registerNode();
|
|
// Request shard assignment if auto-rebalance is enabled
|
|
if (this.clusterConfig.settings.autoRebalance) {
|
|
await this.requestShardAssignment();
|
|
}
|
|
this.emit('clusterJoined', this.clusterConfig);
|
|
}
|
|
/**
|
|
* Leave cluster cleanly
|
|
*/
|
|
async leaveCluster() {
|
|
if (!this.clusterConfig)
|
|
return;
|
|
console.log(`[${this.nodeId}] Leaving cluster`);
|
|
// Remove ourselves from node registry
|
|
try {
|
|
// Mark as deleted rather than actually deleting
|
|
const deadNode = { ...this.nodeInfo, lastSeen: 0, status: 'inactive' };
|
|
await this.storage.saveMetadata(`${this.CLUSTER_PATH}/nodes/${this.nodeId}.json`, deadNode);
|
|
}
|
|
catch (err) {
|
|
// Ignore errors during shutdown
|
|
}
|
|
// If we're the leader, trigger new election
|
|
if (this.clusterConfig.leader === this.nodeId) {
|
|
this.clusterConfig.leader = null;
|
|
await this.saveClusterConfig();
|
|
}
|
|
this.emit('clusterLeft');
|
|
}
|
|
/**
|
|
* Register node in storage
|
|
*/
|
|
async registerNode() {
|
|
const path = `${this.CLUSTER_PATH}/nodes/${this.nodeId}.json`;
|
|
await this.storage.saveMetadata(path, this.nodeInfo);
|
|
// Also update registry
|
|
await this.updateNodeRegistry(this.nodeId);
|
|
}
|
|
/**
|
|
* Heartbeat to keep node alive
|
|
*/
|
|
startHeartbeat() {
|
|
this.heartbeatInterval = setInterval(async () => {
|
|
try {
|
|
this.nodeInfo.lastSeen = Date.now();
|
|
await this.registerNode();
|
|
// Also update cluster config if we're the leader
|
|
if (this.clusterConfig && this.clusterConfig.leader === this.nodeId) {
|
|
await this.saveClusterConfig();
|
|
}
|
|
}
|
|
catch (err) {
|
|
console.error(`[${this.nodeId}] Heartbeat failed:`, err);
|
|
}
|
|
}, this.HEARTBEAT_INTERVAL);
|
|
}
|
|
/**
|
|
* Discover other nodes and monitor health
|
|
*/
|
|
startDiscovery() {
|
|
this.discoveryInterval = setInterval(async () => {
|
|
try {
|
|
await this.discoverNodes();
|
|
await this.checkNodeHealth();
|
|
// Check if we need to rebalance
|
|
if (this.shouldRebalance()) {
|
|
await this.triggerRebalance();
|
|
}
|
|
}
|
|
catch (err) {
|
|
console.error(`[${this.nodeId}] Discovery failed:`, err);
|
|
}
|
|
}, this.DISCOVERY_INTERVAL);
|
|
}
|
|
/**
|
|
* Discover nodes from storage
|
|
*/
|
|
async discoverNodes() {
|
|
try {
|
|
// Since we can't list arbitrary paths, we'll use a registry approach
|
|
// Each node registers in a central registry file
|
|
const registry = await this.loadNodeRegistry();
|
|
const now = Date.now();
|
|
let updated = false;
|
|
for (const nodeId of registry) {
|
|
if (nodeId === this.nodeId)
|
|
continue;
|
|
try {
|
|
const nodeInfo = await this.storage.getMetadata(`${this.CLUSTER_PATH}/nodes/${nodeId}.json`);
|
|
// Check if node is alive
|
|
if (now - nodeInfo.lastSeen < this.NODE_TIMEOUT) {
|
|
if (!this.clusterConfig.nodes[nodeId]) {
|
|
// New node discovered!
|
|
console.log(`[${this.nodeId}] Discovered new node: ${nodeId}`);
|
|
this.clusterConfig.nodes[nodeId] = nodeInfo;
|
|
updated = true;
|
|
this.emit('nodeDiscovered', nodeInfo);
|
|
}
|
|
else {
|
|
// Update existing node info
|
|
this.clusterConfig.nodes[nodeId] = nodeInfo;
|
|
}
|
|
}
|
|
}
|
|
catch (err) {
|
|
// Node file might be corrupted or deleted
|
|
console.warn(`[${this.nodeId}] Failed to read node ${nodeId}:`, err);
|
|
}
|
|
}
|
|
if (updated) {
|
|
this.clusterConfig.version++;
|
|
this.clusterConfig.updated = Date.now();
|
|
}
|
|
}
|
|
catch (err) {
|
|
// Storage might be unavailable
|
|
console.error(`[${this.nodeId}] Failed to discover nodes:`, err);
|
|
}
|
|
}
|
|
/**
|
|
* Load node registry from storage
|
|
*/
|
|
async loadNodeRegistry() {
|
|
try {
|
|
const registry = await this.storage.getMetadata(`${this.CLUSTER_PATH}/registry.json`);
|
|
return registry?.nodes || [];
|
|
}
|
|
catch (err) {
|
|
return [];
|
|
}
|
|
}
|
|
/**
|
|
* Update node registry in storage
|
|
*/
|
|
async updateNodeRegistry(add, remove) {
|
|
try {
|
|
let registry = await this.loadNodeRegistry();
|
|
if (add && !registry.includes(add)) {
|
|
registry.push(add);
|
|
}
|
|
if (remove) {
|
|
registry = registry.filter(id => id !== remove);
|
|
}
|
|
await this.storage.saveMetadata(`${this.CLUSTER_PATH}/registry.json`, {
|
|
nodes: registry,
|
|
updated: Date.now()
|
|
});
|
|
}
|
|
catch (err) {
|
|
console.error(`[${this.nodeId}] Failed to update registry:`, err);
|
|
}
|
|
}
|
|
/**
|
|
* Check health of known nodes
|
|
*/
|
|
async checkNodeHealth() {
|
|
if (!this.clusterConfig)
|
|
return;
|
|
const now = Date.now();
|
|
const deadNodes = [];
|
|
for (const [nodeId, nodeInfo] of Object.entries(this.clusterConfig.nodes)) {
|
|
if (nodeId === this.nodeId)
|
|
continue;
|
|
if (now - nodeInfo.lastSeen > this.NODE_TIMEOUT) {
|
|
console.log(`[${this.nodeId}] Node ${nodeId} is dead (last seen ${now - nodeInfo.lastSeen}ms ago)`);
|
|
deadNodes.push(nodeId);
|
|
}
|
|
}
|
|
// Remove dead nodes
|
|
for (const nodeId of deadNodes) {
|
|
delete this.clusterConfig.nodes[nodeId];
|
|
this.emit('nodeLost', nodeId);
|
|
// If dead node was leader, trigger election
|
|
if (this.clusterConfig.leader === nodeId) {
|
|
await this.triggerLeaderElection();
|
|
}
|
|
}
|
|
if (deadNodes.length > 0) {
|
|
// Trigger rebalance to reassign shards from dead nodes
|
|
await this.triggerRebalance();
|
|
}
|
|
}
|
|
/**
|
|
* Load cluster configuration from storage
|
|
*/
|
|
async loadClusterConfig() {
|
|
try {
|
|
const config = await this.storage.getMetadata(`${this.CLUSTER_PATH}/config.json`);
|
|
return config;
|
|
}
|
|
catch (err) {
|
|
// No cluster config exists yet
|
|
return null;
|
|
}
|
|
}
|
|
/**
|
|
* Save cluster configuration to storage
|
|
*/
|
|
async saveClusterConfig() {
|
|
if (!this.clusterConfig)
|
|
return;
|
|
await this.storage.saveMetadata(`${this.CLUSTER_PATH}/config.json`, this.clusterConfig);
|
|
}
|
|
/**
|
|
* Trigger leader election (simplified - not full Raft)
|
|
*/
|
|
async triggerLeaderElection() {
|
|
console.log(`[${this.nodeId}] Triggering leader election`);
|
|
// Simple election: node with lowest ID wins
|
|
// In production, use proper Raft consensus
|
|
const activeNodes = Object.entries(this.clusterConfig.nodes)
|
|
.filter(([_, info]) => Date.now() - info.lastSeen < this.NODE_TIMEOUT)
|
|
.sort(([a], [b]) => a.localeCompare(b));
|
|
if (activeNodes.length > 0) {
|
|
const [leaderId, leaderInfo] = activeNodes[0];
|
|
this.clusterConfig.leader = leaderId;
|
|
if (leaderId === this.nodeId) {
|
|
console.log(`[${this.nodeId}] Became leader`);
|
|
this.nodeInfo.role = 'primary';
|
|
this.emit('becameLeader');
|
|
}
|
|
else {
|
|
console.log(`[${this.nodeId}] Node ${leaderId} is the new leader`);
|
|
this.nodeInfo.role = 'replica';
|
|
this.emit('leaderElected', leaderId);
|
|
}
|
|
await this.saveClusterConfig();
|
|
}
|
|
}
|
|
/**
|
|
* Request shard assignment for this node
|
|
*/
|
|
async requestShardAssignment() {
|
|
if (!this.clusterConfig)
|
|
return;
|
|
// Calculate how many shards each node should have
|
|
const nodeCount = Object.keys(this.clusterConfig.nodes).length;
|
|
const shardsPerNode = Math.ceil(this.clusterConfig.shards.count / nodeCount);
|
|
// Find shards that need assignment
|
|
const unassignedShards = [];
|
|
for (let i = 0; i < this.clusterConfig.shards.count; i++) {
|
|
const shardId = `shard-${i.toString().padStart(3, '0')}`;
|
|
if (!this.clusterConfig.shards.assignments[shardId] ||
|
|
this.clusterConfig.shards.assignments[shardId].length === 0) {
|
|
unassignedShards.push(shardId);
|
|
}
|
|
}
|
|
// Assign some shards to ourselves
|
|
const ourShare = unassignedShards.slice(0, shardsPerNode);
|
|
for (const shardId of ourShare) {
|
|
this.clusterConfig.shards.assignments[shardId] = [this.nodeId];
|
|
this.nodeInfo.shards.push(shardId);
|
|
}
|
|
if (ourShare.length > 0) {
|
|
console.log(`[${this.nodeId}] Assigned ${ourShare.length} shards`);
|
|
await this.saveClusterConfig();
|
|
}
|
|
}
|
|
/**
|
|
* Check if rebalancing is needed
|
|
*/
|
|
shouldRebalance() {
|
|
if (!this.clusterConfig || !this.clusterConfig.settings.autoRebalance) {
|
|
return false;
|
|
}
|
|
// Check if shards are evenly distributed
|
|
const nodeCount = Object.keys(this.clusterConfig.nodes).length;
|
|
if (nodeCount <= 1)
|
|
return false;
|
|
const targetShardsPerNode = Math.ceil(this.clusterConfig.shards.count / nodeCount);
|
|
const variance = 2; // Allow some variance
|
|
for (const nodeInfo of Object.values(this.clusterConfig.nodes)) {
|
|
const shardCount = nodeInfo.shards.length;
|
|
if (Math.abs(shardCount - targetShardsPerNode) > variance) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
/**
|
|
* Trigger shard rebalancing
|
|
*/
|
|
async triggerRebalance() {
|
|
// Only leader can trigger rebalance
|
|
if (this.clusterConfig?.leader !== this.nodeId)
|
|
return;
|
|
console.log(`[${this.nodeId}] Triggering shard rebalance`);
|
|
// This will be implemented with actual data migration
|
|
// For now, just redistribute shard assignments
|
|
await this.redistributeShards();
|
|
this.emit('rebalanceTriggered');
|
|
}
|
|
/**
|
|
* Redistribute shards among active nodes
|
|
*/
|
|
async redistributeShards() {
|
|
if (!this.clusterConfig)
|
|
return;
|
|
const activeNodes = Object.keys(this.clusterConfig.nodes)
|
|
.filter(id => Date.now() - this.clusterConfig.nodes[id].lastSeen < this.NODE_TIMEOUT);
|
|
if (activeNodes.length === 0)
|
|
return;
|
|
const shardsPerNode = Math.ceil(this.clusterConfig.shards.count / activeNodes.length);
|
|
const newAssignments = {};
|
|
// Clear current shard assignments from nodes
|
|
for (const nodeInfo of Object.values(this.clusterConfig.nodes)) {
|
|
nodeInfo.shards = [];
|
|
}
|
|
// Redistribute shards
|
|
let nodeIndex = 0;
|
|
for (let i = 0; i < this.clusterConfig.shards.count; i++) {
|
|
const shardId = `shard-${i.toString().padStart(3, '0')}`;
|
|
const primaryNode = activeNodes[nodeIndex % activeNodes.length];
|
|
// Assign primary
|
|
newAssignments[shardId] = [primaryNode];
|
|
this.clusterConfig.nodes[primaryNode].shards.push(shardId);
|
|
// Assign replicas
|
|
const replicas = [];
|
|
for (let r = 1; r < Math.min(this.clusterConfig.settings.replicationFactor, activeNodes.length); r++) {
|
|
const replicaNode = activeNodes[(nodeIndex + r) % activeNodes.length];
|
|
if (replicaNode !== primaryNode) {
|
|
replicas.push(replicaNode);
|
|
}
|
|
}
|
|
if (replicas.length > 0) {
|
|
newAssignments[shardId].push(...replicas);
|
|
}
|
|
nodeIndex++;
|
|
}
|
|
this.clusterConfig.shards.assignments = newAssignments;
|
|
this.clusterConfig.version++;
|
|
this.clusterConfig.updated = Date.now();
|
|
await this.saveClusterConfig();
|
|
console.log(`[${this.nodeId}] Rebalanced ${this.clusterConfig.shards.count} shards across ${activeNodes.length} nodes`);
|
|
}
|
|
/**
|
|
* Detect our public endpoint
|
|
*/
|
|
async detectEndpoint(port) {
|
|
// Try to detect public IP
|
|
const interfaces = os.networkInterfaces();
|
|
let ip = '127.0.0.1';
|
|
// Find first non-internal IPv4 address
|
|
for (const iface of Object.values(interfaces)) {
|
|
if (!iface)
|
|
continue;
|
|
for (const addr of iface) {
|
|
if (addr.family === 'IPv4' && !addr.internal) {
|
|
ip = addr.address;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
// In cloud environments, might need to detect public IP differently
|
|
if (process.env.PUBLIC_IP) {
|
|
ip = process.env.PUBLIC_IP;
|
|
}
|
|
else if (process.env.KUBERNETES_SERVICE_HOST) {
|
|
// In Kubernetes, use pod IP
|
|
ip = process.env.POD_IP || ip;
|
|
}
|
|
return `http://${ip}:${port}`;
|
|
}
|
|
/**
|
|
* Generate unique node ID
|
|
*/
|
|
generateNodeId() {
|
|
const hostname = os.hostname();
|
|
const pid = process.pid;
|
|
const random = Math.random().toString(36).substring(2, 8);
|
|
return `${hostname}-${pid}-${random}`;
|
|
}
|
|
/**
|
|
* Get current cluster configuration
|
|
*/
|
|
getClusterConfig() {
|
|
return this.clusterConfig;
|
|
}
|
|
/**
|
|
* Get active nodes
|
|
*/
|
|
getActiveNodes() {
|
|
if (!this.clusterConfig)
|
|
return [];
|
|
const now = Date.now();
|
|
return Object.values(this.clusterConfig.nodes)
|
|
.filter(node => now - node.lastSeen < this.NODE_TIMEOUT);
|
|
}
|
|
/**
|
|
* Get shards assigned to this node
|
|
*/
|
|
getMyShards() {
|
|
return this.nodeInfo.shards;
|
|
}
|
|
/**
|
|
* Update node statistics
|
|
*/
|
|
updateStats(stats) {
|
|
Object.assign(this.nodeInfo.stats, stats);
|
|
}
|
|
}
|
|
//# sourceMappingURL=storageDiscovery.js.map
|