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/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/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 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..d41d3303978 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` 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` | + +- 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: diff --git a/packages/dashmate/src/config/configJsonSchema.js b/packages/dashmate/src/config/configJsonSchema.js index 263c1e4432d..f6280fa6f7b 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,47 @@ 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 means retry indefinitely and never fall back', + }, + 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 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])$', + }, + ], + }, + 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, }, }, diff --git a/packages/dashmate/src/doctor/analyse/analyseSystemResourcesFactory.js b/packages/dashmate/src/doctor/analyse/analyseSystemResourcesFactory.js index a5728a4b413..8f4ddb5aa4b 100644 --- a/packages/dashmate/src/doctor/analyse/analyseSystemResourcesFactory.js +++ b/packages/dashmate/src/doctor/analyse/analyseSystemResourcesFactory.js @@ -22,6 +22,18 @@ export default function analyseSystemResourcesFactory(verifySystemRequirements) diskIO, } = samples.getSystemInfo(); + let stateSyncSnapshotsEnabled = false; + try { + 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 + } + // System requirements const problems = verifySystemRequirements( { @@ -33,6 +45,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..c29991b698e 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 BASE_MINIMUM_DISK_SPACE = overrideRequirements.diskSpace ?? (isHP ? 200 : 100); // GB + const MINIMUM_DISK_SPACE = BASE_MINIMUM_DISK_SPACE + SNAPSHOTS_DISK_HEADROOM; // GB const problems = []; @@ -112,11 +118,17 @@ 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, + // 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/templates/platform/drive/tenderdash/config.toml.dot b/packages/dashmate/templates/platform/drive/tenderdash/config.toml.dot index 220f84df843..088476e063a 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,17 @@ 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. + # + # 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 }, ] @@ -418,29 +429,28 @@ 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. 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(). # The synchronizer will create a new, randomly named directory within this directory # and remove it when the sync is complete. @@ -448,10 +458,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/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. 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..2f4be19d01e --- /dev/null +++ b/packages/dashmate/test/unit/config/stateSyncOptions.spec.js @@ -0,0 +1,116 @@ +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(); + }); + + // 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)) + .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', () => { + ['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', '0.05m', 'nonsense', 15].forEach((invalid) => { + expect(() => config.set('platform.drive.tenderdash.stateSync.chunkRequestTimeout', invalid), String(invalid)) + .to.throw(); + }); + }); + + // 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); + + 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(); + }); + }); +}); 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(); + }); + }); +}); diff --git a/packages/dashmate/test/unit/doctor/verifySystemRequirementsFactory.spec.js b/packages/dashmate/test/unit/doctor/verifySystemRequirementsFactory.spec.js index 38590b3fbdc..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; @@ -130,6 +131,53 @@ 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 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', () => { 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 }'); + }); +});