diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 6ed7c43f9..b7bd1526f 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -6,6 +6,7 @@ import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './components/app/app.component'; import { ElectrsApiService } from './services/electrs-api.service'; import { StateService } from './services/state.service'; +import { CacheService } from './services/cache.service'; import { EnterpriseService } from './services/enterprise.service'; import { WebsocketService } from './services/websocket.service'; import { AudioService } from './services/audio.service'; @@ -23,6 +24,7 @@ import { AppPreloadingStrategy } from './app.preloading-strategy'; const providers = [ ElectrsApiService, StateService, + CacheService, WebsocketService, AudioService, SeoService, diff --git a/frontend/src/app/components/block/block.component.ts b/frontend/src/app/components/block/block.component.ts index e92e44937..f04b4ec9c 100644 --- a/frontend/src/app/components/block/block.component.ts +++ b/frontend/src/app/components/block/block.component.ts @@ -138,7 +138,6 @@ export class BlockComponent implements OnInit, OnDestroy { this.page = 1; this.error = undefined; this.fees = undefined; - this.stateService.markBlock$.next({}); this.auditDataMissing = false; if (history.state.data && history.state.data.blockHeight) { diff --git a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts index 930fa2ea8..8972afcae 100644 --- a/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts +++ b/frontend/src/app/components/blockchain-blocks/blockchain-blocks.component.ts @@ -5,6 +5,7 @@ import { specialBlocks } from '../../app.constants'; import { BlockExtended } from '../../interfaces/node-api.interface'; import { Location } from '@angular/common'; import { config } from 'process'; +import { CacheService } from 'src/app/services/cache.service'; interface BlockchainBlock extends BlockExtended { loading?: boolean; @@ -28,6 +29,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { emptyBlocks: BlockExtended[] = this.mountEmptyBlocks(); markHeight: number; blocksSubscription: Subscription; + blockPageSubscription: Subscription; networkSubscription: Subscription; tabHiddenSubscription: Subscription; markBlockSubscription: Subscription; @@ -56,6 +58,7 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { constructor( public stateService: StateService, + public cacheService: CacheService, private cd: ChangeDetectorRef, private location: Location, ) { @@ -123,6 +126,12 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { } this.cd.markForCheck(); }); + } else { + this.blockPageSubscription = this.cacheService.loadedBlocks$.subscribe((block) => { + if (block.height <= this.height && block.height > this.height - this.count) { + this.onBlockLoaded(block); + } + }); } this.markBlockSubscription = this.stateService.markBlock$ @@ -151,6 +160,9 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { if (this.blocksSubscription) { this.blocksSubscription.unsubscribe(); } + if (this.blockPageSubscription) { + this.blockPageSubscription.unsubscribe(); + } this.networkSubscription.unsubscribe(); this.tabHiddenSubscription.unsubscribe(); this.markBlockSubscription.unsubscribe(); @@ -201,12 +213,9 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { while (this.blocks.length < Math.min(this.height + 1, this.count)) { const height = this.height - this.blocks.length; if (height >= 0) { - // const block = this.cacheService.getCachedBlock(height) || null; - // if (!block) { - // this.cacheService.loadBlock(height); - // } - // this.blocks.push(block || { - this.blocks.push({ + this.cacheService.loadBlock(height); + const block = this.cacheService.getCachedBlock(height) || null; + this.blocks.push(block || { loading: true, id: '', height, @@ -236,6 +245,15 @@ export class BlockchainBlocksComponent implements OnInit, OnChanges, OnDestroy { } } + onBlockLoaded(block: BlockExtended) { + const blockIndex = this.height - block.height; + if (blockIndex >= 0 && blockIndex < this.blocks.length) { + this.blocks[blockIndex] = block; + this.blockStyles[blockIndex] = this.getStyleForBlock(block, blockIndex); + } + this.cd.markForCheck(); + } + getStyleForBlock(block: BlockchainBlock, index: number, animateSlideStart: boolean = false) { if (!block || block.loading) { return this.getStyleForLoadingBlock(index, animateSlideStart); diff --git a/frontend/src/app/components/start/start.component.ts b/frontend/src/app/components/start/start.component.ts index 5a70bf15c..ca8c2a47c 100644 --- a/frontend/src/app/components/start/start.component.ts +++ b/frontend/src/app/components/start/start.component.ts @@ -46,6 +46,11 @@ export class StartComponent implements OnInit, OnDestroy { this.chainTip = height; this.updatePages(); }); + this.markBlockSubscription = this.stateService.markBlock$.subscribe((mark) => { + if (mark?.blockHeight != null) { + this.scrollToBlock(mark.blockHeight); + } + }); this.stateService.blocks$ .subscribe((blocks: any) => { if (this.stateService.network !== '') { diff --git a/frontend/src/app/components/transaction/transaction-preview.component.ts b/frontend/src/app/components/transaction/transaction-preview.component.ts index 9d2d502b4..6db0e588c 100644 --- a/frontend/src/app/components/transaction/transaction-preview.component.ts +++ b/frontend/src/app/components/transaction/transaction-preview.component.ts @@ -11,6 +11,7 @@ import { import { Transaction, Vout } from '../../interfaces/electrs.interface'; import { of, merge, Subscription, Observable, Subject, from } from 'rxjs'; import { StateService } from '../../services/state.service'; +import { CacheService } from '../../services/cache.service'; import { OpenGraphService } from '../../services/opengraph.service'; import { ApiService } from '../../services/api.service'; import { SeoService } from '../../services/seo.service'; @@ -45,6 +46,7 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy { private route: ActivatedRoute, private electrsApiService: ElectrsApiService, private stateService: StateService, + private cacheService: CacheService, private apiService: ApiService, private seoService: SeoService, private openGraphService: OpenGraphService, @@ -97,7 +99,7 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy { }), switchMap(() => { let transactionObservable$: Observable; - const cached = this.stateService.getTxFromCache(this.txId); + const cached = this.cacheService.getTxFromCache(this.txId); if (cached && cached.fee !== -1) { transactionObservable$ = of(cached); } else { diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index 575c00637..1856ee48a 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -13,6 +13,7 @@ import { import { Transaction } from '../../interfaces/electrs.interface'; import { of, merge, Subscription, Observable, Subject, timer, combineLatest, from, throwError } from 'rxjs'; import { StateService } from '../../services/state.service'; +import { CacheService } from '../../services/cache.service'; import { WebsocketService } from '../../services/websocket.service'; import { AudioService } from '../../services/audio.service'; import { ApiService } from '../../services/api.service'; @@ -74,6 +75,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { private relativeUrlPipe: RelativeUrlPipe, private electrsApiService: ElectrsApiService, private stateService: StateService, + private cacheService: CacheService, private websocketService: WebsocketService, private audioService: AudioService, private apiService: ApiService, @@ -203,7 +205,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { }), switchMap(() => { let transactionObservable$: Observable; - const cached = this.stateService.getTxFromCache(this.txId); + const cached = this.cacheService.getTxFromCache(this.txId); if (cached && cached.fee !== -1) { transactionObservable$ = of(cached); } else { @@ -302,7 +304,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.waitingForTransaction = false; } this.rbfTransaction = rbfTransaction; - this.stateService.setTxCache([this.rbfTransaction]); + this.cacheService.setTxCache([this.rbfTransaction]); }); this.queryParamsSubscription = this.route.queryParams.subscribe((params) => { diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.ts b/frontend/src/app/components/transactions-list/transactions-list.component.ts index 8b4fabf6e..67df2daa2 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.ts +++ b/frontend/src/app/components/transactions-list/transactions-list.component.ts @@ -1,5 +1,6 @@ import { Component, OnInit, Input, ChangeDetectionStrategy, OnChanges, Output, EventEmitter, ChangeDetectorRef } from '@angular/core'; import { StateService } from '../../services/state.service'; +import { CacheService } from '../../services/cache.service'; import { Observable, ReplaySubject, BehaviorSubject, merge, Subscription } from 'rxjs'; import { Outspend, Transaction, Vin, Vout } from '../../interfaces/electrs.interface'; import { ElectrsApiService } from '../../services/electrs-api.service'; @@ -44,6 +45,7 @@ export class TransactionsListComponent implements OnInit, OnChanges { constructor( public stateService: StateService, + private cacheService: CacheService, private electrsApiService: ElectrsApiService, private apiService: ApiService, private assetsService: AssetsService, @@ -123,7 +125,7 @@ export class TransactionsListComponent implements OnInit, OnChanges { } this.transactionsLength = this.transactions.length; - this.stateService.setTxCache(this.transactions); + this.cacheService.setTxCache(this.transactions); this.transactions.forEach((tx) => { tx['@voutLimit'] = true; diff --git a/frontend/src/app/services/cache.service.ts b/frontend/src/app/services/cache.service.ts new file mode 100644 index 000000000..5e184c184 --- /dev/null +++ b/frontend/src/app/services/cache.service.ts @@ -0,0 +1,105 @@ +import { Injectable } from '@angular/core'; +import { firstValueFrom, Subject, Subscription} from 'rxjs'; +import { Transaction } from '../interfaces/electrs.interface'; +import { BlockExtended } from '../interfaces/node-api.interface'; +import { StateService } from './state.service'; +import { ApiService } from './api.service'; + +const BLOCK_CACHE_SIZE = 50; +const KEEP_RECENT_BLOCKS = 50; + +@Injectable({ + providedIn: 'root' +}) +export class CacheService { + loadedBlocks$ = new Subject(); + tip: number = 0; + + txCache: { [txid: string]: Transaction } = {}; + + blockCache: { [height: number]: BlockExtended } = {}; + blockLoading: { [height: number]: boolean } = {}; + copiesInBlockQueue: { [height: number]: number } = {}; + blockPriorities: number[] = []; + + constructor( + private stateService: StateService, + private apiService: ApiService, + ) { + this.stateService.blocks$.subscribe(([block]) => { + this.addBlockToCache(block); + this.clearBlocks(); + }); + this.stateService.chainTip$.subscribe((height) => { + this.tip = height; + }); + } + + setTxCache(transactions) { + this.txCache = {}; + transactions.forEach(tx => { + this.txCache[tx.txid] = tx; + }); + } + + getTxFromCache(txid) { + if (this.txCache && this.txCache[txid]) { + return this.txCache[txid]; + } else { + return null; + } + } + + addBlockToCache(block: BlockExtended) { + this.blockCache[block.height] = block; + this.bumpBlockPriority(block.height); + } + + async loadBlock(height) { + if (!this.blockCache[height] && !this.blockLoading[height]) { + const chunkSize = 10; + const maxHeight = Math.ceil(height / chunkSize) * chunkSize; + for (let i = 0; i < chunkSize; i++) { + this.blockLoading[maxHeight - i] = true; + } + const result = await firstValueFrom(this.apiService.getBlocks$(maxHeight)); + for (let i = 0; i < chunkSize; i++) { + delete this.blockLoading[maxHeight - i]; + } + if (result && result.length) { + result.forEach(block => { + this.addBlockToCache(block); + this.loadedBlocks$.next(block); + }); + } + this.clearBlocks(); + } else { + this.bumpBlockPriority(height); + } + } + + // increase the priority of a block, to delay removal + bumpBlockPriority(height) { + this.blockPriorities.push(height); + this.copiesInBlockQueue[height] = (this.copiesInBlockQueue[height] || 0) + 1; + } + + // remove lowest priority blocks from the cache + clearBlocks() { + while (Object.keys(this.blockCache).length > (BLOCK_CACHE_SIZE + KEEP_RECENT_BLOCKS) && this.blockPriorities.length > KEEP_RECENT_BLOCKS) { + const height = this.blockPriorities.shift(); + if (this.copiesInBlockQueue[height] > 1) { + this.copiesInBlockQueue[height]--; + } else if ((this.tip - height) < KEEP_RECENT_BLOCKS) { + this.bumpBlockPriority(height); + } else { + delete this.blockCache[height]; + delete this.copiesInBlockQueue[height]; + } + } + } + + getCachedBlock(height) { + return this.blockCache[height]; + } +} \ No newline at end of file diff --git a/frontend/src/app/services/state.service.ts b/frontend/src/app/services/state.service.ts index a15c992ad..86efa57f8 100644 --- a/frontend/src/app/services/state.service.ts +++ b/frontend/src/app/services/state.service.ts @@ -1,6 +1,6 @@ import { Inject, Injectable, PLATFORM_ID, LOCALE_ID } from '@angular/core'; import { ReplaySubject, BehaviorSubject, Subject, fromEvent, Observable } from 'rxjs'; -import { Block, Transaction } from '../interfaces/electrs.interface'; +import { Transaction } from '../interfaces/electrs.interface'; import { IBackendInfo, MempoolBlock, MempoolBlockWithTransactions, MempoolBlockDelta, MempoolInfo, Recommendedfees, ReplacedTransaction, TransactionStripped } from '../interfaces/websocket.interface'; import { BlockExtended, DifficultyAdjustment, OptimizedMempoolStats } from '../interfaces/node-api.interface'; import { Router, NavigationStart } from '@angular/router'; @@ -119,8 +119,6 @@ export class StateService { timeLtr: BehaviorSubject; hideFlow: BehaviorSubject; - txCache: { [txid: string]: Transaction } = {}; - constructor( @Inject(PLATFORM_ID) private platformId: any, @Inject(LOCALE_ID) private locale: string, @@ -275,28 +273,12 @@ export class StateService { return this.network === 'liquid' || this.network === 'liquidtestnet'; } - setTxCache(transactions) { - this.txCache = {}; - transactions.forEach(tx => { - this.txCache[tx.txid] = tx; - }); - } - - getTxFromCache(txid) { - if (this.txCache && this.txCache[txid]) { - return this.txCache[txid]; - } else { - return null; - } - } - resetChainTip() { this.latestBlockHeight = -1; this.chainTip$.next(-1); } updateChainTip(height) { - console.log('updating chain tip to ', height); if (height > this.latestBlockHeight) { this.latestBlockHeight = height; this.chainTip$.next(height);