Skip to content

Commit f40e6eb

Browse files
Monitor chain for capacity stake/unstake (#329)
Add Capacity event monitoring to the blockchain scanner, so that we can pause/unpause the job queue as Capacity becomes available or unavailable, based on staking. Previously, if the queue was paused due to low Capacity, we would have to wait for the next epoch, even if more Capacity was staked. - [x] account-service - [x] content-publishing-service - ~[ ] content-watcher-service~ (not needed) - [x] graph-service Closes: #296 Closes: #267 --------- Co-authored-by: Matthew Orris <[email protected]>
1 parent c4372bf commit f40e6eb

File tree

91 files changed

+3111
-1474
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

91 files changed

+3111
-1474
lines changed

.tool-versions

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
nodejs 20.12.2
1+
nodejs 20.16.0
22
make 4.3

package-lock.json

+9-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

services/account/apps/api/src/controllers/v1/handles-v1.controller.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { Controller, Get, Post, HttpCode, HttpStatus, Logger, Param, HttpException, Body, Put } from '@nestjs/common';
1+
import { Controller, Get, Post, HttpCode, HttpStatus, Logger, Param, HttpException, Body } from '@nestjs/common';
22
import { ApiBody, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
3-
import type { HandleResponse } from '@frequency-chain/api-augment/interfaces';
43
import { TransactionType } from '#lib/types/enums';
54
import { HandlesService } from '#api/services/handles.service';
65
import { EnqueueService } from '#lib/services/enqueue-request.service';

services/account/apps/worker/src/main.ts

+6
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
55
import { redisReady } from '#lib/utils/redis';
66
import { WorkerModule } from './worker.module';
77

8+
// Monkey-patch BigInt so that JSON.stringify will work
9+
// eslint-disable-next-line
10+
BigInt.prototype['toJSON'] = function () {
11+
return this.toString();
12+
};
13+
814
const logger = new Logger('worker_main');
915

1016
// bootstrap() does not have a main loop to keep the process alive.
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import { Module } from '@nestjs/common';
22
import { BlockchainModule } from '#lib/blockchain/blockchain.module';
3-
import { EnqueueService } from '#lib/services/enqueue-request.service';
43
import { TxnNotifierService } from './notifier.service';
54

65
@Module({
76
imports: [BlockchainModule],
8-
providers: [EnqueueService, TxnNotifierService],
9-
exports: [EnqueueService, TxnNotifierService],
7+
providers: [TxnNotifierService],
8+
exports: [],
109
})
1110
export class TxnNotifierModule {}

services/account/apps/worker/src/transaction_notifier/notifier.service.ts

+14-27
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@ import { BlockchainService } from '#lib/blockchain/blockchain.service';
88
import { TransactionType } from '#lib/types/enums';
99
import { SECONDS_PER_BLOCK, TxWebhookRsp, RedisUtils } from 'libs/common/src';
1010
import { createWebhookRsp } from '#worker/transaction_notifier/notifier.service.helper.createWebhookRsp';
11-
import { BlockchainScannerService, NullScanError } from '#lib/utils/blockchain-scanner.service';
11+
import { BlockchainScannerService } from '#lib/utils/blockchain-scanner.service';
1212
import { SchedulerRegistry } from '@nestjs/schedule';
13-
import { BlockHash } from '@polkadot/types/interfaces';
13+
import { SignedBlock } from '@polkadot/types/interfaces';
1414
import { HexString } from '@polkadot/util/types';
1515
import { ITxStatus } from '#lib/interfaces/tx-status.interface';
1616
import { FrameSystemEventRecord } from '@polkadot/types/lookup';
1717
import { ConfigService } from '#lib/config/config.service';
1818
import { QueueConstants } from '#lib/queues';
19+
import { CapacityCheckerService } from '#lib/blockchain/capacity-checker.service';
1920

2021
@Injectable()
2122
export class TxnNotifierService
@@ -47,9 +48,13 @@ export class TxnNotifierService
4748
private readonly schedulerRegistry: SchedulerRegistry,
4849
@InjectRedis() cacheManager: Redis,
4950
private readonly configService: ConfigService,
51+
private readonly capacityService: CapacityCheckerService,
5052
) {
5153
super(cacheManager, blockchainService, new Logger(TxnNotifierService.prototype.constructor.name));
5254
this.scanParameters = { onlyFinalized: this.configService.trustUnfinalizedBlocks };
55+
this.registerChainEventHandler(['capacity.UnStaked', 'capacity.Staked'], () =>
56+
this.capacityService.checkForSufficientCapacity(),
57+
);
5358
}
5459

5560
public get intervalName() {
@@ -72,25 +77,6 @@ export class TxnNotifierService
7277
}
7378
}
7479

75-
protected async checkInitialScanParameters(): Promise<void> {
76-
const pendingTxns = await this.cacheManager.hlen(RedisUtils.TXN_WATCH_LIST_KEY);
77-
if (pendingTxns === 0) {
78-
throw new NullScanError('No pending extrinsics; no scan will be performed');
79-
}
80-
81-
return super.checkInitialScanParameters();
82-
}
83-
84-
protected async checkScanParameters(blockNumber: number, blockHash: BlockHash): Promise<void> {
85-
const pendingTxns = await this.cacheManager.hlen(RedisUtils.TXN_WATCH_LIST_KEY);
86-
87-
if (pendingTxns === 0) {
88-
throw new NullScanError('No pending extrinsics; terminating current scan iteration');
89-
}
90-
91-
return super.checkScanParameters(blockNumber, blockHash);
92-
}
93-
9480
public async getLastSeenBlockNumber(): Promise<number> {
9581
let blockNumber = await super.getLastSeenBlockNumber();
9682
const pendingTxns = await this.cacheManager.hvals(RedisUtils.TXN_WATCH_LIST_KEY);
@@ -107,15 +93,16 @@ export class TxnNotifierService
10793
return blockNumber;
10894
}
10995

110-
async processCurrentBlock(currentBlockHash: BlockHash, currentBlockNumber: number): Promise<void> {
96+
async processCurrentBlock(currentBlock: SignedBlock, blockEvents: FrameSystemEventRecord[]): Promise<void> {
97+
const currentBlockNumber = currentBlock.block.header.number.toNumber();
98+
11199
// Get set of tx hashes to monitor from cache
112100
const pendingTxns = (await this.cacheManager.hvals(RedisUtils.TXN_WATCH_LIST_KEY)).map(
113101
(val) => JSON.parse(val) as ITxStatus,
114102
);
115103

116-
const block = await this.blockchainService.getBlock(currentBlockHash);
117104
const extrinsicIndices: [HexString, number][] = [];
118-
block.block.extrinsics.forEach((extrinsic, index) => {
105+
currentBlock.block.extrinsics.forEach((extrinsic, index) => {
119106
if (pendingTxns.some(({ txHash }) => txHash === extrinsic.hash.toHex())) {
120107
extrinsicIndices.push([extrinsic.hash.toHex(), index]);
121108
}
@@ -124,9 +111,9 @@ export class TxnNotifierService
124111
let pipeline = this.cacheManager.multi({ pipeline: true });
125112

126113
if (extrinsicIndices.length > 0) {
127-
const at = await this.blockchainService.api.at(currentBlockHash);
114+
const at = await this.blockchainService.api.at(currentBlock.block.header.hash);
128115
const epoch = (await at.query.capacity.currentEpoch()).toNumber();
129-
const events: FrameSystemEventRecord[] = (await at.query.system.events()).filter(
116+
const events: FrameSystemEventRecord[] = blockEvents.filter(
130117
({ phase }) => phase.isApplyExtrinsic && extrinsicIndices.some((index) => phase.asApplyExtrinsic.eq(index)),
131118
);
132119

@@ -154,7 +141,7 @@ export class TxnNotifierService
154141
const moduleError = dispatchError.registry.findMetaError(moduleThatErrored);
155142
this.logger.error(`Extrinsic failed with error: ${JSON.stringify(moduleError)}`);
156143
} else if (successEvent) {
157-
this.logger.verbose(`Successfully found transaction ${txHash} in block ${currentBlockHash}`);
144+
this.logger.verbose(`Successfully found transaction ${txHash} in block ${currentBlockNumber}`);
158145
const webhook = await this.getWebhook();
159146
let webhookResponse: Partial<TxWebhookRsp> = {};
160147
webhookResponse.referenceId = txStatus.referenceId;

services/account/apps/worker/src/transaction_publisher/publisher.service.ts

+59-83
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { SubmittableExtrinsic } from '@polkadot/api-base/types';
88
import { Codec, ISubmittableResult } from '@polkadot/types/types';
99
import { MILLISECONDS_PER_SECOND } from 'time-constants';
1010
import { SchedulerRegistry } from '@nestjs/schedule';
11-
import { BlockchainService } from '#lib/blockchain/blockchain.service';
11+
import { BlockchainService, ICapacityInfo } from '#lib/blockchain/blockchain.service';
1212
import { createKeys } from '#lib/blockchain/create-keys';
1313
import { NonceService } from '#lib/services/nonce.service';
1414
import { TransactionType } from '#lib/types/enums';
@@ -18,6 +18,12 @@ import { RedisUtils, TransactionData } from 'libs/common/src';
1818
import { ConfigService } from '#lib/config/config.service';
1919
import { ITxStatus } from '#lib/interfaces/tx-status.interface';
2020
import { HexString } from '@polkadot/util/types';
21+
import {
22+
CAPACITY_AVAILABLE_EVENT,
23+
CAPACITY_EXHAUSTED_EVENT,
24+
CapacityCheckerService,
25+
} from '#lib/blockchain/capacity-checker.service';
26+
import { OnEvent } from '@nestjs/event-emitter';
2127

2228
export const SECONDS_PER_BLOCK = 12;
2329
const CAPACITY_EPOCH_TIMEOUT_NAME = 'capacity_check';
@@ -29,7 +35,7 @@ const CAPACITY_EPOCH_TIMEOUT_NAME = 'capacity_check';
2935
@Processor(QueueConstants.TRANSACTION_PUBLISH_QUEUE)
3036
export class TransactionPublisherService extends BaseConsumer implements OnApplicationShutdown {
3137
public async onApplicationBootstrap() {
32-
await this.checkCapacity();
38+
await this.capacityCheckerService.checkForSufficientCapacity();
3339
}
3440

3541
public async onApplicationShutdown(_signal?: string | undefined): Promise<void> {
@@ -48,6 +54,7 @@ export class TransactionPublisherService extends BaseConsumer implements OnAppli
4854
private blockchainService: BlockchainService,
4955
private nonceService: NonceService,
5056
private schedulerRegistry: SchedulerRegistry,
57+
private capacityCheckerService: CapacityCheckerService,
5158
) {
5259
super();
5360
}
@@ -61,7 +68,7 @@ export class TransactionPublisherService extends BaseConsumer implements OnAppli
6168
let txHash: HexString;
6269
try {
6370
// Check capacity first; if out of capacity, send job back to queue
64-
if (!(await this.checkCapacity())) {
71+
if (!(await this.capacityCheckerService.checkForSufficientCapacity())) {
6572
job.moveToDelayed(Date.now(), job.token); // fake delay, we just want to avoid processing the current job if we're out of capacity
6673
throw new DelayedError();
6774
}
@@ -114,11 +121,10 @@ export class TransactionPublisherService extends BaseConsumer implements OnAppli
114121
obj[txHash] = JSON.stringify(status);
115122
this.cacheManager.hset(RedisUtils.TXN_WATCH_LIST_KEY, obj);
116123
} catch (error: any) {
117-
if (error instanceof DelayedError) {
118-
throw error;
124+
if (!(error instanceof DelayedError)) {
125+
this.logger.error('Unknown error encountered: ', error, error?.stack);
119126
}
120127

121-
this.logger.error('Unknown error encountered: ', error, error?.stack);
122128
throw error;
123129
}
124130
}
@@ -183,89 +189,59 @@ export class TransactionPublisherService extends BaseConsumer implements OnAppli
183189
}
184190
}
185191

186-
/**
187-
* Checks the capacity of the account publisher and takes appropriate actions based on the capacity status.
188-
* If the capacity is exhausted, it pauses the account change publish queue and sets a timeout to check the capacity again.
189-
* If the capacity is refilled, it resumes the account change publish queue and clears the timeout.
190-
* If any jobs failed due to low balance/no capacity, it retries them.
191-
* If any error occurs during the capacity check, it logs the error.
192-
*/
193-
private async checkCapacity(): Promise<boolean> {
194-
let outOfCapacity = false;
192+
@OnEvent(CAPACITY_EXHAUSTED_EVENT)
193+
public async handleCapacityExhausted(capacityInfo: ICapacityInfo) {
194+
await this.transactionPublishQueue.pause();
195+
const blocksRemaining = capacityInfo.nextEpochStart - capacityInfo.currentBlockNumber;
196+
const epochTimeout = blocksRemaining * SECONDS_PER_BLOCK * MILLISECONDS_PER_SECOND;
197+
// Avoid spamming the log
198+
if (!(await this.transactionPublishQueue.isPaused())) {
199+
this.logger.warn(
200+
`Capacity Exhausted: Pausing account change publish queue until next epoch: ${epochTimeout / 1000} seconds`,
201+
);
202+
}
195203
try {
196-
const { capacityLimit } = this.configService;
197-
const capacityInfo = await this.blockchainService.capacityInfo(this.configService.providerId);
198-
const { remainingCapacity } = capacityInfo;
199-
const { currentEpoch } = capacityInfo;
200-
const epochCapacityKey = `epochCapacity:${currentEpoch}`;
201-
const epochUsedCapacity = BigInt((await this.cacheManager.get(epochCapacityKey)) ?? 0); // Fetch capacity used by the service
202-
outOfCapacity = remainingCapacity <= 0n;
203-
204-
if (!outOfCapacity) {
205-
this.logger.debug(` Capacity remaining: ${remainingCapacity}`);
206-
if (capacityLimit.type === 'percentage') {
207-
const capacityLimitPercentage = BigInt(capacityLimit.value);
208-
const capacityLimitThreshold = (capacityInfo.totalCapacityIssued * capacityLimitPercentage) / 100n;
209-
this.logger.debug(`Capacity limit threshold: ${capacityLimitThreshold}`);
210-
if (epochUsedCapacity >= capacityLimitThreshold) {
211-
outOfCapacity = true;
212-
this.logger.warn(`Capacity threshold reached: used ${epochUsedCapacity} of ${capacityLimitThreshold}`);
213-
}
214-
} else if (epochUsedCapacity >= capacityLimit.value) {
215-
outOfCapacity = true;
216-
this.logger.warn(`Capacity threshold reached: used ${epochUsedCapacity} of ${capacityLimit.value}`);
217-
}
204+
// Check if a timeout with the same name already exists
205+
if (this.schedulerRegistry.doesExist('timeout', CAPACITY_EPOCH_TIMEOUT_NAME)) {
206+
// If it does, delete it
207+
this.schedulerRegistry.deleteTimeout(CAPACITY_EPOCH_TIMEOUT_NAME);
218208
}
219209

220-
if (outOfCapacity) {
221-
await this.transactionPublishQueue.pause();
222-
const blocksRemaining = capacityInfo.nextEpochStart - capacityInfo.currentBlockNumber;
223-
const epochTimeout = blocksRemaining * SECONDS_PER_BLOCK * MILLISECONDS_PER_SECOND;
224-
this.logger.warn(
225-
`Capacity Exhausted: Pausing account change publish queue until next epoch: ${epochTimeout / 1000} seconds`,
226-
);
227-
try {
228-
// Check if a timeout with the same name already exists
229-
if (this.schedulerRegistry.doesExist('timeout', CAPACITY_EPOCH_TIMEOUT_NAME)) {
230-
// If it does, delete it
231-
this.schedulerRegistry.deleteTimeout(CAPACITY_EPOCH_TIMEOUT_NAME);
232-
}
233-
234-
// Add the new timeout
235-
this.schedulerRegistry.addTimeout(
236-
CAPACITY_EPOCH_TIMEOUT_NAME,
237-
setTimeout(() => this.checkCapacity(), epochTimeout),
238-
);
239-
} catch (err) {
240-
// Handle any errors
241-
console.error(err);
242-
}
243-
} else {
244-
this.logger.verbose('Capacity Available: Resuming account change publish queue and clearing timeout');
245-
// Get the failed jobs and check if they failed due to capacity
246-
const failedJobs = await this.transactionPublishQueue.getFailed();
247-
const capacityFailedJobs = failedJobs.filter((job) =>
248-
job.failedReason?.includes('1010: Invalid Transaction: Inability to pay some fees'),
249-
);
250-
// Retry the failed jobs
251-
await Promise.all(
252-
capacityFailedJobs.map(async (job) => {
253-
this.logger.debug(`Retrying job ${job.id}`);
254-
job.retry();
255-
}),
256-
);
257-
try {
258-
this.schedulerRegistry.deleteTimeout(CAPACITY_EPOCH_TIMEOUT_NAME);
259-
} catch (err) {
260-
// ignore
261-
}
210+
// Add the new timeout
211+
this.schedulerRegistry.addTimeout(
212+
CAPACITY_EPOCH_TIMEOUT_NAME,
213+
setTimeout(() => this.capacityCheckerService.checkForSufficientCapacity(), epochTimeout),
214+
);
215+
} catch (err) {
216+
// Handle any errors
217+
console.error(err);
218+
}
219+
}
262220

263-
await this.transactionPublishQueue.resume();
264-
}
221+
@OnEvent(CAPACITY_AVAILABLE_EVENT)
222+
public async handleCapacityAvailable() {
223+
// Avoid spamming the log
224+
if (await this.transactionPublishQueue.isPaused()) {
225+
this.logger.verbose('Capacity Available: Resuming account change publish queue and clearing timeout');
226+
}
227+
// Get the failed jobs and check if they failed due to capacity
228+
const failedJobs = await this.transactionPublishQueue.getFailed();
229+
const capacityFailedJobs = failedJobs.filter((job) =>
230+
job.failedReason?.includes('1010: Invalid Transaction: Inability to pay some fees'),
231+
);
232+
// Retry the failed jobs
233+
await Promise.all(
234+
capacityFailedJobs.map(async (job) => {
235+
this.logger.debug(`Retrying job ${job.id}`);
236+
job.retry();
237+
}),
238+
);
239+
try {
240+
this.schedulerRegistry.deleteTimeout(CAPACITY_EPOCH_TIMEOUT_NAME);
265241
} catch (err) {
266-
this.logger.error('Caught error in checkCapacity', err);
242+
// ignore
267243
}
268244

269-
return !outOfCapacity;
245+
await this.transactionPublishQueue.resume();
270246
}
271247
}

services/account/apps/worker/src/worker.module.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,6 @@ import { TransactionPublisherModule } from './transaction_publisher/publisher.mo
6262
TxnNotifierModule,
6363
],
6464
providers: [ProviderWebhookService, NonceService],
65-
exports: [],
65+
exports: [EventEmitterModule],
6666
})
6767
export class WorkerModule {}

services/account/env.template

+1-1
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ HEALTH_CHECK_SUCCESS_THRESHOLD=10
5656
# Maximum amount of provider capacity this app is allowed to use (per epoch)
5757
# type: 'percentage' | 'amount'
5858
# value: number (may be percentage, ie '80', or absolute amount of capacity)
59-
CAPACITY_LIMIT='{"type":"percentage", "value":80}'
59+
CAPACITY_LIMIT='{"serviceLimit":{"type":"percentage","value":"80"}}'
6060

6161
# URL for the Sign-In With Frequency UI
6262
SIWF_URL=https://projectlibertylabs.github.io/siwf/ui

services/account/jest.init.ts

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/* eslint-disable func-names */
2+
/* eslint-disable no-extend-native */
3+
// eslint-disable-next-line dot-notation
4+
BigInt.prototype['toJSON'] = function () {
5+
return this.toString();
6+
};

0 commit comments

Comments
 (0)