@tetherto/mdk-core 0.0.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +572 -0
  3. package/index.js +519 -0
  4. package/lib/address-policy.js +124 -0
  5. package/lib/local-discovery.js +321 -0
  6. package/lib/services/actions.service.js +68 -0
  7. package/lib/services/alerts.service.js +207 -0
  8. package/lib/services/comments.service.js +118 -0
  9. package/lib/services/log-history.service.js +307 -0
  10. package/lib/services/logs.service.js +249 -0
  11. package/lib/services/pool-utils/constants.js +20 -0
  12. package/lib/services/pool-utils/time.js +49 -0
  13. package/lib/services/pool.service.js +397 -0
  14. package/lib/services/provisioning.service.js +420 -0
  15. package/lib/services/settings.service.js +26 -0
  16. package/lib/services/snaps.service.js +181 -0
  17. package/lib/services/stats.service.js +205 -0
  18. package/lib/stats/README.md +80 -0
  19. package/lib/stats/index.js +57 -0
  20. package/lib/stats/ops/alerts_aggr.js +40 -0
  21. package/lib/stats/ops/alerts_group_cnt.js +43 -0
  22. package/lib/stats/ops/arr_concat.js +27 -0
  23. package/lib/stats/ops/array_obj_calc.js +41 -0
  24. package/lib/stats/ops/avg.js +42 -0
  25. package/lib/stats/ops/cnt.js +19 -0
  26. package/lib/stats/ops/group.js +38 -0
  27. package/lib/stats/ops/group_avg.js +53 -0
  28. package/lib/stats/ops/group_cnt.js +38 -0
  29. package/lib/stats/ops/group_max.js +40 -0
  30. package/lib/stats/ops/group_multiple_stats.js +40 -0
  31. package/lib/stats/ops/group_sum.js +40 -0
  32. package/lib/stats/ops/index.js +35 -0
  33. package/lib/stats/ops/nested_obj_concat.js +54 -0
  34. package/lib/stats/ops/obj_concat.js +33 -0
  35. package/lib/stats/ops/sum.js +27 -0
  36. package/lib/stats/utils.js +99 -0
  37. package/lib/templates/alerts.js +128 -0
  38. package/lib/templates/stats.js +476 -0
  39. package/lib/things/constants.js +65 -0
  40. package/lib/things/container.js +84 -0
  41. package/lib/things/miner.js +190 -0
  42. package/lib/things/powermeter.js +30 -0
  43. package/lib/things/sensor.js +18 -0
  44. package/lib/things/thing.js +93 -0
  45. package/lib/utils.js +603 -0
  46. package/lib/worker-infra.js +221 -0
  47. package/package.json +64 -1
  48. package/services.js +246 -0
  49. package/utils/compose-yaml.js +56 -0
  50. package/utils/constants.js +30 -0
  51. package/utils/index.js +29 -0
  52. package/utils/initialize.js +148 -0
  53. package/utils/service-bootstrap.js +271 -0
  54. package/worker.js +9 -0
@@ -0,0 +1,49 @@
1
+ 'use strict'
2
+
3
+ const { HOUR_MS, HOURS_24_MS } = require('./constants')
4
+
5
+ const isCurrentMonth = (month) => {
6
+ return parseInt(month.split('-')[0]) === new Date().getMonth() + 1
7
+ }
8
+
9
+ const convertMsToSeconds = (timestampMs) => {
10
+ return Math.floor(timestampMs / 1000)
11
+ }
12
+
13
+ // Upper bound on buckets one call may build (~1 year hourly, ~27 years
14
+ // daily). start/end come from callers, so an unbounded span is a cheap way to
15
+ // allocate an arbitrarily large array.
16
+ const MAX_TIME_RANGES = 9000
17
+
18
+ const getTimeRanges = (start, end, isHourly = true) => {
19
+ if (!Number.isFinite(start) || !Number.isFinite(end)) throw new Error('ERR_TIME_RANGE_INVALID')
20
+ if (start >= end) return []
21
+
22
+ const step = isHourly ? HOUR_MS : HOURS_24_MS
23
+ if ((end - start) / step > MAX_TIME_RANGES) throw new Error('ERR_TIME_RANGE_TOO_LARGE')
24
+
25
+ const ranges = []
26
+ const timeDiff = isHourly ? HOUR_MS : HOURS_24_MS
27
+ let endTime = new Date(start + timeDiff)
28
+
29
+ if (isHourly) {
30
+ endTime.setUTCMinutes(0, 0, 0)
31
+ } else {
32
+ endTime.setUTCHours(0, 0, 0, 0)
33
+ }
34
+
35
+ endTime = endTime.getTime()
36
+ while (start < end) {
37
+ ranges.push({ start, end: endTime })
38
+ start = endTime
39
+ endTime += timeDiff
40
+ }
41
+ return ranges
42
+ }
43
+
44
+ module.exports = {
45
+ isCurrentMonth,
46
+ convertMsToSeconds,
47
+ getTimeRanges,
48
+ MAX_TIME_RANGES
49
+ }
@@ -0,0 +1,397 @@
1
+ 'use strict'
2
+
3
+ const StoreFacility = require('@tetherto/hp-svc-facs-store')
4
+ const HttpFacility = require('@bitfinex/bfx-facs-http')
5
+ const SchedulerFacility = require('@bitfinex/bfx-facs-scheduler')
6
+ const EventEmitter = require('events')
7
+ const fs = require('fs')
8
+ const path = require('path')
9
+ const gLibUtilBase = require('@bitfinex/lib-js-util-base')
10
+ const mingo = require('mingo')
11
+ const { SCHEDULER_TIMES, BTC_SATS, MINUTE_MS, HOUR_MS, HOURS_24_MS } = require('./pool-utils/constants')
12
+ const { getTimeRanges } = require('./pool-utils/time')
13
+
14
+ // Rows one getDbData call may return (conf.maxDbReadLimit overrides).
15
+ const MAX_DB_READ_LIMIT = 10000
16
+ const utilsStore = require('@tetherto/hp-svc-facs-store/utils')
17
+
18
+ /**
19
+ * Scheduler-driven HTTP-polling pool worker base (relocated
20
+ * MinerpoolManager). Not a Thing worker: one logical pool per instance,
21
+ * data exposed through getWrkExtData / getWorkers rather than per-device
22
+ * telemetry. Concrete pools (ocean, f2pool) subclass and implement the
23
+ * fetch* hooks + getHttpUrl.
24
+ */
25
+ class PoolService extends EventEmitter {
26
+ constructor (conf, ctx) {
27
+ super()
28
+ this.conf = conf || {}
29
+ this.ctx = ctx || {}
30
+ this.wtype = this.conf.wtype
31
+ this.debugError = (ctx && ctx.debugError) || ((...args) => console.error(...args))
32
+
33
+ if (!ctx || !ctx.rack) {
34
+ throw new Error('ERR_PROC_RACK_UNDEFINED')
35
+ }
36
+
37
+ this.prefix = `${this.wtype}-${ctx.rack}`
38
+ this.loadConf = ctx.loadConf ?? this._defaultLoadConf.bind(this)
39
+
40
+ // Aborts any in-flight fetch* HTTP call/rate-limit sleep the instant stop()
41
+ // runs — stop() tears down http_0 (baseUrl reset to '') right after, and
42
+ // without this a request already in flight fails with a confusing
43
+ // "Only absolute URLs are supported" instead of a clean abort.
44
+ this._abortController = new AbortController()
45
+
46
+ this.data = {
47
+ statsData: {},
48
+ workersData: { ts: 0, workers: [] },
49
+ blocks: [],
50
+ yearlyBalances: {}
51
+ }
52
+ }
53
+
54
+ async init (pool) {
55
+ if (this._initialized) return
56
+
57
+ this.loadConf(pool, pool)
58
+ const ctx = this.ctx
59
+ await this._createFacilities(ctx)
60
+
61
+ // Library default: never take over the process's signals. Standalone
62
+ // processes opt in with ctx.handleSignals.
63
+ if (this._ownsFacilities && this.ctx.handleSignals === true) {
64
+ this._bindProcessExit()
65
+ }
66
+
67
+ this._initialized = true
68
+
69
+ const db = await this.store_s1.getBee(
70
+ { name: 'main' },
71
+ { keyEncoding: 'binary' }
72
+ )
73
+ await db.ready()
74
+ this.transactionsDb = db.sub('transactions')
75
+ this.workersCountDb = db.sub('workers-count')
76
+ this.statsDb = db.sub('stats')
77
+ this.workersDb = db.sub('workers')
78
+ this.blocksDb = db.sub('blocks')
79
+
80
+ for (const { time, key } of Object.values(SCHEDULER_TIMES)) {
81
+ this.scheduler_0.add(key, (fireTime) => {
82
+ this.fetchData(key, fireTime)
83
+ }, time)
84
+ }
85
+ }
86
+
87
+ async fetchData (key, time) {
88
+ try {
89
+ switch (key) {
90
+ case SCHEDULER_TIMES._1M.key:
91
+ await this.fetchStats(time)
92
+ break
93
+ case SCHEDULER_TIMES._5M.key:
94
+ await this.fetchWorkers(time)
95
+ await this.saveStats(time)
96
+ break
97
+ case SCHEDULER_TIMES._1D.key:
98
+ await this.fetchTransactions()
99
+ await this.fetchBlocks()
100
+ await this.saveWorkers(time)
101
+ break
102
+ }
103
+ } catch (e) {
104
+ this._logErr('ERR_DATA_FETCH', e)
105
+ }
106
+ }
107
+
108
+ async fetchStats (time) {
109
+ // no-op
110
+ }
111
+
112
+ async fetchWorkers (time) {
113
+ // no-op
114
+ }
115
+
116
+ async fetchTransactions () {
117
+ // no-op
118
+ }
119
+
120
+ async fetchBlocks () {
121
+ // no-op
122
+ }
123
+
124
+ async saveStats (time) {
125
+ const ts = Math.floor(time.getTime() / 1000) * 1000
126
+ await this._saveToDb(this.statsDb, ts, { ts, stats: this.data.statsData.stats })
127
+ }
128
+
129
+ async saveWorkers (time) {
130
+ const ts = Math.floor(time.getTime() / 1000) * 1000
131
+ await this._saveToDb(this.workersDb, ts, { ts, workers: this.data.workersData.workers })
132
+ }
133
+
134
+ async _saveToDb (db, ts, data) {
135
+ await db.put(utilsStore.convIntToBin(ts), Buffer.from(JSON.stringify(data)))
136
+ }
137
+
138
+ _logErr (msg, err) {
139
+ console.error(new Date().toISOString(), msg, err)
140
+ }
141
+
142
+ _aggrTransactions (data, { start, end }) {
143
+ const totalRevenue = data.reduce((total, log) => {
144
+ log.transactions?.forEach((transaction) => {
145
+ total += transaction.satoshis_net_earned
146
+ })
147
+ return total
148
+ }, 0)
149
+ const tsRange = getTimeRanges(Number(start), Number(end))
150
+ const hourlyRevenues = []
151
+ if (!tsRange.length) return { ts: Date.now(), hourlyRevenues }
152
+ const hourlyAvgRevenue = (totalRevenue / tsRange.length) / BTC_SATS
153
+ tsRange.forEach(({ end: rangeEnd }) => {
154
+ hourlyRevenues.push({ ts: rangeEnd, revenue: hourlyAvgRevenue })
155
+ })
156
+
157
+ return { ts: Date.now(), hourlyRevenues }
158
+ }
159
+
160
+ _getIntervalMs (interval) {
161
+ switch (interval) {
162
+ case '1D':
163
+ return HOURS_24_MS
164
+ case '3h':
165
+ return 3 * HOUR_MS
166
+ case '30m':
167
+ return 30 * MINUTE_MS
168
+ case '5m':
169
+ default:
170
+ return 5 * MINUTE_MS
171
+ }
172
+ }
173
+
174
+ _avg (avg, value, count) {
175
+ return (avg * (count - 1) + value) / count
176
+ }
177
+
178
+ _aggrByInterval (data, interval) {
179
+ const intervalMs = this._getIntervalMs(interval)
180
+ const aggrBuckets = {}
181
+
182
+ data.forEach(d => {
183
+ const aggrTimestamp = Math.ceil(d.ts / intervalMs) * intervalMs
184
+ if (!aggrBuckets[aggrTimestamp]) {
185
+ aggrBuckets[aggrTimestamp] = []
186
+ }
187
+ aggrBuckets[aggrTimestamp].push(d)
188
+ })
189
+
190
+ return Object.entries(aggrBuckets).map(([ts, items]) => {
191
+ return items.reduce((acc, d, itemIndex) => {
192
+ d.stats = d.stats.map((stat, statsIndex) => {
193
+ const avgHashrate = acc?.stats?.[statsIndex]?.hashrate || 0
194
+ const hashrate = this._avg(avgHashrate, stat.hashrate, itemIndex + 1)
195
+ return { ...stat, hashrate }
196
+ })
197
+ return { ...acc, ...d, ts: Number(ts) }
198
+ }, {})
199
+ })
200
+ }
201
+
202
+ async getDbData (db, { start, end, limit }) {
203
+ if (!start || !Number.isFinite(Number(start)) || Number(start) < 0) throw new Error('ERR_START_INVALID')
204
+ if (!end || !Number.isFinite(Number(end)) || Number(end) < 0) throw new Error('ERR_END_INVALID')
205
+ start = Number(start)
206
+ end = Number(end)
207
+
208
+ const maxLimit = Number.isInteger(this.conf.maxDbReadLimit) && this.conf.maxDbReadLimit > 0
209
+ ? this.conf.maxDbReadLimit
210
+ : MAX_DB_READ_LIMIT
211
+ const n = limit === undefined || limit === null ? maxLimit : Number(limit)
212
+ if (!Number.isInteger(n) || n < 0) throw new Error('ERR_LIMIT_INVALID')
213
+ const query = {
214
+ gte: utilsStore.convIntToBin(start),
215
+ lte: utilsStore.convIntToBin(end),
216
+ limit: Math.min(n, maxLimit)
217
+ }
218
+
219
+ const stream = db.createReadStream(query)
220
+ const res = []
221
+ for await (const entry of stream) {
222
+ res.push(JSON.parse(entry.value.toString()))
223
+ }
224
+
225
+ return res
226
+ }
227
+
228
+ _projection (data, fields = {}) {
229
+ const query = new mingo.Query({})
230
+ if (Array.isArray(data)) return query.find(data, fields).all()
231
+ const cursor = query.find([data], fields)
232
+ return cursor.all()[0]
233
+ }
234
+
235
+ filterWorkers (workers, offset, limit) {
236
+ offset = Number(offset)
237
+ limit = Number(limit)
238
+ if (!Number.isInteger(offset) || offset < 0) throw new Error('ERR_OFFSET_INVALID')
239
+ if (!Number.isInteger(limit) || limit < 0) throw new Error('ERR_LIMIT_INVALID')
240
+ return workers.slice(offset, offset + (Math.min(limit, 100)))
241
+ }
242
+
243
+ async getWorkers (query) {
244
+ const { offset = 0, limit = 100, name, start, end } = query
245
+ if (!start || !end) {
246
+ const workersObj = { ts: 0, workers: this.filterWorkers(this.data.workersData.workers, offset, limit) }
247
+ workersObj.workers = this.appendPoolType(workersObj.workers)
248
+ return workersObj
249
+ }
250
+ const data = await this.getDbData(this.workersDb, query)
251
+ return data.reduce((aggr, obj) => {
252
+ let workersObj
253
+ if (name) workersObj = { ts: obj.ts, workers: obj.workers.filter(w => w.name === name) }
254
+ else workersObj = { ts: obj.ts, workers: this.filterWorkers(obj.workers, offset, limit) }
255
+ workersObj.workers = this.appendPoolType(workersObj.workers)
256
+ aggr = aggr.concat(workersObj)
257
+ return aggr
258
+ }, [])
259
+ }
260
+
261
+ appendPoolType (data) {
262
+ return data.map(d => ({ poolType: this.wtype, ...d }))
263
+ }
264
+
265
+ async _getWrkExtDataExtra (key, query) {
266
+ return undefined
267
+ }
268
+
269
+ async getWrkExtData (req) {
270
+ const { query } = req
271
+ if (!query) throw new Error('ERR_QUERY_INVALID')
272
+
273
+ const { key } = query
274
+ if (!key) throw new Error('ERR_KEY_INVALID')
275
+
276
+ const extra = await this._getWrkExtDataExtra(key, query)
277
+ if (extra !== undefined) {
278
+ const data = extra
279
+ if (!gLibUtilBase.isEmpty(query.fields)) return this._projection(data, query.fields)
280
+ return data
281
+ }
282
+
283
+ let data
284
+ switch (key) {
285
+ case 'transactions':
286
+ data = await this.getDbData(this.transactionsDb, query)
287
+ if (query.aggrHourly) data = this._aggrTransactions(data, query)
288
+ break
289
+ case 'workers-count':
290
+ data = await this.getDbData(this.workersCountDb, query)
291
+ break
292
+ case 'workers':
293
+ data = await this.getWorkers(query)
294
+ break
295
+ case 'stats':
296
+ data = this.data.statsData
297
+ if (data.stats) data.stats = this.appendPoolType(data.stats)
298
+ break
299
+ case 'stats-history':
300
+ data = await this.getDbData(this.statsDb, query)
301
+ if (query.interval) data = this._aggrByInterval(data, query.interval)
302
+ data.forEach(d => { if (d.stats) d.stats = this.appendPoolType(d.stats) })
303
+ break
304
+ default:
305
+ // Own keys only — never hand back inherited members (constructor, ...).
306
+ data = Object.prototype.hasOwnProperty.call(this.data, key) ? this.data[key] : undefined
307
+ break
308
+ }
309
+
310
+ if (!gLibUtilBase.isEmpty(query.fields)) return this._projection(data, query.fields)
311
+ return data
312
+ }
313
+
314
+ _defaultLoadConf (c, group = null) {
315
+ const root = this.ctx.root || ''
316
+ const configPath = path.join(root, 'config', `${c}.json`)
317
+ if (fs.existsSync(configPath)) {
318
+ const data = JSON.parse(fs.readFileSync(configPath, 'utf8'))
319
+ const merged = group ? { [group]: data } : data
320
+ Object.assign(this.conf, merged)
321
+ }
322
+ }
323
+
324
+ async _createFacilities (ctx) {
325
+ if (ctx.facs) {
326
+ this.store_s1 = ctx.facs.store_s1
327
+ this.http_0 = ctx.facs.http_0
328
+ this.scheduler_0 = ctx.facs.scheduler_0
329
+ return
330
+ }
331
+
332
+ this.store_s1 = new StoreFacility(this, { storeDir: ctx.storeDir }, ctx)
333
+ await this._startFacility(this.store_s1)
334
+
335
+ this.scheduler_0 = new SchedulerFacility(this, {}, ctx)
336
+ await this._startFacility(this.scheduler_0)
337
+
338
+ this.http_0 = new HttpFacility(this, {
339
+ baseUrl: this.getHttpUrl(),
340
+ timeout: 30 * 1000
341
+ }, {})
342
+ await this._startFacility(this.http_0)
343
+
344
+ this._ownsFacilities = true
345
+ }
346
+
347
+ getHttpUrl () {
348
+ return this.conf.baseUrl
349
+ }
350
+
351
+ // Concrete pools (ocean, f2pool) thread this into their API client so a
352
+ // fetch* call in flight when stop() runs aborts instead of racing the
353
+ // facility teardown below.
354
+ get abortSignal () {
355
+ return this._abortController.signal
356
+ }
357
+
358
+ stop (cb) {
359
+ this._abortController.abort()
360
+ if (!this._ownsFacilities || !this._initialized) {
361
+ return cb()
362
+ }
363
+ this._initialized = false
364
+ const facilities = [
365
+ this.scheduler_0,
366
+ this.http_0,
367
+ this.store_s1
368
+ ]
369
+ let i = 0
370
+ const next = (err) => {
371
+ if (err) this.debugError('ERR_FAC_STOP', err)
372
+ if (i >= facilities.length) return cb()
373
+ const facility = facilities[i++]
374
+ if (typeof facility.stop !== 'function') return next()
375
+ facility.stop(next)
376
+ }
377
+ next()
378
+ }
379
+
380
+ // Opt-in only (ctx.handleSignals: true): stop the facilities on
381
+ // SIGINT/SIGTERM. Embedders own their process's signals by default.
382
+ _bindProcessExit () {
383
+ const stop = () => {
384
+ this.stop(() => {})
385
+ }
386
+ process.once('SIGINT', stop)
387
+ process.once('SIGTERM', stop)
388
+ }
389
+
390
+ _startFacility (facility) {
391
+ return new Promise((resolve, reject) => {
392
+ facility.start((err) => (err ? reject(err) : resolve()))
393
+ })
394
+ }
395
+ }
396
+
397
+ module.exports = PoolService