From 61ae58155cc579183f7424af0d6b4c5b0fdae107 Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 29 Aug 2026 00:33:11 +0200 Subject: [PATCH 01/12] feat(dashmate): add state sync options to config schema Adds platform.drive.tenderdash.stateSync (enabled, retries, chunkRequestTimeout, fetchersCount) and platform.drive.abci.stateSync.snapshots (enabled, frequencySeconds, maxCount). Tenderdash 1.7 minimums are encoded in the schema: chunk request timeout of at least 5s, 1-64 fetchers. Co-Authored-By: Claude Fable 5 --- .../dashmate/src/config/configJsonSchema.js | 69 ++++++++++++++++++- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/packages/dashmate/src/config/configJsonSchema.js b/packages/dashmate/src/config/configJsonSchema.js index 263c1e4432d..6397a4f6c33 100644 --- a/packages/dashmate/src/config/configJsonSchema.js +++ b/packages/dashmate/src/config/configJsonSchema.js @@ -1087,9 +1087,37 @@ export default { required: ['txProcessingTimeLimit'], additionalProperties: false, }, + stateSync: { + type: 'object', + properties: { + snapshots: { + type: 'object', + properties: { + enabled: { + type: 'boolean', + description: 'Take state sync snapshots (GroveDB checkpoints) and serve them to peers', + }, + frequencySeconds: { + type: 'integer', + minimum: 60, + description: 'How often to take a snapshot, in seconds', + }, + maxCount: { + type: 'integer', + minimum: 2, + description: 'How many snapshots to keep before pruning the oldest', + }, + }, + required: ['enabled', 'frequencySeconds', 'maxCount'], + additionalProperties: false, + }, + }, + required: ['snapshots'], + additionalProperties: false, + }, }, additionalProperties: false, - required: ['docker', 'logs', 'tokioConsole', 'validatorSet', 'chainLock', 'epochTime', 'metrics', 'grovedbVisualizer', 'proposer'], + required: ['docker', 'logs', 'tokioConsole', 'validatorSet', 'chainLock', 'epochTime', 'metrics', 'grovedbVisualizer', 'proposer', 'stateSync'], }, tenderdash: { type: 'object', @@ -1337,8 +1365,45 @@ export default { genesis: { type: 'object', }, + stateSync: { + type: 'object', + properties: { + enabled: { + type: 'boolean', + description: 'Bootstrap a fresh node from a state sync snapshot instead of replaying' + + ' all blocks. Ignored once the node has local state', + }, + retries: { + type: 'integer', + minimum: 0, + description: 'How many times to retry state sync before falling back to block sync.' + + ' 0 disables retries', + }, + chunkRequestTimeout: { + description: 'Timeout before re-requesting a snapshot chunk. Tenderdash requires at least 5s', + allOf: [ + { + $ref: '#/definitions/duration', + }, + { + type: 'string', + // At least 5 seconds: 5s+, 5000ms+, or any whole number of minutes/hours + pattern: '^(([5-9]|[1-9][0-9]+)(\\.[0-9]+)?s|([5-9][0-9]{3}|[1-9][0-9]{4,})(\\.[0-9]+)?ms|[1-9][0-9]*(\\.[0-9]+)?[mh])$', + }, + ], + }, + fetchersCount: { + type: 'integer', + minimum: 1, + maximum: 64, + description: 'Number of concurrent snapshot chunk fetchers', + }, + }, + required: ['enabled', 'retries', 'chunkRequestTimeout', 'fetchersCount'], + additionalProperties: false, + }, }, - required: ['mode', 'docker', 'p2p', 'mempool', 'consensus', 'log', 'rpc', 'pprof', 'node', 'moniker', 'genesis', 'metrics'], + required: ['mode', 'docker', 'p2p', 'mempool', 'consensus', 'log', 'rpc', 'pprof', 'node', 'moniker', 'genesis', 'metrics', 'stateSync'], additionalProperties: false, }, }, From 0ccf0842d61b29fedd5ea9ae643bebe313c35127 Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 29 Aug 2026 00:33:20 +0200 Subject: [PATCH 02/12] feat(dashmate): default state sync on, except for local networks Base config enables consuming (tenderdash stateSync) and serving (drive snapshots every 600s, keeping 6). Serving is always on in Tenderdash and a node with local state ignores the consume flag, so the default is safe for existing nodes. The local preset disables both: a local network genesis starts every node from scratch, so there is no populated peer to sync from. Co-Authored-By: Claude Fable 5 --- .../configs/defaults/getBaseConfigFactory.js | 17 ++++ .../configs/defaults/getLocalConfigFactory.js | 10 ++ .../test/unit/config/stateSyncOptions.spec.js | 99 +++++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 packages/dashmate/test/unit/config/stateSyncOptions.spec.js diff --git a/packages/dashmate/configs/defaults/getBaseConfigFactory.js b/packages/dashmate/configs/defaults/getBaseConfigFactory.js index 6e8a7132487..5d491183c59 100644 --- a/packages/dashmate/configs/defaults/getBaseConfigFactory.js +++ b/packages/dashmate/configs/defaults/getBaseConfigFactory.js @@ -356,6 +356,13 @@ export default function getBaseConfigFactory() { txProcessingTimeLimit: null, }, epochTime: 788400, + stateSync: { + snapshots: { + enabled: true, + frequencySeconds: 600, + maxCount: 6, + }, + }, }, tenderdash: { mode: 'full', @@ -460,6 +467,16 @@ export default function getBaseConfigFactory() { }, }, moniker: null, + // Serving snapshots to peers is always on in Tenderdash; `enabled` + // only makes a fresh node bootstrap from a snapshot, and Tenderdash + // ignores it once the node has local state, so it is safe on by + // default for existing nodes. + stateSync: { + enabled: true, + retries: 3, + chunkRequestTimeout: '15s', + fetchersCount: 4, + }, }, }, sourcePath: null, diff --git a/packages/dashmate/configs/defaults/getLocalConfigFactory.js b/packages/dashmate/configs/defaults/getLocalConfigFactory.js index 3ce6adc4851..26054578e7e 100644 --- a/packages/dashmate/configs/defaults/getLocalConfigFactory.js +++ b/packages/dashmate/configs/defaults/getLocalConfigFactory.js @@ -105,6 +105,11 @@ export default function getLocalConfigFactory(getBaseConfig) { metrics: { port: 46660, }, + // A local network genesis starts every node from scratch at the + // same time, so there is no populated peer to state sync from. + stateSync: { + enabled: false, + }, }, abci: { tokioConsole: { @@ -141,6 +146,11 @@ export default function getLocalConfigFactory(getBaseConfig) { rotation: false, }, }, + stateSync: { + snapshots: { + enabled: false, + }, + }, }, }, }, diff --git a/packages/dashmate/test/unit/config/stateSyncOptions.spec.js b/packages/dashmate/test/unit/config/stateSyncOptions.spec.js new file mode 100644 index 00000000000..a92e34cf2e3 --- /dev/null +++ b/packages/dashmate/test/unit/config/stateSyncOptions.spec.js @@ -0,0 +1,99 @@ +import HomeDir from '../../../src/config/HomeDir.js'; +import getBaseConfigFactory from '../../../configs/defaults/getBaseConfigFactory.js'; +import getLocalConfigFactory from '../../../configs/defaults/getLocalConfigFactory.js'; + +describe('state sync options', () => { + let getBaseConfig; + + beforeEach(() => { + getBaseConfig = getBaseConfigFactory(HomeDir.createTemp()); + }); + + describe('defaults', () => { + it('should enable consuming and serving snapshots on the base config', () => { + const config = getBaseConfig(); + + expect(config.get('platform.drive.tenderdash.stateSync')).to.deep.equal({ + enabled: true, + retries: 3, + chunkRequestTimeout: '15s', + fetchersCount: 4, + }); + + expect(config.get('platform.drive.abci.stateSync')).to.deep.equal({ + snapshots: { + enabled: true, + frequencySeconds: 600, + maxCount: 6, + }, + }); + }); + + // A local network genesis starts every node from scratch at the same time, + // so there is no populated peer to sync from and nothing worth serving. + it('should disable consuming and serving snapshots on the local preset', () => { + const config = getLocalConfigFactory(getBaseConfig)(); + + expect(config.get('platform.drive.tenderdash.stateSync.enabled')).to.be.false(); + expect(config.get('platform.drive.abci.stateSync.snapshots.enabled')).to.be.false(); + }); + }); + + describe('schema', () => { + let config; + + beforeEach(() => { + config = getBaseConfig(); + }); + + it('should accept retries of 0 (disable retries) but not negative', () => { + config.set('platform.drive.tenderdash.stateSync.retries', 0); + + expect(() => config.set('platform.drive.tenderdash.stateSync.retries', -1)) + .to.throw(); + }); + + it('should accept 1 to 64 fetchers only', () => { + config.set('platform.drive.tenderdash.stateSync.fetchersCount', 1); + config.set('platform.drive.tenderdash.stateSync.fetchersCount', 64); + + expect(() => config.set('platform.drive.tenderdash.stateSync.fetchersCount', 0)) + .to.throw(); + expect(() => config.set('platform.drive.tenderdash.stateSync.fetchersCount', 65)) + .to.throw(); + }); + + // Tenderdash 1.7 rejects a statesync chunk-request-timeout below 5 seconds. + it('should reject a chunk request timeout below the 5s Tenderdash minimum', () => { + for (const valid of ['5s', '15s', '1.5m', '2h', '5000ms', '30000ms']) { + config.set('platform.drive.tenderdash.stateSync.chunkRequestTimeout', valid); + } + + for (const invalid of ['0', '4s', '4.9s', '4999ms', '500ms', 'nonsense', 15]) { + expect(() => config.set('platform.drive.tenderdash.stateSync.chunkRequestTimeout', invalid), String(invalid)) + .to.throw(); + } + }); + + it('should reject a snapshot frequency below one minute', () => { + config.set('platform.drive.abci.stateSync.snapshots.frequencySeconds', 60); + + expect(() => config.set('platform.drive.abci.stateSync.snapshots.frequencySeconds', 59)) + .to.throw(); + }); + + it('should keep at least two snapshots', () => { + config.set('platform.drive.abci.stateSync.snapshots.maxCount', 2); + + expect(() => config.set('platform.drive.abci.stateSync.snapshots.maxCount', 1)) + .to.throw(); + }); + + it('should reject unknown state sync options', () => { + expect(() => config.set('platform.drive.tenderdash.stateSync.maxConcurrentListSnapshots', 100)) + .to.throw(); + expect(() => config.set('platform.drive.abci.stateSync.snapshots.frequency', 5)) + .to.throw(); + }); + }); +}); From 3991c4077f3c54a188d67d0a1269896d4730284a Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 29 Aug 2026 00:34:47 +0200 Subject: [PATCH 03/12] feat(dashmate): wire state sync into the tenderdash config template Templates the statesync section from config: enable, retries, chunk-request-timeout and fetchers. use-p2p is hardcoded to true because the RPC state provider needs two reachable RPC servers while dashmate publishes the Tenderdash RPC on loopback only, unproxied and without TLS. Drops the trust-height/trust-hash/trust-period keys removed in Tenderdash 1.7. Routes ListSnapshots and LoadSnapshotChunk to the drive gRPC app alongside CheckTx and bounds their concurrency; OfferSnapshot and ApplySnapshotChunk stay on the consensus socket. Co-Authored-By: Claude Fable 5 --- .../platform/drive/tenderdash/config.toml.dot | 34 +++++++------ .../tenderdashConfigTemplate.spec.js | 51 +++++++++++++++++++ 2 files changed, 69 insertions(+), 16 deletions(-) create mode 100644 packages/dashmate/test/unit/templates/tenderdashConfigTemplate.spec.js diff --git a/packages/dashmate/templates/platform/drive/tenderdash/config.toml.dot b/packages/dashmate/templates/platform/drive/tenderdash/config.toml.dot index 220f84df843..499d37e5925 100644 --- a/packages/dashmate/templates/platform/drive/tenderdash/config.toml.dot +++ b/packages/dashmate/templates/platform/drive/tenderdash/config.toml.dot @@ -81,7 +81,7 @@ filter-peers = false # Example for routed multi-app setup: # abci = "routed" # address = "Info:socket:unix:///tmp/socket.1,Info:socket:unix:///tmp/socket.2,CheckTx:socket:unix:///tmp/socket.1,*:socket:unix:///tmp/socket.3" -address = "CheckTx:grpc:drive_abci:26670,*:socket:tcp://drive_abci:26658" +address = "CheckTx:grpc:drive_abci:26670,ListSnapshots:grpc:drive_abci:26670,LoadSnapshotChunk:grpc:drive_abci:26670,*:socket:tcp://drive_abci:26658" # Transport mechanism to connect to the ABCI application: socket | grpc | routed transport = "routed" # Maximum number of simultaneous connections to the ABCI application @@ -97,6 +97,10 @@ transport = "routed" #] grpc-concurrency = [ { "check_tx" = {{= it.platform.drive.tenderdash.mempool.maxConcurrentCheckTx }} }, + # Snapshot serving: discovery is one request per peer, chunk downloads run + # several concurrent fetchers per syncing peer. + { "list_snapshots" = 10 }, + { "load_snapshot_chunk" = 100 }, ] @@ -418,29 +422,27 @@ ttl-num-blocks = {{=it.platform.drive.tenderdash.mempool.ttlNumBlocks}} # the network to take and serve state machine snapshots. State sync is not attempted if the node # has any local state (LastBlockHeight > 0). The node will have a truncated block history, # starting from the height of the snapshot. -enable = false +enable = {{? it.platform.drive.tenderdash.stateSync.enabled }}true{{??}}false{{?}} # State sync uses light client verification to verify state. This can be done either through the -# P2P layer or RPC layer. Set this to true to use the P2P layer. If false (default), RPC layer -# will be used. -use-p2p = false +# P2P layer or RPC layer. Set this to true to use the P2P layer. +# Hardcoded to P2P: the RPC mode needs at least two reachable RPC servers, but dashmate +# publishes the Tenderdash RPC on loopback only and does not proxy it through the gateway +# (no TLS or auth), so the RPC state provider is not viable here. +use-p2p = true # If using RPC, at least two addresses need to be provided. They should be compatible with net.Dial, # for example: "host.example.com:2125" rpc-servers = "" -# The hash and height of a trusted block. Must be within the trust-period. -trust-height = 0 -trust-hash = "" - -# The trust period should be set so that Tendermint can detect and gossip misbehavior before -# it is considered expired. For chains based on the Cosmos SDK, one day less than the unbonding -# period should suffice. -trust-period = "168h0m0s" - # Time to spend discovering snapshots before initiating a restore. discovery-time = "15s" +# The number of times to retry state sync. When retries are exhausted, the node falls back +# to block sync. Set to 0 to disable retries. In the pessimistic case it takes at least +# discovery-time * retries before falling back. +retries = {{= it.platform.drive.tenderdash.stateSync.retries }} + # Temporary directory for state sync snapshot chunks, defaults to os.TempDir(). # The synchronizer will create a new, randomly named directory within this directory # and remove it when the sync is complete. @@ -448,10 +450,10 @@ temp-dir = "" # The timeout duration before re-requesting a chunk, possibly from a different # peer (default: 15 seconds). -chunk-request-timeout = "15s" +chunk-request-timeout = "{{= it.platform.drive.tenderdash.stateSync.chunkRequestTimeout }}" # The number of concurrent chunk and block fetchers to run (default: 4). -fetchers = "4" +fetchers = "{{= it.platform.drive.tenderdash.stateSync.fetchersCount }}" ####################################################### ### Consensus Configuration Options ### diff --git a/packages/dashmate/test/unit/templates/tenderdashConfigTemplate.spec.js b/packages/dashmate/test/unit/templates/tenderdashConfigTemplate.spec.js new file mode 100644 index 00000000000..3d4f323fee6 --- /dev/null +++ b/packages/dashmate/test/unit/templates/tenderdashConfigTemplate.spec.js @@ -0,0 +1,51 @@ +import getBaseConfigFactory from '../../../configs/defaults/getBaseConfigFactory.js'; +import HomeDir from '../../../src/config/HomeDir.js'; +import renderServiceTemplatesFactory from '../../../src/templates/renderServiceTemplatesFactory.js'; +import renderTemplateFactory from '../../../src/templates/renderTemplateFactory.js'; + +describe('tenderdash config template', () => { + let config; + let renderServiceTemplates; + + beforeEach(() => { + const getBaseConfig = getBaseConfigFactory(HomeDir.createTemp()); + config = getBaseConfig(); + + const renderTemplate = renderTemplateFactory(); + renderServiceTemplates = renderServiceTemplatesFactory(renderTemplate); + }); + + const renderTenderdashConfig = () => renderServiceTemplates(config)['platform/drive/tenderdash/config.toml']; + + it('should render the statesync section from config defaults', () => { + const toml = renderTenderdashConfig(); + + expect(toml).to.include('enable = true'); + expect(toml).to.include('use-p2p = true'); + expect(toml).to.include('retries = 3'); + expect(toml).to.include('chunk-request-timeout = "15s"'); + expect(toml).to.include('fetchers = "4"'); + + // Light client trust options were removed in Tenderdash 1.7 + expect(toml).to.not.include('trust-height'); + expect(toml).to.not.include('trust-period'); + + expect(toml).to.not.include('undefined'); + }); + + it('should render statesync consuming disabled', () => { + config.set('platform.drive.tenderdash.stateSync.enabled', false); + + expect(renderTenderdashConfig()).to.include('enable = false'); + }); + + it('should route snapshot serving to the drive grpc app', () => { + const toml = renderTenderdashConfig(); + + expect(toml).to.include('ListSnapshots:grpc:drive_abci:26670'); + expect(toml).to.include('LoadSnapshotChunk:grpc:drive_abci:26670'); + expect(toml).to.include('*:socket:tcp://drive_abci:26658'); + expect(toml).to.include('{ "list_snapshots" = 10 }'); + expect(toml).to.include('{ "load_snapshot_chunk" = 100 }'); + }); +}); From f5cbbeda7ebdf506cef1456287d7d9b58f5c6bec Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 29 Aug 2026 00:35:50 +0200 Subject: [PATCH 04/12] feat(dashmate): pass snapshot settings to drive-abci Maps the state sync snapshot config to the SNAPSHOTS_ENABLED, SNAPSHOTS_FREQUENCY_SECONDS and MAX_NUM_SNAPSHOTS envs drive-abci consumes. Checkpoints are written to the default CHECKPOINTS_PATH under DB_PATH, which is already inside the drive_abci_data volume, so no new volume is needed. Co-Authored-By: Claude Fable 5 --- packages/dashmate/docker-compose.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/dashmate/docker-compose.yml b/packages/dashmate/docker-compose.yml index 0f0384e2aaa..a5a21d55383 100644 --- a/packages/dashmate/docker-compose.yml +++ b/packages/dashmate/docker-compose.yml @@ -96,6 +96,11 @@ services: - GROVEDB_VISUALIZER_ADDRESS=0.0.0.0:${PLATFORM_DRIVE_ABCI_GROVEDB_VISUALIZER_PORT:?err} - PROPOSER_TX_PROCESSING_TIME_LIMIT=${PLATFORM_DRIVE_ABCI_PROPOSER_TX_PROCESSING_TIME_LIMIT} - NETWORK=${NETWORK:?err} + # Checkpoints live in the default CHECKPOINTS_PATH (DB_PATH/checkpoints), + # inside the drive_abci_data volume + - SNAPSHOTS_ENABLED=${PLATFORM_DRIVE_ABCI_STATE_SYNC_SNAPSHOTS_ENABLED:?err} + - SNAPSHOTS_FREQUENCY_SECONDS=${PLATFORM_DRIVE_ABCI_STATE_SYNC_SNAPSHOTS_FREQUENCY_SECONDS:?err} + - MAX_NUM_SNAPSHOTS=${PLATFORM_DRIVE_ABCI_STATE_SYNC_SNAPSHOTS_MAX_COUNT:?err} stop_grace_period: 30s expose: - 26658 From 04e2b0f9912970f8a27c9f2542dc0b18b1dd7ea5 Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 29 Aug 2026 00:39:12 +0200 Subject: [PATCH 05/12] feat(dashmate): migrate configs to the state sync options Keyed at 4.2.0-dev.6, above the 4.2.0-dev.5 the package is at, so the runner picks it up and dev-build stamped configs cross it. Options are pulled from the default config matching each config's name or group, which gives the local preset its disables and everything else the base defaults. Co-Authored-By: Claude Fable 5 --- .../configs/getConfigFileMigrationsFactory.js | 17 +++++++ .../migrateConfigFileFactory.spec.js | 45 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/packages/dashmate/configs/getConfigFileMigrationsFactory.js b/packages/dashmate/configs/getConfigFileMigrationsFactory.js index 077ebae1225..ea6dca8f9c4 100644 --- a/packages/dashmate/configs/getConfigFileMigrationsFactory.js +++ b/packages/dashmate/configs/getConfigFileMigrationsFactory.js @@ -1764,6 +1764,23 @@ export default function getConfigFileMigrationsFactory(homeDir, defaultConfigs) return configFile; }, + '4.2.0-dev.6': (configFile) => { + // State sync options are required by the schema now. Pulled from the + // default config matching each config's name or group, so the local + // preset gets its disables while everything else gets the base + // defaults (consume and serve snapshots). + Object.entries(configFile.configs) + .forEach(([name, options]) => { + const defaultConfig = getDefaultConfigByNameOrGroup(name, options.group); + + options.platform.drive.tenderdash.stateSync = defaultConfig + .getStored('platform.drive.tenderdash.stateSync'); + options.platform.drive.abci.stateSync = defaultConfig + .getStored('platform.drive.abci.stateSync'); + }); + + return configFile; + }, }; } diff --git a/packages/dashmate/test/unit/config/configFile/migrateConfigFileFactory.spec.js b/packages/dashmate/test/unit/config/configFile/migrateConfigFileFactory.spec.js index 5fc22d104e0..265a2c160f4 100644 --- a/packages/dashmate/test/unit/config/configFile/migrateConfigFileFactory.spec.js +++ b/packages/dashmate/test/unit/config/configFile/migrateConfigFileFactory.spec.js @@ -234,6 +234,51 @@ describe('migrateConfigFileFactory', () => { } }); + it('should add state sync options to a config stamped before they existed', async () => { + // The schema now requires the state sync options, so a config written + // before they existed cannot be loaded until the migration adds them. + const fromVersion = '4.2.0-dev.5'; + const { version } = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT_DIR, 'package.json'), 'utf8')); + + const configFileData = createConfigFile().toObject(); + configFileData.configFormatVersion = fromVersion; + for (const options of Object.values(configFileData.configs)) { + delete options.platform.drive.tenderdash.stateSync; + delete options.platform.drive.abci.stateSync; + } + + const migrated = migrateConfigFile(configFileData, fromVersion, version); + + for (const [name, options] of Object.entries(migrated.configs)) { + // A local network genesis starts every node from scratch, so the local + // preset neither consumes nor serves snapshots. + const enabled = !(name === 'local' || options.group === 'local'); + + expect(options.platform.drive.tenderdash.stateSync).to.deep.equal( + { + enabled, + retries: 3, + chunkRequestTimeout: '15s', + fetchersCount: 4, + }, + `tenderdash state sync options not added for ${name}`, + ); + expect(options.platform.drive.abci.stateSync).to.deep.equal( + { + snapshots: { + enabled, + frequencySeconds: 600, + maxCount: 6, + }, + }, + `drive snapshot options not added for ${name}`, + ); + + expect(() => new Config(name, options), `migrated ${name} config does not load`) + .to.not.throw(); + } + }); + it('should load a config a development build stamped with its own prerelease version', async () => { // A development build records its own package version in the config, so // every node running one is stamped at a prerelease of the next release. From 0ab4b9947c74a7fc3e1fc1b1968a940ab76ef4ea Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 29 Aug 2026 00:40:14 +0200 Subject: [PATCH 06/12] feat(dashmate): account for snapshot disk headroom in doctor When drive snapshots are enabled, the doctor adds a conservative 10GB to the required free disk space and says so in the problem message. Checkpoints hard-link unchanged data, so a small fixed headroom is enough. Configs collected by an older dashmate have no state sync options and skip the headroom. Co-Authored-By: Claude Fable 5 --- .../analyse/analyseSystemResourcesFactory.js | 9 ++++++++ .../doctor/verifySystemRequirementsFactory.js | 14 +++++++++-- .../verifySystemRequirementsFactory.spec.js | 23 +++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/dashmate/src/doctor/analyse/analyseSystemResourcesFactory.js b/packages/dashmate/src/doctor/analyse/analyseSystemResourcesFactory.js index a5728a4b413..aea5ad19788 100644 --- a/packages/dashmate/src/doctor/analyse/analyseSystemResourcesFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseSystemResourcesFactory.js @@ -22,6 +22,14 @@ export default function analyseSystemResourcesFactory(verifySystemRequirements) diskIO, } = samples.getSystemInfo(); + let stateSyncSnapshotsEnabled = false; + try { + stateSyncSnapshotsEnabled = samples.getDashmateConfig() + .get('platform.drive.abci.stateSync.snapshots.enabled') === true; + } catch (e) { + // A config collected by an older dashmate has no state sync options + } + // System requirements const problems = verifySystemRequirements( { @@ -33,6 +41,7 @@ export default function analyseSystemResourcesFactory(verifySystemRequirements) samples.getDashmateConfig().get('platform.enable'), { diskSpace: 5, + stateSyncSnapshotsEnabled, }, ); diff --git a/packages/dashmate/src/doctor/verifySystemRequirementsFactory.js b/packages/dashmate/src/doctor/verifySystemRequirementsFactory.js index f4ed0196a5a..e9a6779b583 100644 --- a/packages/dashmate/src/doctor/verifySystemRequirementsFactory.js +++ b/packages/dashmate/src/doctor/verifySystemRequirementsFactory.js @@ -15,6 +15,7 @@ export default function verifySystemRequirementsFactory() { * @param {boolean} isHP * @param {Object} [overrideRequirements] * @param {Number} [overrideRequirements.diskSpace] + * @param {boolean} [overrideRequirements.stateSyncSnapshotsEnabled] * @returns {Problem[]} */ function verifySystemRequirements( @@ -30,7 +31,12 @@ export default function verifySystemRequirementsFactory() { const MINIMUM_CPU_CORES = isHP ? 4 : 2; const MINIMUM_CPU_FREQUENCY = 2.4; // GHz const MINIMUM_RAM = isHP ? 7.3 : 3.6; // GB - const MINIMUM_DISK_SPACE = overrideRequirements.diskSpace ?? (isHP ? 200 : 100); // GB + + // State sync snapshots are GroveDB checkpoints stored next to the database. + // They share unchanged data with it, so a small fixed headroom is enough. + const SNAPSHOTS_DISK_HEADROOM = overrideRequirements.stateSyncSnapshotsEnabled ? 10 : 0; // GB + const MINIMUM_DISK_SPACE = (overrideRequirements.diskSpace ?? (isHP ? 200 : 100)) + + SNAPSHOTS_DISK_HEADROOM; // GB const problems = []; @@ -112,8 +118,12 @@ for required network services and avoid Proof-of-Service bans`, const availableDiskSpace = diskSpace.available / (1024 ** 3); // Convert to GB if (availableDiskSpace < MINIMUM_DISK_SPACE) { + const headroomNote = SNAPSHOTS_DISK_HEADROOM > 0 + ? ` (including ${SNAPSHOTS_DISK_HEADROOM}GB headroom for state sync snapshots)` + : ''; + const problem = new Problem( - `${availableDiskSpace.toFixed(2)}GB of available disk space detected. At least ${MINIMUM_DISK_SPACE}GB is required`, + `${availableDiskSpace.toFixed(2)}GB of available disk space detected. At least ${MINIMUM_DISK_SPACE}GB is required${headroomNote}`, `Consider increasing disk space to make sure the node can provide timely responses for required network services and avoid Proof-of-Service bans`, MINIMUM_DISK_SPACE - availableDiskSpace < 5 ? SEVERITY.HIGH : SEVERITY.MEDIUM, diff --git a/packages/dashmate/test/unit/doctor/verifySystemRequirementsFactory.spec.js b/packages/dashmate/test/unit/doctor/verifySystemRequirementsFactory.spec.js index 38590b3fbdc..53cccfb4f75 100644 --- a/packages/dashmate/test/unit/doctor/verifySystemRequirementsFactory.spec.js +++ b/packages/dashmate/test/unit/doctor/verifySystemRequirementsFactory.spec.js @@ -130,6 +130,29 @@ describe('verifySystemRequirementsFactory', () => { expect(problems[0]).to.be.an.instanceOf(Problem); expect(problems[0].getDescription()).to.include('50.00GB of available disk space detected'); }); + + it('should add headroom for state sync snapshots', () => { + const systemInfo = { + diskSpace: { available: 12 * 1024 ** 3 }, + }; + + // 12GB clears the 5GB override on its own... + const problems = verifySystemRequirements(systemInfo, false, { + diskSpace: 5, + }); + + expect(problems).to.have.lengthOf(0); + + // ...but not with the snapshot headroom on top + const problemsWithSnapshots = verifySystemRequirements(systemInfo, false, { + diskSpace: 5, + stateSyncSnapshotsEnabled: true, + }); + + expect(problemsWithSnapshots).to.have.lengthOf(1); + expect(problemsWithSnapshots[0].getDescription()) + .to.include('At least 15GB is required (including 10GB headroom for state sync snapshots)'); + }); }); it('should not return any problems if all requirements are met', () => { From 1d9a47a6815f3d77706a02cac23bd67d7fcfb1f5 Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 29 Aug 2026 00:40:57 +0200 Subject: [PATCH 07/12] docs(dashmate): document state sync configuration Adds a State Sync section to the tenderdash config doc (consume side, P2P-only rationale, self-disable semantics) and a State Sync Snapshots section to the drive-abci doc (serve side, checkpoint location and cost). Co-Authored-By: Claude Fable 5 --- packages/dashmate/docs/config/drive-abci.md | 12 ++++++++++++ packages/dashmate/docs/config/tenderdash.md | 15 +++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/packages/dashmate/docs/config/drive-abci.md b/packages/dashmate/docs/config/drive-abci.md index 3bbbf91ea44..3cc9341f590 100644 --- a/packages/dashmate/docs/config/drive-abci.md +++ b/packages/dashmate/docs/config/drive-abci.md @@ -124,6 +124,18 @@ These settings control developer and debugging tools: - Tokio Console: A debugging tool for Rust's async runtime - GroveDB Visualizer: A visualization tool for the GroveDB database structure +## State Sync Snapshots + +These settings control the serving side of state sync: Drive periodically takes GroveDB checkpoints and hands them to Tenderdash, which offers them to peers bootstrapping via state sync. The consuming side is configured on Tenderdash (see [Tenderdash configuration](./tenderdash.md#state-sync)). + +| Option | Description | Default | Example | +|--------|-------------|---------|---------| +| `platform.drive.abci.stateSync.snapshots.enabled` | Take and serve state sync snapshots | `true` | `false` | +| `platform.drive.abci.stateSync.snapshots.frequencySeconds` | How often to take a snapshot, in seconds, at least 60 | `600` | `3600` | +| `platform.drive.abci.stateSync.snapshots.maxCount` | Snapshots kept before pruning the oldest, at least 2 | `6` | `10` | + +Checkpoints are stored inside the Drive data volume under `db/checkpoints`. They hard-link unchanged data, so keeping several costs only a fraction of the database size. The local preset disables snapshots along with state sync consumption. + ## Other options | Option | Description | Default | Example | diff --git a/packages/dashmate/docs/config/tenderdash.md b/packages/dashmate/docs/config/tenderdash.md index ddb285243a4..38d22a0ec0b 100644 --- a/packages/dashmate/docs/config/tenderdash.md +++ b/packages/dashmate/docs/config/tenderdash.md @@ -80,6 +80,21 @@ The RPC interface is used for: - Submitting transactions - Fetching network status +## State Sync + +State sync bootstraps a fresh node from a recent state snapshot fetched from peers instead of replaying every block. These settings control the consuming side; serving snapshots to peers is always on in Tenderdash and is fed by Drive's snapshots (see [Drive ABCI configuration](./drive-abci.md#state-sync-snapshots)). + +| Option | Description | Default | Example | +|--------|-------------|---------|---------| +| `platform.drive.tenderdash.stateSync.enabled` | Bootstrap a fresh node from a snapshot | `true` | `false` | +| `platform.drive.tenderdash.stateSync.retries` | Retries before falling back to block sync, `0` disables retries | `3` | `5` | +| `platform.drive.tenderdash.stateSync.chunkRequestTimeout` | Timeout before re-requesting a snapshot chunk, at least `5s` | `15s` | `30s` | +| `platform.drive.tenderdash.stateSync.fetchersCount` | Concurrent chunk fetchers, 1 to 64 | `4` | `8` | + +- Enabling is safe for existing nodes: Tenderdash only attempts state sync when the node has no local state and disables it by itself otherwise. Only a freshly set up (or reset) node consumes a snapshot, and it ends up with a truncated block history starting at the snapshot height. +- Snapshots are verified through the P2P layer. The alternative RPC state provider needs at least two reachable Tenderdash RPC servers, but dashmate publishes the Tenderdash RPC on loopback only, without TLS and unproxied by the gateway, so the rendered config hardcodes `use-p2p = true`. +- The local preset disables state sync: a local network genesis starts every node from scratch, so there is no populated peer to sync from. + ## Metrics and Profiling These settings control monitoring and profiling tools: From e6ed7e23601466b40fc7cddecb846c8823f22723 Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 29 Aug 2026 00:46:08 +0200 Subject: [PATCH 08/12] style(dashmate): fix lint in new state sync code Replaces for-of loops over test fixtures with forEach to satisfy no-loop-func, and drops an unused catch binding. Co-Authored-By: Claude Fable 5 --- .../src/doctor/analyse/analyseSystemResourcesFactory.js | 2 +- .../dashmate/test/unit/config/stateSyncOptions.spec.js | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/dashmate/src/doctor/analyse/analyseSystemResourcesFactory.js b/packages/dashmate/src/doctor/analyse/analyseSystemResourcesFactory.js index aea5ad19788..ca66dfc6c12 100644 --- a/packages/dashmate/src/doctor/analyse/analyseSystemResourcesFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseSystemResourcesFactory.js @@ -26,7 +26,7 @@ export default function analyseSystemResourcesFactory(verifySystemRequirements) try { stateSyncSnapshotsEnabled = samples.getDashmateConfig() .get('platform.drive.abci.stateSync.snapshots.enabled') === true; - } catch (e) { + } catch { // A config collected by an older dashmate has no state sync options } diff --git a/packages/dashmate/test/unit/config/stateSyncOptions.spec.js b/packages/dashmate/test/unit/config/stateSyncOptions.spec.js index a92e34cf2e3..28b704253c5 100644 --- a/packages/dashmate/test/unit/config/stateSyncOptions.spec.js +++ b/packages/dashmate/test/unit/config/stateSyncOptions.spec.js @@ -65,14 +65,14 @@ describe('state sync options', () => { // Tenderdash 1.7 rejects a statesync chunk-request-timeout below 5 seconds. it('should reject a chunk request timeout below the 5s Tenderdash minimum', () => { - for (const valid of ['5s', '15s', '1.5m', '2h', '5000ms', '30000ms']) { + ['5s', '15s', '1.5m', '2h', '5000ms', '30000ms'].forEach((valid) => { config.set('platform.drive.tenderdash.stateSync.chunkRequestTimeout', valid); - } + }); - for (const invalid of ['0', '4s', '4.9s', '4999ms', '500ms', 'nonsense', 15]) { + ['0', '4s', '4.9s', '4999ms', '500ms', 'nonsense', 15].forEach((invalid) => { expect(() => config.set('platform.drive.tenderdash.stateSync.chunkRequestTimeout', invalid), String(invalid)) .to.throw(); - } + }); }); it('should reject a snapshot frequency below one minute', () => { From 428052cd74066a9dc7c073685196f4e6378170db Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 29 Aug 2026 01:20:27 +0200 Subject: [PATCH 09/12] fix(dashmate): review fixes for state sync doctor severity and duration pattern Judges disk problem severity against the base minimum so enabling snapshots widens when a problem is raised but never downgrades a HIGH shortage to MEDIUM. Accepts fractional minute and hour chunk request timeouts down to 0.1 (all at least 6s, above the 5s Tenderdash floor). Co-Authored-By: Claude Fable 5 --- .../dashmate/src/config/configJsonSchema.js | 4 +-- .../doctor/verifySystemRequirementsFactory.js | 8 +++--- .../test/unit/config/stateSyncOptions.spec.js | 4 +-- .../verifySystemRequirementsFactory.spec.js | 25 +++++++++++++++++++ 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/packages/dashmate/src/config/configJsonSchema.js b/packages/dashmate/src/config/configJsonSchema.js index 6397a4f6c33..0dd56b6cb2b 100644 --- a/packages/dashmate/src/config/configJsonSchema.js +++ b/packages/dashmate/src/config/configJsonSchema.js @@ -1387,8 +1387,8 @@ export default { }, { type: 'string', - // At least 5 seconds: 5s+, 5000ms+, or any whole number of minutes/hours - pattern: '^(([5-9]|[1-9][0-9]+)(\\.[0-9]+)?s|([5-9][0-9]{3}|[1-9][0-9]{4,})(\\.[0-9]+)?ms|[1-9][0-9]*(\\.[0-9]+)?[mh])$', + // At least 5 seconds: 5s+, 5000ms+, or minutes/hours down to 0.1 + pattern: '^(([5-9]|[1-9][0-9]+)(\\.[0-9]+)?s|([5-9][0-9]{3}|[1-9][0-9]{4,})(\\.[0-9]+)?ms|([1-9][0-9]*(\\.[0-9]+)?|0\\.[1-9][0-9]*)[mh])$', }, ], }, diff --git a/packages/dashmate/src/doctor/verifySystemRequirementsFactory.js b/packages/dashmate/src/doctor/verifySystemRequirementsFactory.js index e9a6779b583..c29991b698e 100644 --- a/packages/dashmate/src/doctor/verifySystemRequirementsFactory.js +++ b/packages/dashmate/src/doctor/verifySystemRequirementsFactory.js @@ -35,8 +35,8 @@ export default function verifySystemRequirementsFactory() { // State sync snapshots are GroveDB checkpoints stored next to the database. // They share unchanged data with it, so a small fixed headroom is enough. const SNAPSHOTS_DISK_HEADROOM = overrideRequirements.stateSyncSnapshotsEnabled ? 10 : 0; // GB - const MINIMUM_DISK_SPACE = (overrideRequirements.diskSpace ?? (isHP ? 200 : 100)) - + SNAPSHOTS_DISK_HEADROOM; // GB + const BASE_MINIMUM_DISK_SPACE = overrideRequirements.diskSpace ?? (isHP ? 200 : 100); // GB + const MINIMUM_DISK_SPACE = BASE_MINIMUM_DISK_SPACE + SNAPSHOTS_DISK_HEADROOM; // GB const problems = []; @@ -126,7 +126,9 @@ for required network services and avoid Proof-of-Service bans`, `${availableDiskSpace.toFixed(2)}GB of available disk space detected. At least ${MINIMUM_DISK_SPACE}GB is required${headroomNote}`, `Consider increasing disk space to make sure the node can provide timely responses for required network services and avoid Proof-of-Service bans`, - MINIMUM_DISK_SPACE - availableDiskSpace < 5 ? SEVERITY.HIGH : SEVERITY.MEDIUM, + // Judged against the base minimum so that enabling snapshots can + // widen when a problem is raised but never downgrade its severity + BASE_MINIMUM_DISK_SPACE - availableDiskSpace < 5 ? SEVERITY.HIGH : SEVERITY.MEDIUM, ); problems.push(problem); diff --git a/packages/dashmate/test/unit/config/stateSyncOptions.spec.js b/packages/dashmate/test/unit/config/stateSyncOptions.spec.js index 28b704253c5..b455106f294 100644 --- a/packages/dashmate/test/unit/config/stateSyncOptions.spec.js +++ b/packages/dashmate/test/unit/config/stateSyncOptions.spec.js @@ -65,11 +65,11 @@ describe('state sync options', () => { // Tenderdash 1.7 rejects a statesync chunk-request-timeout below 5 seconds. it('should reject a chunk request timeout below the 5s Tenderdash minimum', () => { - ['5s', '15s', '1.5m', '2h', '5000ms', '30000ms'].forEach((valid) => { + ['5s', '15s', '1.5m', '0.5m', '2h', '0.1h', '5000ms', '30000ms'].forEach((valid) => { config.set('platform.drive.tenderdash.stateSync.chunkRequestTimeout', valid); }); - ['0', '4s', '4.9s', '4999ms', '500ms', 'nonsense', 15].forEach((invalid) => { + ['0', '4s', '4.9s', '4999ms', '500ms', '0.05m', 'nonsense', 15].forEach((invalid) => { expect(() => config.set('platform.drive.tenderdash.stateSync.chunkRequestTimeout', invalid), String(invalid)) .to.throw(); }); diff --git a/packages/dashmate/test/unit/doctor/verifySystemRequirementsFactory.spec.js b/packages/dashmate/test/unit/doctor/verifySystemRequirementsFactory.spec.js index 53cccfb4f75..4f978ce530d 100644 --- a/packages/dashmate/test/unit/doctor/verifySystemRequirementsFactory.spec.js +++ b/packages/dashmate/test/unit/doctor/verifySystemRequirementsFactory.spec.js @@ -1,5 +1,6 @@ import verifySystemRequirementsFactory from '../../../src/doctor/verifySystemRequirementsFactory.js'; import Problem from '../../../src/doctor/Problem.js'; +import { SEVERITY } from '../../../src/doctor/Prescription.js'; describe('verifySystemRequirementsFactory', () => { let verifySystemRequirements; @@ -153,6 +154,30 @@ describe('verifySystemRequirementsFactory', () => { expect(problemsWithSnapshots[0].getDescription()) .to.include('At least 15GB is required (including 10GB headroom for state sync snapshots)'); }); + + it('should not downgrade severity when snapshot headroom widens the requirement', () => { + const systemInfo = { + diskSpace: { available: 2 * 1024 ** 3 }, + }; + + // 3GB short of the 5GB base minimum: HIGH + const problems = verifySystemRequirements(systemInfo, false, { + diskSpace: 5, + }); + + expect(problems).to.have.lengthOf(1); + expect(problems[0].getSeverity()).to.equal(SEVERITY.HIGH); + + // The 10GB headroom widens the deficit to 13GB, which must stay HIGH + // rather than fall over the 5GB near-threshold cutoff into MEDIUM + const problemsWithSnapshots = verifySystemRequirements(systemInfo, false, { + diskSpace: 5, + stateSyncSnapshotsEnabled: true, + }); + + expect(problemsWithSnapshots).to.have.lengthOf(1); + expect(problemsWithSnapshots[0].getSeverity()).to.equal(SEVERITY.HIGH); + }); }); it('should not return any problems if all requirements are met', () => { From 11a35824d376dfe6c7ee6d5d501ef4e9fd102ed0 Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 30 Aug 2026 23:28:54 +0200 Subject: [PATCH 10/12] docs(dashmate): correct state sync retries=0 semantics to unlimited Tenderdash's SyncAny only returns errNoSnapshots (the block sync fallback trigger) when retries > 0, so retries=0 repeats snapshot discovery indefinitely rather than disabling retries - Tenderdash's own sample config comment is wrong about this. Describe 0 as retry-indefinitely in the schema, rendered template, docs, and test. Also annotate the grpc-concurrency block: Tenderdash drops the concurrency map for the routed transport (NewRoutedClientWithAddr passes address and transport only), so the entries, including the pre-existing check_tx one, are declarative until that propagates. Co-Authored-By: Claude Fable 5 --- packages/dashmate/docs/config/tenderdash.md | 2 +- packages/dashmate/src/config/configJsonSchema.js | 2 +- .../platform/drive/tenderdash/config.toml.dot | 12 ++++++++++-- .../test/unit/config/stateSyncOptions.spec.js | 4 +++- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/dashmate/docs/config/tenderdash.md b/packages/dashmate/docs/config/tenderdash.md index 38d22a0ec0b..d41d3303978 100644 --- a/packages/dashmate/docs/config/tenderdash.md +++ b/packages/dashmate/docs/config/tenderdash.md @@ -87,7 +87,7 @@ State sync bootstraps a fresh node from a recent state snapshot fetched from pee | Option | Description | Default | Example | |--------|-------------|---------|---------| | `platform.drive.tenderdash.stateSync.enabled` | Bootstrap a fresh node from a snapshot | `true` | `false` | -| `platform.drive.tenderdash.stateSync.retries` | Retries before falling back to block sync, `0` disables retries | `3` | `5` | +| `platform.drive.tenderdash.stateSync.retries` | Retries before falling back to block sync, `0` retries indefinitely (never falls back) | `3` | `5` | | `platform.drive.tenderdash.stateSync.chunkRequestTimeout` | Timeout before re-requesting a snapshot chunk, at least `5s` | `15s` | `30s` | | `platform.drive.tenderdash.stateSync.fetchersCount` | Concurrent chunk fetchers, 1 to 64 | `4` | `8` | diff --git a/packages/dashmate/src/config/configJsonSchema.js b/packages/dashmate/src/config/configJsonSchema.js index 0dd56b6cb2b..69c8901f5ed 100644 --- a/packages/dashmate/src/config/configJsonSchema.js +++ b/packages/dashmate/src/config/configJsonSchema.js @@ -1377,7 +1377,7 @@ export default { type: 'integer', minimum: 0, description: 'How many times to retry state sync before falling back to block sync.' - + ' 0 disables retries', + + ' 0 means retry indefinitely and never fall back', }, chunkRequestTimeout: { description: 'Timeout before re-requesting a snapshot chunk. Tenderdash requires at least 5s', diff --git a/packages/dashmate/templates/platform/drive/tenderdash/config.toml.dot b/packages/dashmate/templates/platform/drive/tenderdash/config.toml.dot index 499d37e5925..088476e063a 100644 --- a/packages/dashmate/templates/platform/drive/tenderdash/config.toml.dot +++ b/packages/dashmate/templates/platform/drive/tenderdash/config.toml.dot @@ -99,6 +99,13 @@ grpc-concurrency = [ { "check_tx" = {{= it.platform.drive.tenderdash.mempool.maxConcurrentCheckTx }} }, # Snapshot serving: discovery is one request per peer, chunk downloads run # several concurrent fetchers per syncing peer. + # + # NOTE: Tenderdash currently applies grpc-concurrency only to a direct + # `transport = "grpc"` client; the routed transport used here drops the map + # when it builds the nested per-method clients (NewRoutedClientWithAddr + # passes address and transport only), so these limits — including the + # pre-existing check_tx one — are declarative until Tenderdash propagates + # them to routed clients. { "list_snapshots" = 10 }, { "load_snapshot_chunk" = 100 }, ] @@ -439,8 +446,9 @@ rpc-servers = "" discovery-time = "15s" # The number of times to retry state sync. When retries are exhausted, the node falls back -# to block sync. Set to 0 to disable retries. In the pessimistic case it takes at least -# discovery-time * retries before falling back. +# to block sync. 0 means retry indefinitely: the node keeps repeating snapshot discovery +# and never falls back. In the pessimistic case it takes at least discovery-time * retries +# before falling back. retries = {{= it.platform.drive.tenderdash.stateSync.retries }} # Temporary directory for state sync snapshot chunks, defaults to os.TempDir(). diff --git a/packages/dashmate/test/unit/config/stateSyncOptions.spec.js b/packages/dashmate/test/unit/config/stateSyncOptions.spec.js index b455106f294..e8af1899e89 100644 --- a/packages/dashmate/test/unit/config/stateSyncOptions.spec.js +++ b/packages/dashmate/test/unit/config/stateSyncOptions.spec.js @@ -46,7 +46,9 @@ describe('state sync options', () => { config = getBaseConfig(); }); - it('should accept retries of 0 (disable retries) but not negative', () => { + // Tenderdash treats 0 as an unlimited retry count: SyncAny only returns + // errNoSnapshots (the block sync fallback trigger) when retries > 0. + it('should accept retries of 0 (retry indefinitely) but not negative', () => { config.set('platform.drive.tenderdash.stateSync.retries', 0); expect(() => config.set('platform.drive.tenderdash.stateSync.retries', -1)) From 781b8f4ffd4a34e8871513c42dab76e71e9c709b Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 31 Aug 2026 12:04:55 +0200 Subject: [PATCH 11/12] fix(dashmate): only add snapshot disk headroom when Platform is enabled Fullnode and masternode setup disables Platform but keeps the base snapshot default of true, so doctor added 10GB of disk headroom for checkpoints that Drive never creates. Gate the headroom on platform.enable. Co-Authored-By: Claude Fable 5 --- .../analyse/analyseSystemResourcesFactory.js | 8 ++- .../analyseSystemResourcesFactory.spec.js | 60 +++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 packages/dashmate/test/unit/doctor/analyse/analyseSystemResourcesFactory.spec.js diff --git a/packages/dashmate/src/doctor/analyse/analyseSystemResourcesFactory.js b/packages/dashmate/src/doctor/analyse/analyseSystemResourcesFactory.js index ca66dfc6c12..8f4ddb5aa4b 100644 --- a/packages/dashmate/src/doctor/analyse/analyseSystemResourcesFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseSystemResourcesFactory.js @@ -24,8 +24,12 @@ export default function analyseSystemResourcesFactory(verifySystemRequirements) let stateSyncSnapshotsEnabled = false; try { - stateSyncSnapshotsEnabled = samples.getDashmateConfig() - .get('platform.drive.abci.stateSync.snapshots.enabled') === true; + const config = samples.getDashmateConfig(); + + // Gate on Platform being enabled: a Core-only node keeps the base + // snapshot default of true, but Drive isn't running to create them + stateSyncSnapshotsEnabled = config.get('platform.enable') === true + && config.get('platform.drive.abci.stateSync.snapshots.enabled') === true; } catch { // A config collected by an older dashmate has no state sync options } diff --git a/packages/dashmate/test/unit/doctor/analyse/analyseSystemResourcesFactory.spec.js b/packages/dashmate/test/unit/doctor/analyse/analyseSystemResourcesFactory.spec.js new file mode 100644 index 00000000000..25922b6b195 --- /dev/null +++ b/packages/dashmate/test/unit/doctor/analyse/analyseSystemResourcesFactory.spec.js @@ -0,0 +1,60 @@ +import getBaseConfigFactory from '../../../../configs/defaults/getBaseConfigFactory.js'; +import analyseSystemResourcesFactory from '../../../../src/doctor/analyse/analyseSystemResourcesFactory.js'; +import verifySystemRequirementsFactory from '../../../../src/doctor/verifySystemRequirementsFactory.js'; +import Samples from '../../../../src/doctor/Samples.js'; + +describe('analyseSystemResourcesFactory', () => { + let analyseSystemResources; + let config; + let samples; + + beforeEach(() => { + config = getBaseConfigFactory()(); + + samples = new Samples(); + samples.setDashmateConfig(config); + + // 12GB clears the doctor's 5GB base disk requirement on its own, + // but not with the 10GB snapshot headroom on top + samples.setSystemInfo({ + diskSpace: { available: 12 * 1024 ** 3 }, + }); + + analyseSystemResources = analyseSystemResourcesFactory( + verifySystemRequirementsFactory(), + ); + }); + + describe('state sync snapshot disk headroom', () => { + it('should apply headroom when Platform and snapshots are enabled', () => { + config.set('platform.enable', true); + config.set('platform.drive.abci.stateSync.snapshots.enabled', true); + + const problems = analyseSystemResources(samples); + + expect(problems).to.have.lengthOf(1); + expect(problems[0].getDescription()) + .to.include('At least 15GB is required (including 10GB headroom for state sync snapshots)'); + }); + + it('should not apply headroom when Platform is disabled', () => { + // A fullnode/masternode setup disables Platform but keeps the base + // snapshot default of true; Drive isn't running to create snapshots + config.set('platform.enable', false); + config.set('platform.drive.abci.stateSync.snapshots.enabled', true); + + const problems = analyseSystemResources(samples); + + expect(problems).to.be.empty(); + }); + + it('should not apply headroom when snapshots are disabled', () => { + config.set('platform.enable', true); + config.set('platform.drive.abci.stateSync.snapshots.enabled', false); + + const problems = analyseSystemResources(samples); + + expect(problems).to.be.empty(); + }); + }); +}); From b0db7235f2cc98bb4d68f983ca237ac9b5a1e3dd Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 31 Aug 2026 12:05:06 +0200 Subject: [PATCH 12/12] test(dashmate): pin conservative fractional duration floor for chunk timeout The 0.1 m/h floor rejects a sliver of valid durations (0.09m = 5.4s) because 5s is a non-terminating decimal in minutes and no regex can hit the boundary exactly. Document the intent and pin it with boundary tests; s and ms spellings express any duration exactly. Co-Authored-By: Claude Fable 5 --- packages/dashmate/src/config/configJsonSchema.js | 4 +++- .../test/unit/config/stateSyncOptions.spec.js | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/dashmate/src/config/configJsonSchema.js b/packages/dashmate/src/config/configJsonSchema.js index 69c8901f5ed..f6280fa6f7b 100644 --- a/packages/dashmate/src/config/configJsonSchema.js +++ b/packages/dashmate/src/config/configJsonSchema.js @@ -1387,7 +1387,9 @@ export default { }, { type: 'string', - // At least 5 seconds: 5s+, 5000ms+, or minutes/hours down to 0.1 + // At least 5 seconds: 5s+, 5000ms+, or minutes/hours down to 0.1. + // The m/h floor is conservative (5s = 0.0833...m has no exact regex + // boundary); durations below it must be spelled in s or ms instead pattern: '^(([5-9]|[1-9][0-9]+)(\\.[0-9]+)?s|([5-9][0-9]{3}|[1-9][0-9]{4,})(\\.[0-9]+)?ms|([1-9][0-9]*(\\.[0-9]+)?|0\\.[1-9][0-9]*)[mh])$', }, ], diff --git a/packages/dashmate/test/unit/config/stateSyncOptions.spec.js b/packages/dashmate/test/unit/config/stateSyncOptions.spec.js index e8af1899e89..2f4be19d01e 100644 --- a/packages/dashmate/test/unit/config/stateSyncOptions.spec.js +++ b/packages/dashmate/test/unit/config/stateSyncOptions.spec.js @@ -77,6 +77,21 @@ describe('state sync options', () => { }); }); + // The fractional minute/hour floor is 0.1, which is conservative: 5s is a + // non-terminating decimal in minutes (0.0833...m), so a regex can't hit the + // boundary exactly. Durations between 5s and the floor (e.g. 0.09m = 5.4s, + // 0.01h = 36s) must be spelled in s or ms, which express any duration exactly. + it('should accept fractional minutes and hours down to 0.1 only', () => { + ['0.1m', '0.15m', '0.1h'].forEach((valid) => { + config.set('platform.drive.tenderdash.stateSync.chunkRequestTimeout', valid); + }); + + ['0.09m', '0.01h', '0.099m'].forEach((invalid) => { + expect(() => config.set('platform.drive.tenderdash.stateSync.chunkRequestTimeout', invalid), invalid) + .to.throw(); + }); + }); + it('should reject a snapshot frequency below one minute', () => { config.set('platform.drive.abci.stateSync.snapshots.frequencySeconds', 60);