EXPERIMENTAL: replace KV-scan consensus with Autobase sidecar engine
Cut over domain resolution to an Autobase apply-based sidecar fed by claim/vote dual-writes from dnsPass. Remove the legacy full-KV scan getConsensusState path and wire all reads through consensus-view. - Add consensus-resolver, consensus-events, consensus-apply, consensus-autobase, and consensus-view modules - Dual-append consensus events on claim/vote dnsPassAdd/Remove - Bootstrap sidecar from existing KV entries; persist consensusAutobaseKey in network manifest - Expose sidecar health via GET /api/consensus/status and metrics - Add consensus-resolver and consensus-apply unit tests - Expand RFC 0001 to Implemented; update CONSENSUS.md Rollback: deploy prior release; KV data unchanged, sidecar rebuilds on next startup.
This commit is contained in:
@@ -10,6 +10,25 @@ async function handleConsensusRoutes(req, res) {
|
||||
const method = req.method;
|
||||
const url = new URL(req.url, `https://${req.headers.host}`);
|
||||
|
||||
// GET /api/consensus/status - Sidecar health
|
||||
if (method === 'GET' && urlPath === '/api/consensus/status') {
|
||||
try {
|
||||
trackRequest('/api/consensus/status', true);
|
||||
const { getConsensusStatus } = require('../../../core/consensus-autobase');
|
||||
const status = getConsensusStatus();
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(status));
|
||||
return true;
|
||||
} catch (err) {
|
||||
logError('Consensus', `Failed to get consensus status: ${err.message}`);
|
||||
trackRequest('/api/consensus/status', false);
|
||||
const errorResponse = createErrorResponse(err, 500);
|
||||
res.writeHead(errorResponse.statusCode, errorResponse.headers);
|
||||
res.end(errorResponse.body);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/consensus/metrics - Get consensus metrics
|
||||
// Check this BEFORE the domain route to avoid matching "metrics" as a domain
|
||||
if (method === 'GET' && urlPath === '/api/consensus/metrics') {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Autobase apply handler and in-memory consensus view state.
|
||||
*/
|
||||
|
||||
const { decodeEvent, EVENT_TYPES } = require('./consensus-events');
|
||||
|
||||
function createEmptyViewState() {
|
||||
return {
|
||||
domains: new Map(),
|
||||
eventCount: 0,
|
||||
lastApplyAt: 0
|
||||
};
|
||||
}
|
||||
|
||||
function getOrCreateDomain(view, domain) {
|
||||
if (!view.domains.has(domain)) {
|
||||
view.domains.set(domain, {
|
||||
claims: new Map(),
|
||||
voterVotes: new Map()
|
||||
});
|
||||
}
|
||||
return view.domains.get(domain);
|
||||
}
|
||||
|
||||
function applyEventToView(view, event) {
|
||||
switch (event.type) {
|
||||
case EVENT_TYPES.CLAIM_UPSERT: {
|
||||
const domainState = getOrCreateDomain(view, event.domain);
|
||||
domainState.claims.set(event.claimant, {
|
||||
hash: event.hash,
|
||||
timestamp: event.timestamp,
|
||||
ssl: event.ssl === true,
|
||||
clients: event.clients || []
|
||||
});
|
||||
break;
|
||||
}
|
||||
case EVENT_TYPES.CLAIM_REMOVE: {
|
||||
const domainState = view.domains.get(event.domain);
|
||||
if (!domainState) break;
|
||||
domainState.claims.delete(event.claimant);
|
||||
for (const [voter, claimant] of domainState.voterVotes) {
|
||||
if (claimant === event.claimant) {
|
||||
domainState.voterVotes.delete(voter);
|
||||
}
|
||||
}
|
||||
if (domainState.claims.size === 0 && domainState.voterVotes.size === 0) {
|
||||
view.domains.delete(event.domain);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case EVENT_TYPES.VOTE_UPSERT: {
|
||||
const domainState = getOrCreateDomain(view, event.domain);
|
||||
domainState.voterVotes.set(event.voter, event.claimant);
|
||||
break;
|
||||
}
|
||||
case EVENT_TYPES.VOTE_REMOVE: {
|
||||
const domainState = view.domains.get(event.domain);
|
||||
if (!domainState) break;
|
||||
domainState.voterVotes.delete(event.voter);
|
||||
if (domainState.claims.size === 0 && domainState.voterVotes.size === 0) {
|
||||
view.domains.delete(event.domain);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function openConsensusView() {
|
||||
return createEmptyViewState();
|
||||
}
|
||||
|
||||
async function applyConsensusNodes(nodes, view, host) {
|
||||
for (const node of nodes) {
|
||||
let event;
|
||||
try {
|
||||
event = decodeEvent(node.value);
|
||||
} catch (err) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.type === 'add_writer' && event.key && host && typeof host.addWriter === 'function') {
|
||||
await host.addWriter(Buffer.from(event.key, 'hex'), { indexer: true });
|
||||
view.eventCount++;
|
||||
view.lastApplyAt = Date.now();
|
||||
continue;
|
||||
}
|
||||
|
||||
applyEventToView(view, event);
|
||||
view.eventCount++;
|
||||
view.lastApplyAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createEmptyViewState,
|
||||
getOrCreateDomain,
|
||||
applyEventToView,
|
||||
openConsensusView,
|
||||
applyConsensusNodes
|
||||
};
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Consensus Autobase sidecar lifecycle.
|
||||
*/
|
||||
|
||||
const Autobase = require('autobase');
|
||||
const state = require('../infrastructure/state');
|
||||
const { logDebug, logInfo, logWarn, logError } = require('../infrastructure/logger');
|
||||
const { openConsensusView, applyConsensusNodes } = require('./consensus-apply');
|
||||
const { encodeEvent, entriesToBootstrapEvents } = require('./consensus-events');
|
||||
const { listAllEntries, waitForAutobaseIdle } = require('./dns-pass-queue');
|
||||
|
||||
let consensusBase = null;
|
||||
let bootstrapComplete = false;
|
||||
let initPromise = null;
|
||||
|
||||
function getConsensusBase() {
|
||||
return consensusBase;
|
||||
}
|
||||
|
||||
function isBootstrapComplete() {
|
||||
return bootstrapComplete;
|
||||
}
|
||||
|
||||
function createConsensusAutobase(store, bootstrapKey) {
|
||||
const opts = {
|
||||
open() {
|
||||
return openConsensusView();
|
||||
},
|
||||
async apply(nodes, view, host) {
|
||||
await applyConsensusNodes(nodes, view, host);
|
||||
}
|
||||
};
|
||||
|
||||
if (bootstrapKey) {
|
||||
return new Autobase(store, bootstrapKey, opts);
|
||||
}
|
||||
return new Autobase(store, opts);
|
||||
}
|
||||
|
||||
async function appendConsensusEvent(event) {
|
||||
if (!consensusBase || consensusBase.closed) {
|
||||
throw new Error('consensus autobase not open');
|
||||
}
|
||||
await waitForAutobaseIdle({ base: consensusBase });
|
||||
await consensusBase.append(encodeEvent(event));
|
||||
await consensusBase.update();
|
||||
}
|
||||
|
||||
async function appendConsensusEvents(events) {
|
||||
for (const event of events) {
|
||||
await appendConsensusEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureLocalWriter() {
|
||||
if (!consensusBase || consensusBase.closed) return;
|
||||
await consensusBase.ready();
|
||||
await consensusBase.update();
|
||||
if (consensusBase.writable) return;
|
||||
|
||||
const localKey = consensusBase.local?.key;
|
||||
if (!localKey) return;
|
||||
|
||||
try {
|
||||
await appendConsensusEvent({ type: 'add_writer', key: localKey.toString('hex') });
|
||||
await consensusBase.update();
|
||||
} catch (err) {
|
||||
logWarn('ConsensusAutobase', `Could not request writer access: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrapFromDnsPass(pass, options = {}) {
|
||||
const { allowLocalBootstrap = true, syncWaitMs = 3000 } = options;
|
||||
if (!consensusBase || bootstrapComplete) return;
|
||||
await consensusBase.ready();
|
||||
await consensusBase.update();
|
||||
|
||||
if (!allowLocalBootstrap && syncWaitMs > 0) {
|
||||
const deadline = Date.now() + syncWaitMs;
|
||||
while (Date.now() < deadline) {
|
||||
await consensusBase.update();
|
||||
if (consensusBase.view && consensusBase.view.eventCount > 0) break;
|
||||
await sleep(200);
|
||||
}
|
||||
}
|
||||
|
||||
const view = consensusBase.view;
|
||||
if (view && view.eventCount > 0) {
|
||||
bootstrapComplete = true;
|
||||
logInfo('ConsensusAutobase', `Sidecar already has ${view.eventCount} events, skipping bootstrap`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allowLocalBootstrap) {
|
||||
logInfo('ConsensusAutobase', 'Joiner sidecar empty after sync — waiting for replication before local bootstrap');
|
||||
bootstrapComplete = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pass) {
|
||||
logWarn('ConsensusAutobase', 'dnsPass not available for bootstrap');
|
||||
bootstrapComplete = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = await listAllEntries(pass);
|
||||
const events = entriesToBootstrapEvents(entries);
|
||||
if (events.length === 0) {
|
||||
bootstrapComplete = true;
|
||||
logInfo('ConsensusAutobase', 'No claim/vote entries to bootstrap');
|
||||
return;
|
||||
}
|
||||
|
||||
logInfo('ConsensusAutobase', `Bootstrapping sidecar with ${events.length} events from dnsPass`);
|
||||
await appendConsensusEvents(events);
|
||||
bootstrapComplete = true;
|
||||
logInfo('ConsensusAutobase', 'Bootstrap complete');
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {import('corestore')} options.store
|
||||
* @param {string|null} options.bootstrapKey
|
||||
* @param {import('autopass')} options.dnsPass
|
||||
* @param {boolean} options.isGenesis
|
||||
*/
|
||||
async function initializeConsensusAutobase({ store, bootstrapKey, dnsPass, isGenesis }) {
|
||||
if (!store || typeof store.get !== 'function') {
|
||||
logWarn('ConsensusAutobase', 'Valid corestore required — skipping consensus sidecar init');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (initPromise) return initPromise;
|
||||
|
||||
initPromise = (async () => {
|
||||
if (consensusBase && !consensusBase.closed) {
|
||||
return consensusBase;
|
||||
}
|
||||
|
||||
try {
|
||||
consensusBase = createConsensusAutobase(store, bootstrapKey || null);
|
||||
state.consensusAutobase = consensusBase;
|
||||
|
||||
await consensusBase.ready();
|
||||
await consensusBase.update();
|
||||
|
||||
if (!bootstrapKey && isGenesis) {
|
||||
const keyHex = consensusBase.key.toString('hex');
|
||||
logInfo('ConsensusAutobase', `Genesis consensus autobase created: ${keyHex.slice(0, 16)}...`);
|
||||
}
|
||||
|
||||
await ensureLocalWriter();
|
||||
await bootstrapFromDnsPass(dnsPass, {
|
||||
allowLocalBootstrap: !bootstrapKey || isGenesis,
|
||||
syncWaitMs: bootstrapKey ? 3000 : 0
|
||||
});
|
||||
|
||||
consensusBase.on('update', () => {
|
||||
try {
|
||||
const { invalidateConsensusCache } = require('./consensus-view');
|
||||
invalidateConsensusCache();
|
||||
} catch (err) {
|
||||
logDebug('ConsensusAutobase', `Cache invalidation on update: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
return consensusBase;
|
||||
} catch (err) {
|
||||
initPromise = null;
|
||||
if (consensusBase && !consensusBase.closed) {
|
||||
try {
|
||||
await consensusBase.close();
|
||||
} catch (closeErr) {
|
||||
logDebug('ConsensusAutobase', `Close after failed init: ${closeErr.message}`);
|
||||
}
|
||||
}
|
||||
consensusBase = null;
|
||||
state.consensusAutobase = null;
|
||||
throw err;
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
return await initPromise;
|
||||
} catch (err) {
|
||||
initPromise = null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function replicateConsensus(connection) {
|
||||
if (!consensusBase || consensusBase.closed || !connection) return;
|
||||
try {
|
||||
consensusBase.replicate(connection);
|
||||
} catch (err) {
|
||||
logDebug('ConsensusAutobase', `Replicate failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function closeConsensusAutobase() {
|
||||
bootstrapComplete = false;
|
||||
initPromise = null;
|
||||
if (!consensusBase) return;
|
||||
try {
|
||||
if (!consensusBase.closed) {
|
||||
await consensusBase.close();
|
||||
}
|
||||
} catch (err) {
|
||||
logError('ConsensusAutobase', `Error closing consensus autobase: ${err.message}`);
|
||||
} finally {
|
||||
consensusBase = null;
|
||||
state.consensusAutobase = null;
|
||||
}
|
||||
}
|
||||
|
||||
function getConsensusStatus() {
|
||||
const base = consensusBase;
|
||||
const view = base && !base.closed ? base.view : null;
|
||||
return {
|
||||
open: !!(base && !base.closed),
|
||||
ready: !!(base && base.opened !== false && !base.closed),
|
||||
writable: !!(base && base.writable),
|
||||
bootstrapComplete,
|
||||
eventCount: view ? view.eventCount : 0,
|
||||
domainCount: view ? view.domains.size : 0,
|
||||
lastApplyAt: view ? view.lastApplyAt : null,
|
||||
indexedLength: base ? base.indexedLength : 0,
|
||||
length: base ? base.length : 0,
|
||||
key: base && base.key ? base.key.toString('hex') : null
|
||||
};
|
||||
}
|
||||
|
||||
async function initConsensusForNetwork({ store, dnsPass, networkManifest, manifestPath }) {
|
||||
const base = await initializeConsensusAutobase({
|
||||
store,
|
||||
bootstrapKey: networkManifest?.consensusAutobaseKey || null,
|
||||
dnsPass,
|
||||
isGenesis: !networkManifest?.consensusAutobaseKey
|
||||
});
|
||||
|
||||
if (!base) return null;
|
||||
|
||||
const keyHex = base.key ? base.key.toString('hex') : null;
|
||||
if (keyHex && !networkManifest?.consensusAutobaseKey && manifestPath) {
|
||||
const networkManifestModule = require('../infrastructure/network-manifest');
|
||||
await networkManifestModule.updateManifestConsensusKey(manifestPath, keyHex);
|
||||
if (state.networkManifest) {
|
||||
state.networkManifest.consensusAutobaseKey = keyHex;
|
||||
}
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getConsensusBase,
|
||||
isBootstrapComplete,
|
||||
initializeConsensusAutobase,
|
||||
initConsensusForNetwork,
|
||||
appendConsensusEvent,
|
||||
appendConsensusEvents,
|
||||
replicateConsensus,
|
||||
closeConsensusAutobase,
|
||||
getConsensusStatus,
|
||||
bootstrapFromDnsPass,
|
||||
ensureLocalWriter
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Consensus event encoding and KV key translation.
|
||||
*/
|
||||
|
||||
const { parseClaimValue } = require('./consensus-resolver');
|
||||
|
||||
const EVENT_TYPES = {
|
||||
CLAIM_UPSERT: 'claim_upsert',
|
||||
CLAIM_REMOVE: 'claim_remove',
|
||||
VOTE_UPSERT: 'vote_upsert',
|
||||
VOTE_REMOVE: 'vote_remove'
|
||||
};
|
||||
|
||||
function encodeEvent(event) {
|
||||
return Buffer.from(JSON.stringify(event), 'utf8');
|
||||
}
|
||||
|
||||
function decodeEvent(buffer) {
|
||||
const raw = Buffer.isBuffer(buffer) ? buffer.toString('utf8') : String(buffer);
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
function isConsensusKvKey(key) {
|
||||
return key.startsWith('claim:') || key.startsWith('vote:');
|
||||
}
|
||||
|
||||
function kvMutationToEvent(key, value, isRemove) {
|
||||
if (key.startsWith('claim:')) {
|
||||
const parts = key.split(':');
|
||||
if (parts.length !== 3) return null;
|
||||
const domain = parts[1];
|
||||
const claimant = parts[2];
|
||||
if (isRemove) {
|
||||
return { type: EVENT_TYPES.CLAIM_REMOVE, domain, claimant };
|
||||
}
|
||||
const parsed = parseClaimValue(value);
|
||||
return {
|
||||
type: EVENT_TYPES.CLAIM_UPSERT,
|
||||
domain,
|
||||
claimant,
|
||||
hash: parsed.hash,
|
||||
timestamp: parsed.timestamp,
|
||||
ssl: parsed.ssl,
|
||||
clients: parsed.clients || []
|
||||
};
|
||||
}
|
||||
|
||||
if (key.startsWith('vote:')) {
|
||||
const parts = key.split(':');
|
||||
if (parts.length !== 4) return null;
|
||||
const domain = parts[1];
|
||||
const claimant = parts[2];
|
||||
const voter = parts[3];
|
||||
if (isRemove) {
|
||||
return { type: EVENT_TYPES.VOTE_REMOVE, domain, claimant, voter };
|
||||
}
|
||||
return { type: EVENT_TYPES.VOTE_UPSERT, domain, claimant, voter };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic bootstrap ordering for dnsPass KV entries.
|
||||
* @param {Array<{ key: string, value: string }>} entries
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
function entriesToBootstrapEvents(entries) {
|
||||
const events = [];
|
||||
for (const entry of entries) {
|
||||
if (!isConsensusKvKey(entry.key)) continue;
|
||||
const event = kvMutationToEvent(entry.key, entry.value, false);
|
||||
if (event) events.push(event);
|
||||
}
|
||||
|
||||
events.sort((a, b) => {
|
||||
const typeOrder = (t) => {
|
||||
if (t.startsWith('claim_')) return 0;
|
||||
return 1;
|
||||
};
|
||||
const ta = typeOrder(a.type);
|
||||
const tb = typeOrder(b.type);
|
||||
if (ta !== tb) return ta - tb;
|
||||
if (a.domain !== b.domain) return a.domain.localeCompare(b.domain);
|
||||
const tsA = a.timestamp ?? 0;
|
||||
const tsB = b.timestamp ?? 0;
|
||||
if (tsA !== tsB) return tsA - tsB;
|
||||
const ca = a.claimant || '';
|
||||
const cb = b.claimant || '';
|
||||
if (ca !== cb) return ca.localeCompare(cb);
|
||||
const va = a.voter || '';
|
||||
const vb = b.voter || '';
|
||||
return va.localeCompare(vb);
|
||||
});
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
EVENT_TYPES,
|
||||
encodeEvent,
|
||||
decodeEvent,
|
||||
isConsensusKvKey,
|
||||
kvMutationToEvent,
|
||||
entriesToBootstrapEvents
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Pure consensus resolution logic for domain ownership.
|
||||
* Used by the Autobase apply view at read time.
|
||||
*/
|
||||
|
||||
const LEGACY_CLAIM_TIMESTAMP = 0;
|
||||
|
||||
function parseClaimValue(value) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (parsed.hash && parsed.timestamp !== undefined) {
|
||||
return {
|
||||
hash: parsed.hash,
|
||||
clients: parsed.clients || [],
|
||||
timestamp: parsed.timestamp,
|
||||
ssl: parsed.ssl === true,
|
||||
legacy: false
|
||||
};
|
||||
}
|
||||
if (parsed.hash) {
|
||||
return {
|
||||
hash: parsed.hash,
|
||||
clients: parsed.clients || [],
|
||||
timestamp: parsed.timestamp || LEGACY_CLAIM_TIMESTAMP,
|
||||
ssl: parsed.ssl === true,
|
||||
legacy: false
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
// legacy hash-only string
|
||||
}
|
||||
return {
|
||||
hash: value,
|
||||
clients: [],
|
||||
timestamp: LEGACY_CLAIM_TIMESTAMP,
|
||||
ssl: false,
|
||||
legacy: true
|
||||
};
|
||||
}
|
||||
|
||||
function validateVote(claimant, claims, voteValidationEnabled) {
|
||||
if (!voteValidationEnabled) return true;
|
||||
return Object.prototype.hasOwnProperty.call(claims, claimant);
|
||||
}
|
||||
|
||||
function applyTieBreaker(candidates, claimTimestamps, localWriter, strategy) {
|
||||
switch (strategy) {
|
||||
case 'timestamp':
|
||||
return candidates.sort((a, b) => {
|
||||
const tsA = claimTimestamps[a] || 0;
|
||||
const tsB = claimTimestamps[b] || 0;
|
||||
return tsA - tsB;
|
||||
})[0];
|
||||
|
||||
case 'claimant_age':
|
||||
if (candidates.includes(localWriter)) return localWriter;
|
||||
return candidates.sort((a, b) => a.localeCompare(b))[0];
|
||||
|
||||
case 'lexicographic':
|
||||
default:
|
||||
if (candidates.includes(localWriter)) return localWriter;
|
||||
return candidates.sort((a, b) => a.localeCompare(b))[0];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} params
|
||||
* @param {string} params.domain
|
||||
* @param {Record<string, { hash: string, timestamp: number, ssl?: boolean, clients?: array }>} params.claims
|
||||
* @param {Record<string, string>} params.voterVotes - voter -> claimant
|
||||
* @param {object} params.config
|
||||
* @param {string|null} params.localWriter
|
||||
* @param {number} params.activePeers
|
||||
*/
|
||||
function resolveDomainConsensus({
|
||||
domain,
|
||||
claims,
|
||||
voterVotes,
|
||||
config,
|
||||
localWriter,
|
||||
activePeers
|
||||
}) {
|
||||
const claimTimestamps = {};
|
||||
const claimHashes = {};
|
||||
const voteCounts = {};
|
||||
|
||||
for (const [claimant, claim] of Object.entries(claims || {})) {
|
||||
claimHashes[claimant] = claim.hash;
|
||||
claimTimestamps[claimant] = claim.timestamp;
|
||||
voteCounts[claimant] = 0;
|
||||
}
|
||||
|
||||
const voters = new Set();
|
||||
const voteValidation = config.CONSENSUS_VOTE_VALIDATION !== false;
|
||||
|
||||
for (const [voter, claimant] of Object.entries(voterVotes || {})) {
|
||||
if (!validateVote(claimant, claimHashes, voteValidation)) continue;
|
||||
voteCounts[claimant] = (voteCounts[claimant] || 0) + 1;
|
||||
voters.add(voter);
|
||||
}
|
||||
|
||||
const minVotes = Math.max(
|
||||
config.CONSENSUS_MIN_VOTES || 2,
|
||||
Math.ceil(activePeers * (config.CONSENSUS_QUORUM_THRESHOLD ?? 0.5))
|
||||
);
|
||||
const totalVotes = Object.values(voteCounts).reduce((sum, count) => sum + count, 0);
|
||||
const quorumMet = totalVotes >= minVotes;
|
||||
|
||||
let status = 'no_claims';
|
||||
let resolvedClaimant = null;
|
||||
let hash = null;
|
||||
|
||||
const claimants = Object.keys(claimHashes);
|
||||
|
||||
if (claimants.length === 0) {
|
||||
status = 'no_claims';
|
||||
} else if (!quorumMet) {
|
||||
const isSingleLocalClaim =
|
||||
claimants.length === 1 && localWriter && Object.prototype.hasOwnProperty.call(claimHashes, localWriter);
|
||||
const onlyWeVoted = totalVotes === 0 || (totalVotes === 1 && voters.has(localWriter));
|
||||
|
||||
if (isSingleLocalClaim && (totalVotes === 0 || onlyWeVoted)) {
|
||||
status = 'resolved';
|
||||
resolvedClaimant = localWriter;
|
||||
hash = claimHashes[localWriter];
|
||||
} else {
|
||||
status = 'insufficient_quorum';
|
||||
}
|
||||
} else {
|
||||
const maxVotes = Math.max(...Object.values(voteCounts));
|
||||
const candidates = Object.keys(voteCounts).filter((c) => voteCounts[c] === maxVotes);
|
||||
|
||||
if (candidates.length === 1) {
|
||||
status = 'resolved';
|
||||
resolvedClaimant = candidates[0];
|
||||
hash = claimHashes[resolvedClaimant];
|
||||
} else {
|
||||
status = 'tie';
|
||||
const strategy = config.CONSENSUS_TIE_BREAKER || 'timestamp';
|
||||
resolvedClaimant = applyTieBreaker(candidates, claimTimestamps, localWriter, strategy);
|
||||
hash = claimHashes[resolvedClaimant];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
hash,
|
||||
resolvedClaimant,
|
||||
voteCounts: { ...voteCounts },
|
||||
activePeers,
|
||||
quorumMet,
|
||||
minVotes,
|
||||
totalVotes,
|
||||
lastResolution: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
function buildDomainSnapshotFromView(viewState, domain) {
|
||||
const domainData = viewState.domains.get(domain);
|
||||
if (!domainData) {
|
||||
return { claims: {}, voterVotes: {} };
|
||||
}
|
||||
const claims = {};
|
||||
for (const [claimant, claim] of domainData.claims) {
|
||||
claims[claimant] = { ...claim };
|
||||
}
|
||||
const voterVotes = {};
|
||||
for (const [voter, claimant] of domainData.voterVotes) {
|
||||
voterVotes[voter] = claimant;
|
||||
}
|
||||
return { claims, voterVotes };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LEGACY_CLAIM_TIMESTAMP,
|
||||
parseClaimValue,
|
||||
validateVote,
|
||||
applyTieBreaker,
|
||||
resolveDomainConsensus,
|
||||
buildDomainSnapshotFromView
|
||||
};
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Consensus read API backed by the Autobase sidecar view.
|
||||
*/
|
||||
|
||||
const state = require('../infrastructure/state');
|
||||
const { validateConfig } = require('../infrastructure/config');
|
||||
const { getPersistentPublicKey } = require('../infrastructure/utils');
|
||||
const { logDebug, logInfo, logWarn } = require('../infrastructure/logger');
|
||||
const { trackConsensusEvent } = require('../maintenance/metrics');
|
||||
const {
|
||||
resolveDomainConsensus,
|
||||
buildDomainSnapshotFromView
|
||||
} = require('./consensus-resolver');
|
||||
const { getConsensusBase, getConsensusStatus, isBootstrapComplete } = require('./consensus-autobase');
|
||||
|
||||
function getActivePeerCount() {
|
||||
const writers = state.networkWriterPeers;
|
||||
if (writers && writers.size > 0) {
|
||||
return writers.size + 1;
|
||||
}
|
||||
return (state.connectedPeers?.size || 0) + 1;
|
||||
}
|
||||
|
||||
const consensusStateCache = new Map();
|
||||
const CONSENSUS_CACHE_TTL = 10000;
|
||||
|
||||
const consensusMetrics = {
|
||||
resolutions: 0,
|
||||
quorumFailures: 0,
|
||||
ties: 0,
|
||||
validationFailures: 0,
|
||||
totalVotes: 0,
|
||||
avgVotesPerDomain: 0,
|
||||
domainResolutions: new Map()
|
||||
};
|
||||
|
||||
const countedVotes = new Set();
|
||||
const countedResolutions = new Set();
|
||||
const countedQuorumFailures = new Set();
|
||||
const countedTies = new Set();
|
||||
|
||||
let consensusConfig = null;
|
||||
|
||||
function getConsensusConfig() {
|
||||
if (!consensusConfig) {
|
||||
try {
|
||||
consensusConfig = validateConfig();
|
||||
} catch (err) {
|
||||
consensusConfig = {
|
||||
CONSENSUS_QUORUM_THRESHOLD: 0.5,
|
||||
CONSENSUS_MIN_VOTES: 2,
|
||||
CONSENSUS_TIE_BREAKER: 'timestamp',
|
||||
CONSENSUS_VOTE_VALIDATION: true,
|
||||
CONSENSUS_IMMEDIATE_UPDATE: true
|
||||
};
|
||||
}
|
||||
}
|
||||
return consensusConfig;
|
||||
}
|
||||
|
||||
function invalidateConsensusCache(domain) {
|
||||
if (domain) {
|
||||
consensusStateCache.delete(domain);
|
||||
} else {
|
||||
consensusStateCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function recordResolutionMetrics(domain, status, resolvedClaimant, config) {
|
||||
if (status === 'resolved' && resolvedClaimant) {
|
||||
const resolutionKey = `${domain}:${resolvedClaimant}`;
|
||||
if (!countedResolutions.has(resolutionKey)) {
|
||||
countedResolutions.add(resolutionKey);
|
||||
consensusMetrics.resolutions++;
|
||||
}
|
||||
} else if (status === 'insufficient_quorum') {
|
||||
if (!countedQuorumFailures.has(domain)) {
|
||||
countedQuorumFailures.add(domain);
|
||||
consensusMetrics.quorumFailures++;
|
||||
}
|
||||
} else if (status === 'tie' && resolvedClaimant) {
|
||||
const tieKey = `${domain}:${resolvedClaimant}`;
|
||||
if (!countedTies.has(tieKey)) {
|
||||
countedTies.add(tieKey);
|
||||
consensusMetrics.ties++;
|
||||
}
|
||||
if (trackConsensusEvent) {
|
||||
trackConsensusEvent('tie', { domain });
|
||||
}
|
||||
logInfo('ConsensusView', `Tie resolved for ${domain} using ${config.CONSENSUS_TIE_BREAKER}: ${resolvedClaimant}`);
|
||||
}
|
||||
|
||||
if (!consensusMetrics.domainResolutions.has(domain)) {
|
||||
consensusMetrics.domainResolutions.set(domain, { resolved: 0, failed: 0 });
|
||||
}
|
||||
const domainMetrics = consensusMetrics.domainResolutions.get(domain);
|
||||
if (status === 'resolved') {
|
||||
domainMetrics.resolved++;
|
||||
} else if (status !== 'no_claims') {
|
||||
domainMetrics.failed++;
|
||||
}
|
||||
}
|
||||
|
||||
async function getConsensusState(domain) {
|
||||
const pass = state.dnsPass;
|
||||
if (!pass) {
|
||||
return {
|
||||
status: 'error',
|
||||
error: 'dnsPass not initialized',
|
||||
hash: null,
|
||||
resolvedClaimant: null,
|
||||
voteCounts: {},
|
||||
activePeers: 0,
|
||||
quorumMet: false,
|
||||
lastResolution: null
|
||||
};
|
||||
}
|
||||
|
||||
const cached = consensusStateCache.get(domain);
|
||||
if (cached && Date.now() - cached.timestamp < CONSENSUS_CACHE_TTL) {
|
||||
return cached.state;
|
||||
}
|
||||
|
||||
const base = getConsensusBase();
|
||||
if (!base || base.closed) {
|
||||
return {
|
||||
status: 'error',
|
||||
error: 'consensus sidecar not initialized',
|
||||
hash: null,
|
||||
resolvedClaimant: null,
|
||||
voteCounts: {},
|
||||
activePeers: 0,
|
||||
quorumMet: false,
|
||||
lastResolution: null
|
||||
};
|
||||
}
|
||||
|
||||
await base.ready();
|
||||
await base.update();
|
||||
|
||||
const localWriter = getPersistentPublicKey();
|
||||
if (!localWriter) {
|
||||
return {
|
||||
status: 'error',
|
||||
error: 'Persistent public key not available',
|
||||
hash: null,
|
||||
resolvedClaimant: null,
|
||||
voteCounts: {},
|
||||
activePeers: 0,
|
||||
quorumMet: false,
|
||||
lastResolution: null
|
||||
};
|
||||
}
|
||||
|
||||
const view = base.view;
|
||||
if (!view) {
|
||||
return {
|
||||
status: 'error',
|
||||
error: 'consensus view not available',
|
||||
hash: null,
|
||||
resolvedClaimant: null,
|
||||
voteCounts: {},
|
||||
activePeers: 0,
|
||||
quorumMet: false,
|
||||
lastResolution: null
|
||||
};
|
||||
}
|
||||
|
||||
const snapshot = buildDomainSnapshotFromView(view, domain);
|
||||
const config = getConsensusConfig();
|
||||
const activePeers = getActivePeerCount();
|
||||
|
||||
const consensusState = resolveDomainConsensus({
|
||||
domain,
|
||||
claims: snapshot.claims,
|
||||
voterVotes: snapshot.voterVotes,
|
||||
config,
|
||||
localWriter,
|
||||
activePeers
|
||||
});
|
||||
|
||||
recordResolutionMetrics(domain, consensusState.status, consensusState.resolvedClaimant, config);
|
||||
|
||||
for (const [claimant, count] of Object.entries(consensusState.voteCounts)) {
|
||||
if (count <= 0) continue;
|
||||
for (const voter of Object.keys(snapshot.voterVotes)) {
|
||||
if (snapshot.voterVotes[voter] !== claimant) continue;
|
||||
const voteKey = `${domain}:${claimant}:${voter}`;
|
||||
if (!countedVotes.has(voteKey)) {
|
||||
countedVotes.add(voteKey);
|
||||
consensusMetrics.totalVotes++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const domainsWithVotes = consensusMetrics.domainResolutions.size;
|
||||
if (domainsWithVotes > 0) {
|
||||
consensusMetrics.avgVotesPerDomain = consensusMetrics.totalVotes / domainsWithVotes;
|
||||
}
|
||||
|
||||
consensusStateCache.set(domain, { state: consensusState, timestamp: Date.now() });
|
||||
return consensusState;
|
||||
}
|
||||
|
||||
function getConsensusMetrics() {
|
||||
const sidecar = getConsensusStatus();
|
||||
return {
|
||||
...consensusMetrics,
|
||||
sidecar,
|
||||
bootstrapComplete: isBootstrapComplete(),
|
||||
domainResolutions: Array.from(consensusMetrics.domainResolutions.entries()).map(([d, metrics]) => ({
|
||||
domain: d,
|
||||
...metrics
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function getAllDomainNamesFromView() {
|
||||
const base = getConsensusBase();
|
||||
if (!base || !base.view) return [];
|
||||
return Array.from(base.view.domains.keys());
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getConsensusState,
|
||||
getConsensusMetrics,
|
||||
getConsensusConfig,
|
||||
invalidateConsensusCache,
|
||||
getAllDomainNamesFromView
|
||||
};
|
||||
@@ -106,6 +106,29 @@ function createCoreSwarmHandlers(ctx) {
|
||||
}
|
||||
|
||||
setupListeners();
|
||||
|
||||
try {
|
||||
const { initConsensusForNetwork } = require('./consensus-autobase');
|
||||
const networkManifest = require('../infrastructure/network-manifest');
|
||||
const manifestPath = networkManifest.getManifestPath(process.env.NETWORK_MANIFEST_FILE);
|
||||
let manifest = state.networkManifest;
|
||||
if (!manifest) {
|
||||
manifest = await networkManifest.readManifest(manifestPath);
|
||||
if (manifest) state.networkManifest = manifest;
|
||||
}
|
||||
const base = await initConsensusForNetwork({
|
||||
store,
|
||||
dnsPass: newPass,
|
||||
networkManifest: manifest,
|
||||
manifestPath
|
||||
});
|
||||
if (base) {
|
||||
logInfo('Swarm', 'Consensus sidecar initialized after pairing');
|
||||
}
|
||||
} catch (err) {
|
||||
logError('Swarm', `Failed to initialize consensus sidecar: ${err.message}`);
|
||||
}
|
||||
|
||||
if (isMaster) {
|
||||
state.masterPendingPass = false;
|
||||
logInfo('Swarm', 'Secondary master paired — dnsPass ready, master invite policy active');
|
||||
|
||||
+427
-770
File diff suppressed because it is too large
Load Diff
@@ -156,14 +156,6 @@ async function runSerialized(pass, fn) {
|
||||
});
|
||||
}
|
||||
|
||||
async function dnsPassAdd(pass, key, value, file) {
|
||||
return runSerialized(pass, () => pass.add(key, value, file));
|
||||
}
|
||||
|
||||
async function dnsPassRemove(pass, key) {
|
||||
return runSerialized(pass, () => pass.remove(key));
|
||||
}
|
||||
|
||||
async function dnsPassGet(pass, key) {
|
||||
return runSerialized(pass, () => pass.get(key));
|
||||
}
|
||||
@@ -182,10 +174,47 @@ async function listAllEntries(pass) {
|
||||
});
|
||||
}
|
||||
|
||||
async function appendConsensusEventForKvMutation(key, value, isRemove) {
|
||||
try {
|
||||
const { kvMutationToEvent, isConsensusKvKey } = require('./consensus-events');
|
||||
const { appendConsensusEvent } = require('./consensus-autobase');
|
||||
if (!isConsensusKvKey(key)) return;
|
||||
const event = kvMutationToEvent(key, value, isRemove);
|
||||
if (!event) return;
|
||||
await appendConsensusEvent(event);
|
||||
} catch (err) {
|
||||
logWarn('DnsPassQueue', `Consensus event append failed for ${key}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function dnsPassAdd(pass, key, value, file) {
|
||||
return runSerialized(pass, async () => {
|
||||
const result = await pass.add(key, value, file);
|
||||
await appendConsensusEventForKvMutation(key, value, false);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
async function dnsPassRemove(pass, key) {
|
||||
return runSerialized(pass, async () => {
|
||||
let existingValue = null;
|
||||
try {
|
||||
existingValue = await pass.get(key);
|
||||
} catch (err) {
|
||||
// ignore lookup errors before remove
|
||||
}
|
||||
const result = await pass.remove(key);
|
||||
const valueStr = existingValue != null ? existingValue.toString('utf8') : '';
|
||||
await appendConsensusEventForKvMutation(key, valueStr, true);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
enqueueDnsPass,
|
||||
whenDnsPassIdle,
|
||||
ensureDnsPassOpen,
|
||||
waitForAutobaseIdle,
|
||||
syncDnsPassView,
|
||||
isInviteCreationActive,
|
||||
isAtomicDnsPassError,
|
||||
|
||||
@@ -93,16 +93,30 @@ async function writeManifest(manifestPath, fields) {
|
||||
}
|
||||
|
||||
async function adoptManifestFromPass(pass, topicSeed, manifestPath, options = {}) {
|
||||
const { genesisPublicKey, isGenesisRun = false } = options;
|
||||
const { genesisPublicKey, isGenesisRun = false, consensusAutobaseKey = null } = options;
|
||||
const identity = extractNetworkIdentity(pass, genesisPublicKey);
|
||||
const doc = await writeManifest(manifestPath, {
|
||||
...identity,
|
||||
topicSeed: topicSeed || process.env.TOPIC_SEED || 'p2ns-dns',
|
||||
isGenesis: isGenesisRun
|
||||
isGenesis: isGenesisRun,
|
||||
...(consensusAutobaseKey ? { consensusAutobaseKey } : {})
|
||||
});
|
||||
return doc;
|
||||
}
|
||||
|
||||
async function updateManifestConsensusKey(manifestPath, consensusAutobaseKey) {
|
||||
const filePath = getManifestPath(manifestPath);
|
||||
const existing = await readManifest(manifestPath);
|
||||
if (!existing) return null;
|
||||
const doc = {
|
||||
...existing,
|
||||
consensusAutobaseKey
|
||||
};
|
||||
await fs.writeFile(filePath, `${JSON.stringify(doc, null, 2)}\n`, 'utf8');
|
||||
logInfo('NetworkManifest', `Updated manifest with consensusAutobaseKey: ${consensusAutobaseKey.slice(0, 16)}...`);
|
||||
return doc;
|
||||
}
|
||||
|
||||
function recordPeerNetworkStatus(peerId, status) {
|
||||
if (!peerId || !status) return;
|
||||
const state = require('./state');
|
||||
@@ -163,6 +177,7 @@ module.exports = {
|
||||
readManifest,
|
||||
writeManifest,
|
||||
adoptManifestFromPass,
|
||||
updateManifestConsensusKey,
|
||||
recordPeerNetworkStatus,
|
||||
checkSplitBrain,
|
||||
getLocalNetworkSummary
|
||||
|
||||
@@ -42,6 +42,7 @@ const currentSubnetIndex = 0; // Start with first subnet for round-robin
|
||||
|
||||
module.exports = {
|
||||
dnsPass: null,
|
||||
consensusAutobase: null,
|
||||
corestore: null,
|
||||
hypercoreStats: null,
|
||||
/** Incremented while dns-pass-queue runs a serialized Autopass op (defer update handlers) */
|
||||
|
||||
Reference in New Issue
Block a user