Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion config/common.json.example
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<site-btc-address>" }
Expand Down
2 changes: 1 addition & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<id>.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.<id>.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.<id>.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`).
Expand Down
21 changes: 21 additions & 0 deletions tests/unit/handlers/energy.handlers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
6 changes: 3 additions & 3 deletions tests/unit/handlers/finance.handlers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' }] }
},
{
Expand All @@ -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
Expand Down Expand Up @@ -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 } } }
Expand Down
22 changes: 11 additions & 11 deletions tests/unit/handlers/metrics.handlers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 },
Expand All @@ -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' } })
Expand Down Expand Up @@ -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 },
Expand All @@ -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 },
Expand Down
26 changes: 25 additions & 1 deletion tests/unit/lib/cachedRoute.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand Down Expand Up @@ -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')
})
9 changes: 7 additions & 2 deletions workers/lib/server/handlers/energy.handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 }
}

Expand Down
39 changes: 34 additions & 5 deletions workers/lib/server/lib/cachedRoute.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(':')
Expand Down Expand Up @@ -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)
Expand All @@ -68,4 +97,4 @@ function sweepExpired (lruFac) {
return before - cache.length
}

module.exports = { cachedRoute, sweepExpired }
module.exports = { cachedRoute, clearCachedRoute, sweepExpired }
Loading