feat(experimental)!: multi-master one ledger (Option B)
Remove the last layer of soft centralization: a lone `--master` on empty storage could still implicitly genesis a separate Autopass and split the network on the same TOPIC_SEED. Genesis is now explicit (`--master --genesis`); secondary masters pair via invite into the same dnsPass as joiners, with a shared network manifest, split-brain diagnostics, and writer-aware quorum. - Genesis vs secondary master boot paths; auto-adopt manifest on upgrade - Masters without dnsPass accept invites; masters with pass ignore them - NETWORK_MANIFEST_FILE, P2NS_GENESIS, MASTER_LOAD_DOMAINS, MASTER_INVITE_ONLY - core.status networkId; admin diagnostics; docs and multi-master tests BREAKING: operators must run one genesis per network; additional masters use `node p2ns.js --master` (not `--genesis`) on empty storage.
This commit is contained in:
@@ -4,6 +4,8 @@
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { logDebug, logError, logInfo, logWarn } = require('../infrastructure/logger');
|
||||
const { resolveMasterInviteOnly } = require('../infrastructure/master-mode');
|
||||
const { getLocalNetworkSummary } = require('../infrastructure/network-manifest');
|
||||
const { INVITE_STATUS, METHODS } = require('./core-rpc-contract');
|
||||
|
||||
function newInviteId() {
|
||||
@@ -36,6 +38,7 @@ function createCoreSwarmHandlers(ctx) {
|
||||
getCurrentPairOperation,
|
||||
setCurrentPairOperation,
|
||||
setupDomainsWatcher,
|
||||
shouldLoadDomains,
|
||||
listDomains,
|
||||
doAutoVotes,
|
||||
setupListeners,
|
||||
@@ -48,8 +51,8 @@ function createCoreSwarmHandlers(ctx) {
|
||||
const label = meta.label || 'invite';
|
||||
const ackPeerId = meta.ackPeerId || peerId;
|
||||
|
||||
if (isMaster) {
|
||||
logWarn('Swarm', `Ignoring ${label} as this is the master`);
|
||||
if (isMaster && getDnsPass()) {
|
||||
logWarn('Swarm', `Ignoring ${label} — master already has dnsPass`);
|
||||
return;
|
||||
}
|
||||
if (getDnsPass()) {
|
||||
@@ -87,7 +90,14 @@ function createCoreSwarmHandlers(ctx) {
|
||||
await currentDnsPass.ready();
|
||||
}
|
||||
|
||||
setupDomainsWatcher();
|
||||
const loadDomainsFile = isMaster
|
||||
? (typeof shouldLoadDomains === 'function' && shouldLoadDomains())
|
||||
: true;
|
||||
if (loadDomainsFile) {
|
||||
setupDomainsWatcher();
|
||||
} else {
|
||||
logInfo('Swarm', 'Skipping domains.json watcher (secondary master; set MASTER_LOAD_DOMAINS=true to enable)');
|
||||
}
|
||||
await listDomains();
|
||||
doAutoVotes();
|
||||
|
||||
@@ -96,6 +106,10 @@ function createCoreSwarmHandlers(ctx) {
|
||||
}
|
||||
|
||||
setupListeners();
|
||||
if (isMaster) {
|
||||
state.masterPendingPass = false;
|
||||
logInfo('Swarm', 'Secondary master paired — dnsPass ready, master invite policy active');
|
||||
}
|
||||
state.consecutiveInviteFailures = 0;
|
||||
|
||||
const requestChannelInfo = channelManager.getChannelInfo(CORE_DOMAIN, 'request');
|
||||
@@ -228,7 +242,14 @@ function createCoreSwarmHandlers(ctx) {
|
||||
return { status: INVITE_STATUS.UNAVAILABLE, reason: 'joiner' };
|
||||
}
|
||||
|
||||
if (!isMaster && process.env.ALLOW_ANY_WRITER_INVITES !== 'true') {
|
||||
const masterInviteOnly = resolveMasterInviteOnly();
|
||||
const allowAnyWriter = process.env.ALLOW_ANY_WRITER_INVITES === 'true';
|
||||
if (masterInviteOnly && !isMaster) {
|
||||
logWarn('Swarm', `Rejecting invite.request from ${peerId} (MASTER_INVITE_ONLY)`);
|
||||
coreRpc.inviteUnavailable(peerId, 'not_master');
|
||||
return { status: INVITE_STATUS.UNAVAILABLE, reason: 'not_master' };
|
||||
}
|
||||
if (!isMaster && !allowAnyWriter) {
|
||||
logWarn('Swarm', `Rejecting invite.request from ${peerId} (not master)`);
|
||||
coreRpc.inviteUnavailable(peerId, 'not_master');
|
||||
return { status: INVITE_STATUS.UNAVAILABLE, reason: 'not_master' };
|
||||
@@ -321,7 +342,11 @@ function createCoreSwarmHandlers(ctx) {
|
||||
|
||||
const pass = getDnsPass();
|
||||
const { isDnsPassUsable } = require('./core');
|
||||
if (pass && isDnsPassUsable(pass) && (isMaster || process.env.ALLOW_ANY_WRITER_INVITES === 'true')) {
|
||||
const canRelayInvite =
|
||||
pass &&
|
||||
isDnsPassUsable(pass) &&
|
||||
(isMaster || (!resolveMasterInviteOnly() && process.env.ALLOW_ANY_WRITER_INVITES === 'true'));
|
||||
if (canRelayInvite) {
|
||||
try {
|
||||
const { createInvite, inviteToWire, whenDnsPassIdle } = require('./dns-pass-queue');
|
||||
await whenDnsPassIdle();
|
||||
@@ -391,26 +416,36 @@ function createCoreSwarmHandlers(ctx) {
|
||||
const pass = getDnsPass();
|
||||
const dnsPassInitialized = !!pass;
|
||||
const allowAnyWriterInvites = process.env.ALLOW_ANY_WRITER_INVITES === 'true';
|
||||
const masterInviteOnly = resolveMasterInviteOnly();
|
||||
let canProvideInvite = false;
|
||||
if (dnsPassInitialized) {
|
||||
if (isMaster) {
|
||||
canProvideInvite = true;
|
||||
} else if (allowAnyWriterInvites) {
|
||||
} else if (!masterInviteOnly && allowAnyWriterInvites) {
|
||||
canProvideInvite = true;
|
||||
}
|
||||
}
|
||||
|
||||
const network = getLocalNetworkSummary();
|
||||
const baseCoreKey = pass?.base?.key ? pass.base.key.toString('hex').slice(0, 16) + '...' : null;
|
||||
|
||||
return {
|
||||
nodeType: isMaster ? 'master' : 'joiner',
|
||||
dnsPassInitialized,
|
||||
canProvideInvite,
|
||||
allowAnyWriterInvites,
|
||||
masterInviteOnly,
|
||||
isProcessingInvite: isProcessingInvite(),
|
||||
consecutiveInviteFailures: state.consecutiveInviteFailures || 0,
|
||||
connectedPeers: connectedPeers.size,
|
||||
masterQueueSize: state.pendingInviteRequests?.size || 0,
|
||||
inFlightInviteHandlers: pendingInviteRequestHandlers.size,
|
||||
failedInvitePeerCount: failedInvitePeers.size
|
||||
failedInvitePeerCount: failedInvitePeers.size,
|
||||
networkId: network.networkId,
|
||||
manifestPresent: network.manifestPresent,
|
||||
isGenesis: network.isGenesis,
|
||||
masterPendingPass: network.masterPendingPass,
|
||||
baseCoreKey
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -223,9 +223,13 @@ async function updateClaimClients(domain, claimant, clients) {
|
||||
}
|
||||
}
|
||||
|
||||
// Get active peer count
|
||||
// Get active peer count for quorum (writers on the network, not raw swarm connections)
|
||||
function getActivePeerCount() {
|
||||
return (state.connectedPeers?.size || 0) + 1; // +1 for local node
|
||||
const writers = state.networkWriterPeers;
|
||||
if (writers && writers.size > 0) {
|
||||
return writers.size + 1; // +1 for local node
|
||||
}
|
||||
return (state.connectedPeers?.size || 0) + 1;
|
||||
}
|
||||
|
||||
// Validate vote references existing claim
|
||||
@@ -929,6 +933,7 @@ module.exports = {
|
||||
doAutoVotes,
|
||||
getAllEntries,
|
||||
isDnsPassUsable,
|
||||
getActivePeerCount,
|
||||
autoVoteForDomain,
|
||||
voteForDomain,
|
||||
removeDomain,
|
||||
|
||||
Reference in New Issue
Block a user