From b5943aad0900300c51f7f33312629fd2f1d4c48a Mon Sep 17 00:00:00 2001 From: Shrutesh Pachineela Date: Fri, 16 Oct 2026 17:17:55 +0530 Subject: [PATCH 1/2] change phase 1 consumption to site pm - acme consumption --- config/common.json.example | 2 +- docs/API.md | 2 +- tests/unit/handlers/finance.handlers.test.js | 6 +++--- tests/unit/handlers/metrics.handlers.test.js | 22 ++++++++++---------- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/config/common.json.example b/config/common.json.example index 4559fbe..318e252 100644 --- a/config/common.json.example +++ b/config/common.json.example @@ -65,7 +65,7 @@ "id": "phase1", "label": "Phase 1", "minerTelemetry": true, - "consumption": { "source": "remainder", "meterKey": "ccm" }, + "consumption": { "source": "remainder", "meterKey": "site-pm" }, "pool": { "accounts": [ { "poolType": "ocean", "username": "" } diff --git a/docs/API.md b/docs/API.md index 5bb9f8a..7cd9f34 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1243,7 +1243,7 @@ With phases configured: - A phase with `minerTelemetry: false` has no MOS miners: its hashrate is bucketed from its own pool accounts' `hashrate-history` samples, `nominal=true` reads the ork global config's `phaseNominals..nominalHashrate_MHS` (fields are omitted when unset), and `groupBy`/`racks`/`container` are rejected for it. Its efficiency divides its consumption series by its pool hashrate, and revenue-summary divides uptime by `phaseNominals..nominalPower_MW` (`400 ERR_PHASE_NOMINAL_MISSING` when absent). - Grouped hashrate (`groupBy=miner|container`) additionally carries each pool-only phase's synthetic group key from `groups` (e.g. `HBM` / `acme-container`), filled from that phase's pool hashrate. - The site summary's efficiency and the high-site-efficiency alert divide by miner hashrate plus the pool-only phases' live pool hashrate, since those phases consume inside the site meter without reporting telemetry. `GET /auth/metrics/efficiency` without a phase (Central-DCS sites) likewise divides the Site PM reading (`site_power_w`) by miner hashrate plus the pool-only phases' hourly pool hashrate. `GET /auth/site/efficiency`'s `site_efficiency` does the same with the live pool stats; its `mining_efficiency` stays rack meters over the miners they feed. -- `GET /auth/metrics/consumption?phase=` serves the phase's own series, built hourly and rolled up to `interval` like the site series (Central-DCS sites only; `500 ERR_PHASE_CONSUMPTION_REQUIRES_CENTRAL_DCS` otherwise). `consumption.source: "manual"` reads `totalConsumptionMWh` from the DCS worker's hourly consumption log (`/auth/energy/consumption`); `consumption.source: "remainder"` is the DCS meter `consumption.meterKey` (a `by_meter_power_w` key, matched case- and punctuation-insensitively) minus those manual entries. A phase with neither (or a remainder phase without `meterKey`) fails with `ERR_PHASE_CONSUMPTION_UNCONFIGURED`. An elapsed hour missing either input is left out of the series, not zero-filled, and counted in `summary.missingHours`. `groupBy`, `byMeter` and `racks` are rejected with a phase (`400 ERR_PHASE_NOT_SUPPORTED`). +- `GET /auth/metrics/consumption?phase=` serves the phase's own series, built hourly and rolled up to `interval` like the site series (Central-DCS sites only; `500 ERR_PHASE_CONSUMPTION_REQUIRES_CENTRAL_DCS` otherwise). `consumption.source: "manual"` reads `totalConsumptionMWh` from the DCS worker's hourly consumption log (`/auth/energy/consumption`); `consumption.source: "remainder"` is the DCS meter `consumption.meterKey` (a `by_meter_power_w` key, matched case- and punctuation-insensitively) minus those manual entries. The remainder phase's `meterKey` must be the site meter (`site-pm`), so that remainder + manual adds up to the site: Phase 1 = Site PM − ACME. A phase with neither (or a remainder phase without `meterKey`) fails with `ERR_PHASE_CONSUMPTION_UNCONFIGURED`. An elapsed hour missing either input is left out of the series, not zero-filled, and counted in `summary.missingHours`. `groupBy`, `byMeter` and `racks` are rejected with a phase (`400 ERR_PHASE_NOT_SUPPORTED`). - `GET /auth/metrics/efficiency?phase=` for a telemetry phase (e.g. Phase 1) divides that phase's consumption series (its meter minus the manual entries) by the fleet's miner hashrate, hour by hour, then averages per bucket like the site series; hours missing either input are counted in `summary.missingHours`. `groupBy`/`racks` keep serving the miner-reported per-group efficiency. - `GET /auth/finance/revenue-summary?phase=` scopes earnings (transaction rows are filtered by the accounts' `username` tags), hashrate and consumption (the phase series above, so `powerUtilization` and `downtimeMWh` are against the phase nominal); rebates stay on the unscoped view, and a manual-source phase carries no Cost-Input opex. Under a phase, energy cost is always the phase's consumption at the month's LCOE: a production-costs month that stores an explicit energy cost (the site's whole bill) keeps it only on the site view. - Capex for amortization follows the same split. `costParameters.capexUsd` is the site build-out (Phase 1); a manual-source phase stores its own under `costParameters.phases..capexUsd`, saved via `POST /auth/global/data?type=costParameters` (a site-defaults save without `phases` keeps the stored map; `phases` inside a `year`/`month` override is `ERR_INVALID_PHASES`). Revenue-summary's `summary.capexUsd` resolves it for the requested scope: a phase with a meter (e.g. Phase 1) gets `capexUsd`, a manual-source phase gets its own figure, and the site view (`total`/no phase) sums `capexUsd` and every manual-source phase's. `summary.capexMissingPhases` lists the manual-source phases in scope with no figure entered yet; they are left out of the sum rather than counted as zero, and `capexUsd` is `null` when nothing in scope is entered. All phases share `commissionedAt`. Like the Cost Input opex and LCOE, a capex edit reaches cached revenue-summary responses only after the route TTL (or with `overwriteCache`). diff --git a/tests/unit/handlers/finance.handlers.test.js b/tests/unit/handlers/finance.handlers.test.js index 10de204..63c5672 100644 --- a/tests/unit/handlers/finance.handlers.test.js +++ b/tests/unit/handlers/finance.handlers.test.js @@ -1827,7 +1827,7 @@ test('getRevenueSummary - phase costs and capex follow the phase build-out', asy { id: 'phase1', minerTelemetry: true, - consumption: { source: 'remainder', meterKey: 'ccm' }, + consumption: { source: 'remainder', meterKey: 'site-pm' }, pool: { accounts: [{ poolType: 'ocean', username: 'site-addr' }] } }, { @@ -1850,7 +1850,7 @@ test('getRevenueSummary - phase costs and capex follow the phase build-out', asy return Array.from({ length: 24 }, (_, h) => ({ ts: dayTs + h * 3600000, totalConsumptionMWh: 0.6 })) } if (method === 'tailLog') { - return [{ ts: dayTs, site_power_w: 10_000_000, by_meter_power_w: { ccm: 10_000_000 }, hashrate_mhs_5m_sum_aggr: 1e9 }] + return [{ ts: dayTs, site_power_w: 10_000_000, by_meter_power_w: { 'site-pm': 10_000_000 }, hashrate_mhs_5m_sum_aggr: 1e9 }] } if (method === 'getWrkExtData') { const key = payload.query && payload.query.key @@ -1896,7 +1896,7 @@ test('getRevenueSummary - phase costs and capex follow the phase build-out', asy test('resolveCapex - splits capex along the phase build-outs', (t) => { const phases = [ - { id: 'phase1', consumption: { source: 'remainder', meterKey: 'ccm' } }, + { id: 'phase1', consumption: { source: 'remainder', meterKey: 'site-pm' } }, { id: 'phase1_5', consumption: { source: 'manual' } } ] const ctx = { conf: { featureConfig: { phases } } } diff --git a/tests/unit/handlers/metrics.handlers.test.js b/tests/unit/handlers/metrics.handlers.test.js index 3ce4a79..3252d45 100644 --- a/tests/unit/handlers/metrics.handlers.test.js +++ b/tests/unit/handlers/metrics.handlers.test.js @@ -5974,7 +5974,7 @@ test('getPoolHashrate - phase filters stats to the phase accounts', async (t) => const PHASE_DCS_FEATURE = { ...PHASES_FEATURE, centralDCSSetup: { enabled: true }, - phases: PHASES_FEATURE.phases.map(p => p.id === 'phase1' ? { ...p, consumption: { source: 'remainder', meterKey: 'CCM' } } : p) + phases: PHASES_FEATURE.phases.map(p => p.id === 'phase1' ? { ...p, consumption: { source: 'remainder', meterKey: 'Site-PM' } } : p) } // Serves the DCS meter rollup and the manual consumption log for the phase tests. @@ -6001,13 +6001,13 @@ test('getEfficiency - pool-only phase divides its manual consumption by its pool t.is(res.summary.missingHours, 0) }) -test('getEfficiency - telemetry phase divides ccm minus manual by the fleet hashrate', async (t) => { +test('getEfficiency - telemetry phase divides site PM minus manual by the fleet hashrate', async (t) => { const H = PHASE_HOUR_MS const ctx = phaseConsumptionCtx({ meter: [ - { ts: PHASE_START, by_meter_power_w: { ccm: 5000000, 'site-pm': 9000000 } }, - { ts: PHASE_START + H, by_meter_power_w: { ccm: 4000000 } }, - { ts: PHASE_START + 2 * H, by_meter_power_w: { ccm: 4000000 } } + { ts: PHASE_START, by_meter_power_w: { ccm: 9000000, 'site-pm': 5000000 } }, + { ts: PHASE_START + H, by_meter_power_w: { 'site-pm': 4000000 } }, + { ts: PHASE_START + 2 * H, by_meter_power_w: { 'site-pm': 4000000 } } ], manual: [ { ts: PHASE_START, totalConsumptionMWh: 1 }, @@ -6023,7 +6023,7 @@ test('getEfficiency - telemetry phase divides ccm minus manual by the fleet hash const query = { start: PHASE_START, end: PHASE_START + 3 * H - 1, phase: 'phase1' } const hourly = await getEfficiency(ctx, { query: { ...query, interval: '1h' } }) - t.alike(hourly.log.map(e => e.efficiencyWThs), [4000000 / 100, 3000000 / 200], '(ccm - manual) W over fleet TH/s') + t.alike(hourly.log.map(e => e.efficiencyWThs), [4000000 / 100, 3000000 / 200], '(site PM - manual) W over fleet TH/s') t.is(hourly.summary.missingHours, 1, 'the hour without hashrate is surfaced') const daily = await getEfficiency(ctx, { query: { ...query, interval: '1d' } }) @@ -6086,8 +6086,8 @@ test('getConsumption - manual phase reads the logged hours and rolls them up', a test('getConsumption - remainder phase is its meter minus the manual hours', async (t) => { const ctx = phaseConsumptionCtx({ meter: [ - { ts: PHASE_START, by_meter_power_w: { ccm: 5000000, 'site-pm': 9000000 } }, - { ts: PHASE_START + PHASE_HOUR_MS, by_meter_power_w: { ccm: 4000000 } } + { ts: PHASE_START, by_meter_power_w: { ccm: 9000000, 'site-pm': 5000000 } }, + { ts: PHASE_START + PHASE_HOUR_MS, by_meter_power_w: { 'site-pm': 4000000 } } ], manual: [ { ts: PHASE_START, totalConsumptionMWh: 1.5 }, @@ -6099,15 +6099,15 @@ test('getConsumption - remainder phase is its meter minus the manual hours', asy query: { start: PHASE_START, end: PHASE_START + 2 * PHASE_HOUR_MS - 1, interval: '1h', phase: 'phase1' } }) - t.alike(res.log.map(e => e.consumptionMWh), [3.5, 3], 'configured CCM matches the ccm meter key') + t.alike(res.log.map(e => e.consumptionMWh), [3.5, 3], 'configured Site-PM matches the site-pm meter key, not ccm') t.is(res.summary.totalConsumptionMWh, 6.5) }) test('getConsumption - an hour without a manual entry is missing, not zero', async (t) => { const ctx = phaseConsumptionCtx({ meter: [ - { ts: PHASE_START, by_meter_power_w: { ccm: 5000000 } }, - { ts: PHASE_START + PHASE_HOUR_MS, by_meter_power_w: { ccm: 5000000 } } + { ts: PHASE_START, by_meter_power_w: { 'site-pm': 5000000 } }, + { ts: PHASE_START + PHASE_HOUR_MS, by_meter_power_w: { 'site-pm': 5000000 } } ], manual: [ { ts: PHASE_START, totalConsumptionMWh: 1 }, From 8c73df68d64ec1eb7a44e9013ce3be6fa7045106 Mon Sep 17 00:00:00 2001 From: Shrutesh Pachineela Date: Fri, 16 Oct 2026 17:33:13 +0530 Subject: [PATCH 2/2] Clear the metrics/consumption cache in saveEnergyConsumption --- tests/unit/handlers/energy.handlers.test.js | 21 ++++++++++ tests/unit/lib/cachedRoute.test.js | 26 ++++++++++++- .../lib/server/handlers/energy.handlers.js | 9 ++++- workers/lib/server/lib/cachedRoute.js | 39 ++++++++++++++++--- 4 files changed, 87 insertions(+), 8 deletions(-) diff --git a/tests/unit/handlers/energy.handlers.test.js b/tests/unit/handlers/energy.handlers.test.js index dbb8d7b..c089cfc 100644 --- a/tests/unit/handlers/energy.handlers.test.js +++ b/tests/unit/handlers/energy.handlers.test.js @@ -360,6 +360,27 @@ test('saveEnergyConsumption - sends only the entries to the DCS worker, aligned }) }) +test('saveEnergyConsumption - clears the cached consumption metrics only after a confirmed write', async (t) => { + const LRU = require('lru') + const cache = new LRU({ max: 100 }) + const okCtx = consumptionCtx({ jRequest: async (key, method, payload) => [{ upserted: payload.entries.length }] }) + okCtx.lru_30s = { cache } + okCtx.conf.cacheTiming = {} + cache.set('metrics/consumption:phase-1:1:2:-:-:-:-', { stale: true }) + cache.set('metrics/efficiency:phase-1', 1) + + await saveEnergyConsumption(okCtx, saveReq([consEntry(CONS_H0)])) + + t.alike(cache.keys, ['metrics/efficiency:phase-1'], 'consumption entries dropped, other routes kept') + + const failCtx = consumptionCtx({ jRequest: async () => [] }) + failCtx.lru_30s = { cache } + failCtx.conf.cacheTiming = {} + cache.set('metrics/consumption:phase-1:1:2:-:-:-:-', { stale: true }) + await t.exception(() => saveEnergyConsumption(failCtx, saveReq([consEntry(CONS_H0)])), /ERR_CONSUMPTION_SAVE_FAILED/) + t.ok(cache.peek('metrics/consumption:phase-1:1:2:-:-:-:-'), 'kept when nothing was written') +}) + test('saveEnergyConsumption - forwards partial entries, zeros and nulls untouched', async (t) => { let captured = null const ctx = consumptionCtx({ diff --git a/tests/unit/lib/cachedRoute.test.js b/tests/unit/lib/cachedRoute.test.js index dab0d3d..c13947c 100644 --- a/tests/unit/lib/cachedRoute.test.js +++ b/tests/unit/lib/cachedRoute.test.js @@ -2,7 +2,7 @@ const test = require('brittle') const LRU = require('lru') -const { cachedRoute, sweepExpired } = require('../../../workers/lib/server/lib/cachedRoute') +const { cachedRoute, clearCachedRoute, sweepExpired } = require('../../../workers/lib/server/lib/cachedRoute') test('cachedRoute - returns cached value', async (t) => { const cachedValue = { data: 'cached' } @@ -271,3 +271,27 @@ test('sweepExpired - tolerates a stopped facility', (t) => { t.is(sweepExpired({}), 0) t.is(sweepExpired(undefined), 0) }) + +test('clearCachedRoute - removes every key of the route in its bucket and nothing else', (t) => { + const cache = new LRU({ max: 100 }) + cache.set('metrics/consumption:phase-1:1:2:-:-:-:-', 1) + cache.set('metrics/consumption:-:3:4:1h:-:-:-', 2) + cache.set('metrics/consumption-other:x', 3) + cache.set('metrics/efficiency:phase-1', 4) + const ctx = { conf: { cacheTiming: {} }, lru_30s: { cache } } + + const removed = clearCachedRoute(ctx, '/auth/metrics/consumption', 'metrics/consumption') + + t.is(removed, 2) + t.alike(cache.keys.sort(), ['metrics/consumption-other:x', 'metrics/efficiency:phase-1']) +}) + +test('clearCachedRoute - uses the configured bucket and tolerates a missing one', (t) => { + const cache = new LRU({ max: 100 }) + cache.set('route:a', 1) + const ctx = { conf: { cacheTiming: { '/route': '1m' } }, lru_1m: { cache } } + + t.is(clearCachedRoute(ctx, '/route', 'route'), 1) + t.is(cache.keys.length, 0) + t.is(clearCachedRoute({ conf: { cacheTiming: {} } }, '/route', 'route'), 0, 'no bucket, no throw') +}) diff --git a/workers/lib/server/handlers/energy.handlers.js b/workers/lib/server/handlers/energy.handlers.js index 26b5936..c8da9f6 100644 --- a/workers/lib/server/handlers/energy.handlers.js +++ b/workers/lib/server/handlers/energy.handlers.js @@ -8,8 +8,10 @@ const { LOG_FIELDS, AGGR_FIELDS, HISTORICAL_LOG_TYPES, - LOCKED_TIMEZONE_DEFAULT + LOCKED_TIMEZONE_DEFAULT, + ENDPOINTS } = require('../../constants') +const { clearCachedRoute } = require('../lib/cachedRoute') const { isCentralDCSEnabled, getDCSTag } = require('../../dcs.utils') const { getIntervalConfig, parseEntryTs, assertTimezone } = require('../../metrics.utils') const { isLocalHourStart } = require('../../period.utils') @@ -216,7 +218,8 @@ const getEnergyConsumption = async (ctx, req) => { // it passes (zeros included) and null clears one (it reads back as null), so a partial entry never wipes // the rest of the hour. Validated here first: the ork drops rack errors (a rejected // write comes back as an empty result), so success is only reported when a rack -// confirms the write. +// confirms the write. The consumption metrics read the manual phase groups from this +// log, so their cached responses are dropped once the write lands. const saveEnergyConsumption = async (ctx, req) => { const { entries } = req.body const timezone = getSiteTimezone(ctx) @@ -244,6 +247,8 @@ const saveEnergyConsumption = async (ctx, req) => { } if (!written) throw new Error('ERR_CONSUMPTION_SAVE_FAILED') + clearCachedRoute(ctx, ENDPOINTS.METRICS_CONSUMPTION, 'metrics/consumption') + return { success: true, upserted: entries.length } } diff --git a/workers/lib/server/lib/cachedRoute.js b/workers/lib/server/lib/cachedRoute.js index 866057d..592ff82 100644 --- a/workers/lib/server/lib/cachedRoute.js +++ b/workers/lib/server/lib/cachedRoute.js @@ -8,11 +8,15 @@ function dequeueRequests (requests, key, data, err = null) { requests.delete(key) } +// Fall back to the default bucket when the configured one does not exist +// (e.g. config referencing a bucket the running build does not ship yet), +// so a config/code deploy-order mismatch degrades to 30s caching, not 500s +function getRouteLru (ctx, apiPath) { + return ctx[`lru_${ctx.conf?.cacheTiming?.[apiPath] || '30s'}`] || ctx.lru_30s +} + async function cachedRoute (ctx, ckeyParts, apiPath, func, overwriteCache = false) { - // Fall back to the default bucket when the configured one does not exist - // (e.g. config referencing a bucket the running build does not ship yet), - // so a config/code deploy-order mismatch degrades to 30s caching, not 500s - const lru = ctx[`lru_${ctx.conf.cacheTiming[apiPath] || '30s'}`] || ctx.lru_30s + const lru = getRouteLru(ctx, apiPath) if (!lru) throw new Error('INTERNAL_SERVER_ERROR') const ckey = ckeyParts.map(k => k ?? '-').join(':') @@ -50,6 +54,31 @@ async function cachedRoute (ctx, ckeyParts, apiPath, func, overwriteCache = fals return data } +/** + * Drops every cached response of a route, whatever its query, after a write + * changes the data it reads. Keys are the route's ckeyParts joined by ':', so + * `prefix` is the first part the route passes (e.g. 'metrics/consumption'). + * + * @param {Object} ctx - worker context holding the lru buckets + * @param {string} apiPath - the route's endpoint, which picks its bucket + * @param {string} prefix - first cache key part of the route + * @returns {number} number of entries removed + */ +function clearCachedRoute (ctx, apiPath, prefix) { + const cache = getRouteLru(ctx, apiPath)?.cache + if (!cache) return 0 + + const keyPrefix = `${prefix}:` + let removed = 0 + for (const key of cache.keys) { + if (key.startsWith(keyPrefix)) { + cache.remove(key) + removed++ + } + } + return removed +} + /** * The lru package only expires an entry when that exact key is read again, so * entries whose key is never repeated (e.g. tail-log keyed on a moving start/end) @@ -68,4 +97,4 @@ function sweepExpired (lruFac) { return before - cache.length } -module.exports = { cachedRoute, sweepExpired } +module.exports = { cachedRoute, clearCachedRoute, sweepExpired }