From 5c5b70de19e5d1d6bcc86414773586cb72a02563 Mon Sep 17 00:00:00 2001 From: Martynas Kazlauskas Date: Tue, 19 Sep 2023 10:44:17 +0300 Subject: [PATCH 1/4] refactor: hoist createObservableDataSource to projection-typeorm restructure observable DataSource utils for easier re-use as preparation for new StabilityWindowBuffer interface that will require it's own connection during Bootstrap --- .../src/Program/services/pgboss.ts | 34 +++++-- .../src/Projection/createTypeormProjection.ts | 61 ++----------- .../test/Program/services/pgboss.test.ts | 33 ++++--- .../createTypeormProjection.test.ts | 21 +++-- .../e2e/test/projection/offline-fork.test.ts | 9 +- .../projection/single-tenant-utxo.test.ts | 6 +- .../src/ChainSyncEvents/chainSyncEvents.ts | 1 + .../src/createDataSource.ts | 90 ++++++++++++++++++- .../src/operators/withTypeormTransaction.ts | 76 ++++------------ .../test/TypeormStabilityWindowBuffer.test.ts | 8 +- .../test/operators/storeAddresses.test.ts | 11 +-- .../test/operators/storeAssets.test.ts | 11 +-- .../operators/storeHandleMetadata.test.ts | 11 +-- .../test/operators/storeHandles/util.ts | 13 ++- .../test/operators/storeNftMetadata.test.ts | 11 +-- .../storeStakeKeyRegistrations.test.ts | 12 +-- .../storeStakePoolMetadataJob.test.ts | 26 +++--- .../test/operators/storeStakePools.test.ts | 27 +++--- .../test/operators/storeUtxo.test.ts | 7 +- .../operators/withTypeormTransaction.test.ts | 4 +- packages/projection-typeorm/test/util.ts | 3 + 21 files changed, 248 insertions(+), 227 deletions(-) diff --git a/packages/cardano-services/src/Program/services/pgboss.ts b/packages/cardano-services/src/Program/services/pgboss.ts index 8ef1c3db880..81cacc27c03 100644 --- a/packages/cardano-services/src/Program/services/pgboss.ts +++ b/packages/cardano-services/src/Program/services/pgboss.ts @@ -6,6 +6,7 @@ import { PoolRegistrationEntity, PoolRetirementEntity, StakePoolEntity, + createDataSource, createPgBoss, isRecoverableTypeormError } from '@cardano-sdk/projection-typeorm'; @@ -32,14 +33,13 @@ import { Pool } from 'pg'; import { Router } from 'express'; import { StakePoolMetadataProgramOptions } from '../options/stakePoolMetadata'; import { contextLogger } from '@cardano-sdk/util'; -import { createObservableDataSource } from '../../Projection/createTypeormProjection'; import { retryBackoff } from 'backoff-rxjs'; import PgBoss from 'pg-boss'; /** * The entities required by the job handlers */ -export const pgBossEntities = [ +export const pgBossEntities: Function[] = [ CurrentPoolMetricsEntity, BlockEntity, PoolMetadataEntity, @@ -49,13 +49,28 @@ export const pgBossEntities = [ ]; export const createPgBossDataSource = (connectionConfig$: Observable, logger: Logger) => - createObservableDataSource({ - connectionConfig$, - entities: pgBossEntities, - extensions: {}, - logger, - migrationsRun: false - }); + // TODO: use createObservableDataSource from projection-typeorm package. + // A challenge in doing that is that we call subscriber.error on retryable errors in order to reconnect. + // Doing that with createObservableDataSource will 'destroy' the data source that's currently used, + // so pg-boss is then unable to update job status and it stays 'active', not available for the newly + // recreated worker to be picked up. + // TODO: this raises another question - what happens when database connection drops while working on a job? + // Will it stay 'active' forever, or will pg-boss eventually update it due to some sort of timeout? + connectionConfig$.pipe( + switchMap((connectionConfig) => + from( + (async () => { + const dataSource = createDataSource({ + connectionConfig, + entities: pgBossEntities, + logger + }); + await dataSource.initialize(); + return dataSource; + })() + ) + ) + ); export type PgBossWorkerArgs = CommonProgramOptions & StakePoolMetadataProgramOptions & @@ -140,6 +155,7 @@ export class PgBossHttpService extends HttpService { // This ensures that if an error which can't be retried arrives here is handled as a FATAL error shouldRetry: (error: unknown) => { const retry = isRecoverableError(error); + this.logger.debug('work() shouldRetry', retry, error); this.#health = { ok: false, diff --git a/packages/cardano-services/src/Projection/createTypeormProjection.ts b/packages/cardano-services/src/Projection/createTypeormProjection.ts index 98a4398b982..ddc46eb87cc 100644 --- a/packages/cardano-services/src/Projection/createTypeormProjection.ts +++ b/packages/cardano-services/src/Projection/createTypeormProjection.ts @@ -2,13 +2,13 @@ /* eslint-disable prefer-spread */ import { Cardano } from '@cardano-sdk/core'; import { Logger } from 'ts-log'; -import { Observable, from, switchMap, takeWhile } from 'rxjs'; +import { Observable, takeWhile } from 'rxjs'; import { PgConnectionConfig, TypeormDevOptions, TypeormStabilityWindowBuffer, WithTypeormContext, - createDataSource, + createObservableConnection, isRecoverableTypeormError, typeormTransactionCommit, withTypeormTransaction @@ -44,47 +44,6 @@ const applyStores = (evt$: Observable) => evt$.pipe.apply(evt$, selectedStores as any) as Observable; -export const createObservableDataSource = ({ - connectionConfig$, - logger, - buffer, - devOptions, - entities, - extensions, - migrationsRun -}: Omit< - CreateTypeormProjectionProps, - 'blocksBufferLength' | 'exitAtBlockNo' | 'projections' | 'projectionSource$' | 'projectionOptions' -> & - Pick & { migrationsRun: boolean }) => - connectionConfig$.pipe( - switchMap((connectionConfig) => - from( - (async () => { - const dataSource = createDataSource({ - connectionConfig, - devOptions, - entities, - extensions, - logger, - options: { - installExtensions: true, - migrations: migrations.filter(({ entity }) => entities.includes(entity as any)), - migrationsRun: migrationsRun && !devOptions?.synchronize - } - }); - await dataSource.initialize(); - if (buffer) { - const queryRunner = dataSource.createQueryRunner('master'); - await buffer.initialize(queryRunner); - await queryRunner.release(); - } - return dataSource; - })() - ) - ) - ); - /** * Creates a projection observable that applies a sequence of operators * required to project requested `projections` into a postgres database. @@ -116,24 +75,22 @@ export const createTypeormProjection = ({ }, { logger } ); - const dataSource$ = createObservableDataSource({ - buffer, + const connection$ = createObservableConnection({ connectionConfig$, devOptions, entities, extensions, logger, - migrationsRun: true + options: { + installExtensions: true, + migrations: migrations.filter(({ entity }) => entities.includes(entity as any)), + migrationsRun: !devOptions?.synchronize + } }); return projectionSource$.pipe( applyMappers(mappers), shareRetryBackoff( - (evt$) => - evt$.pipe( - withTypeormTransaction({ dataSource$, logger }, extensions), - applyStores(stores), - typeormTransactionCommit() - ), + (evt$) => evt$.pipe(withTypeormTransaction({ connection$ }), applyStores(stores), typeormTransactionCommit()), { shouldRetry: isRecoverableTypeormError } ), requestNext(), diff --git a/packages/cardano-services/test/Program/services/pgboss.test.ts b/packages/cardano-services/test/Program/services/pgboss.test.ts index dc4326051d3..19b692cb8bd 100644 --- a/packages/cardano-services/test/Program/services/pgboss.test.ts +++ b/packages/cardano-services/test/Program/services/pgboss.test.ts @@ -2,6 +2,7 @@ import { BlockEntity, PgConnectionConfig, STAKE_POOL_METADATA_QUEUE, + createDataSource, createPgBossExtension } from '@cardano-sdk/projection-typeorm'; import { Cardano } from '@cardano-sdk/core'; @@ -11,7 +12,7 @@ import { PgBossHttpService, pgBossEntities } from '../../../src/Program/services import { Pool } from 'pg'; import { StakePoolMetadataFetchMode } from '../../../src/Program/options'; import { WorkerHandlerFactoryOptions } from '../../../src/PgBoss'; -import { createObservableDataSource, getConnectionConfig, getPool } from '../../../src'; +import { getConnectionConfig, getPool } from '../../../src/Program/services/postgres'; import { logger } from '@cardano-sdk/util-dev'; const dnsResolver = () => Promise.resolve({ name: 'localhost', port: 5433, priority: 6, weight: 5 }); @@ -70,6 +71,7 @@ jest.mock('@cardano-sdk/projection-typeorm', () => { describe('PgBossHttpService', () => { let connectionConfig$: Observable; + let connectionConfig: PgConnectionConfig; let dataSource: DataSource; let db: Pool; let service: PgBossHttpService | undefined; @@ -87,15 +89,7 @@ describe('PgBossHttpService', () => { }; connectionConfig$ = getConnectionConfig(dnsResolver, 'test', 'StakePool', args); - const dataSource$ = createObservableDataSource({ - connectionConfig$, - devOptions: { dropSchema: true, synchronize: true }, - entities: pgBossEntities, - extensions: { pgBoss: true }, - logger, - migrationsRun: false - }); - dataSource = await firstValueFrom(dataSource$); + connectionConfig = await firstValueFrom(connectionConfig$); const pool = await getPool(dnsResolver, logger, args); @@ -104,8 +98,20 @@ describe('PgBossHttpService', () => { db = pool; }); + beforeEach(async () => { + dataSource = createDataSource({ + connectionConfig, + devOptions: { dropSchema: true, synchronize: true }, + entities: pgBossEntities, + extensions: { pgBoss: true }, + logger + }); + await dataSource.initialize(); + }); + afterEach(async () => { await service?.shutdown(); + await dataSource.destroy().catch(() => void 0); }); it('health check is ok after start with a valid db connection', async () => { @@ -116,7 +122,6 @@ describe('PgBossHttpService', () => { parallelJobs: 3, queues: [] }, - { connectionConfig$, db, logger } ); expect(await service.healthCheck()).toEqual({ ok: false, reason: 'PgBossHttpService not started' }); @@ -132,7 +137,6 @@ describe('PgBossHttpService', () => { let observableResolver = () => {}; let subscriptions = 0; - const connectionConfig = await firstValueFrom(connectionConfig$); const config$ = new Observable((subscriber) => { subscriptions++; @@ -155,6 +159,8 @@ describe('PgBossHttpService', () => { await service.start(); // Insert test block with slot 1 + const queryRunner = dataSource.createQueryRunner(); + await queryRunner.connect(); const blockRepos = dataSource.getRepository(BlockEntity); const block = { hash: 'test', height: 1, slot: 1 }; await blockRepos.insert(block); @@ -165,7 +171,6 @@ describe('PgBossHttpService', () => { expect(await collectStatus()).toEqual({ calls: 0, health: { ok: true }, subscriptions: 1 }); // Schedule a job - const queryRunner = dataSource.createQueryRunner(); const pgboss = createPgBossExtension(queryRunner, logger); await pgboss.send(STAKE_POOL_METADATA_QUEUE, {}, { retryDelay: 1, retryLimit: 100, slot: Cardano.Slot(1) }); await queryRunner.release(); @@ -225,5 +230,7 @@ describe('PgBossHttpService', () => { await testPromises[3]; expect(await collectStatus()).toEqual({ calls: 4, health: { ok: true }, subscriptions: 3 }); + + await dataSource.destroy(); }); }); diff --git a/packages/cardano-services/test/Projection/createTypeormProjection.test.ts b/packages/cardano-services/test/Projection/createTypeormProjection.test.ts index 96c23cb8c8c..623d8da2ac0 100644 --- a/packages/cardano-services/test/Projection/createTypeormProjection.test.ts +++ b/packages/cardano-services/test/Projection/createTypeormProjection.test.ts @@ -14,9 +14,21 @@ import { projectorConnectionConfig, projectorConnectionConfig$ } from '../util'; describe('createTypeormProjection', () => { it('creates a projection to PostgreSQL based on requested projection names', async () => { // Setup - const data = chainSyncData(ChainSyncDataSet.WithMint); - const buffer = new TypeormStabilityWindowBuffer({ allowNonSequentialBlockHeights: true, logger }); const projections = [ProjectionName.UTXO]; + const buffer = new TypeormStabilityWindowBuffer({ allowNonSequentialBlockHeights: true, logger }); + const { entities } = prepareTypeormProjection({ buffer, projections }, { logger }); + const dataSource = createDataSource({ + connectionConfig: projectorConnectionConfig, + devOptions: { dropSchema: true, synchronize: true }, + entities, + logger + }); + await dataSource.initialize(); + const queryRunner = dataSource.createQueryRunner(); + await queryRunner.connect(); + const data = chainSyncData(ChainSyncDataSet.WithMint); + await buffer.initialize(queryRunner); + const projection$ = createTypeormProjection({ blocksBufferLength: 10, buffer, @@ -36,11 +48,6 @@ describe('createTypeormProjection', () => { await lastValueFrom(projection$); // Check data in the database - const { entities } = prepareTypeormProjection({ buffer, projections }, { logger }); - const dataSource = createDataSource({ connectionConfig: projectorConnectionConfig, entities, logger }); - await dataSource.initialize(); - const queryRunner = dataSource.createQueryRunner(); - await queryRunner.connect(); expect(await queryRunner.manager.count(AssetEntity)).toBeGreaterThan(0); expect(await queryRunner.manager.count(TokensEntity)).toBeGreaterThan(0); expect(await queryRunner.manager.count(OutputEntity)).toBeGreaterThan(0); diff --git a/packages/e2e/test/projection/offline-fork.test.ts b/packages/e2e/test/projection/offline-fork.test.ts index a798b12ac51..b6bdcc1c6d2 100644 --- a/packages/e2e/test/projection/offline-fork.test.ts +++ b/packages/e2e/test/projection/offline-fork.test.ts @@ -21,8 +21,9 @@ import { } from '@cardano-sdk/core'; import { ChainSyncDataSet, chainSyncData, logger } from '@cardano-sdk/util-dev'; import { ConnectionConfig } from '@cardano-ogmios/client'; -import { Observable, filter, firstValueFrom, lastValueFrom, of, take, takeWhile, toArray } from 'rxjs'; +import { Observable, defer, filter, firstValueFrom, lastValueFrom, of, take, takeWhile, toArray } from 'rxjs'; import { OgmiosObservableCardanoNode } from '@cardano-sdk/ogmios'; +import { QueryRunner } from 'typeorm'; import { createDatabase } from 'typeorm-extension'; import { getEnv } from '../../src'; @@ -202,6 +203,7 @@ describe('resuming projection when intersection is not local tip', () => { installExtensions: true } }); + let queryRunner: QueryRunner; const getNumberOfLocalStakeKeys = async () => { const repository = dataSource.getRepository(Postgres.StakeKeyEntity); @@ -217,7 +219,8 @@ describe('resuming projection when intersection is not local tip', () => { } }); await dataSource.initialize(); - await buffer.initialize(dataSource.createQueryRunner()); + queryRunner = dataSource.createQueryRunner(); + await buffer.initialize(queryRunner); }); afterAll(() => dataSource.destroy()); @@ -225,7 +228,7 @@ describe('resuming projection when intersection is not local tip', () => { buffer, (evt$) => evt$.pipe( - Postgres.withTypeormTransaction({ dataSource$: of(dataSource), logger }), + Postgres.withTypeormTransaction({ connection$: defer(() => of({ queryRunner })) }), Postgres.storeBlock(), Postgres.storeStakeKeys(), buffer.storeBlockData(), diff --git a/packages/e2e/test/projection/single-tenant-utxo.test.ts b/packages/e2e/test/projection/single-tenant-utxo.test.ts index 716b6358e25..2fe941105af 100644 --- a/packages/e2e/test/projection/single-tenant-utxo.test.ts +++ b/packages/e2e/test/projection/single-tenant-utxo.test.ts @@ -4,7 +4,7 @@ import { Bootstrap, Mappers, ProjectionEvent, requestNext } from '@cardano-sdk/p import { Cardano, ObservableCardanoNode } from '@cardano-sdk/core'; import { ConnectionConfig } from '@cardano-ogmios/client'; import { DataSource, QueryRunner } from 'typeorm'; -import { Observable, filter, firstValueFrom, lastValueFrom, of, scan, takeWhile } from 'rxjs'; +import { Observable, defer, filter, firstValueFrom, lastValueFrom, of, scan, takeWhile } from 'rxjs'; import { OgmiosObservableCardanoNode } from '@cardano-sdk/ogmios'; import { createDatabase, dropDatabase } from 'typeorm-extension'; import { getEnv } from '../../src'; @@ -104,7 +104,7 @@ describe('single-tenant utxo projection', () => { Bootstrap.fromCardanoNode({ blocksBufferLength: 10, buffer, cardanoNode, logger }).pipe( Mappers.withMint(), Mappers.withUtxo(), - Postgres.withTypeormTransaction({ dataSource$: of(dataSource), logger }), + Postgres.withTypeormTransaction({ connection$: defer(() => of({ queryRunner })) }), Postgres.storeBlock(), Postgres.storeAssets(), Postgres.storeUtxo(), @@ -115,7 +115,7 @@ describe('single-tenant utxo projection', () => { const storeUtxo = (evt$: Observable>) => evt$.pipe( - Postgres.withTypeormTransaction({ dataSource$: of(dataSource), logger }), + Postgres.withTypeormTransaction({ connection$: defer(() => of({ queryRunner })) }), Postgres.storeBlock(), Postgres.storeAssets(), Postgres.storeUtxo(), diff --git a/packages/golden-test-generator/src/ChainSyncEvents/chainSyncEvents.ts b/packages/golden-test-generator/src/ChainSyncEvents/chainSyncEvents.ts index dd2229b3fbd..2d97132654d 100644 --- a/packages/golden-test-generator/src/ChainSyncEvents/chainSyncEvents.ts +++ b/packages/golden-test-generator/src/ChainSyncEvents/chainSyncEvents.ts @@ -96,6 +96,7 @@ export const getChainSyncEvents = async ( const header = ogmiosToCore.blockHeader(block); if (!header) return; currentBlock = header.blockNo; + if (onBlock !== undefined) { onBlock(currentBlock); } diff --git a/packages/projection-typeorm/src/createDataSource.ts b/packages/projection-typeorm/src/createDataSource.ts index 549e8a3f70d..8df73bbcb99 100644 --- a/packages/projection-typeorm/src/createDataSource.ts +++ b/packages/projection-typeorm/src/createDataSource.ts @@ -1,8 +1,10 @@ import 'reflect-metadata'; import { DataSource, DataSourceOptions, DefaultNamingStrategy, NamingStrategyInterface, QueryRunner } from 'typeorm'; import { Logger } from 'ts-log'; -import { contextLogger, patchObject } from '@cardano-sdk/util'; -import { createPgBoss } from './pgBoss'; +import { NEVER, Observable, concat, from, share, switchMap } from 'rxjs'; +import { PgBossExtension, createPgBoss, createPgBossExtension } from './pgBoss'; +import { WithLogger, contextLogger, patchObject } from '@cardano-sdk/util'; +import { finalizeWithLatest } from '@cardano-sdk/util-rxjs'; import { typeormLogger } from './logger'; import snakeCase from 'lodash/snakeCase'; @@ -157,3 +159,87 @@ export const createDataSource = ({ } }); }; + +export type CreateObservableDataSourceProps = Omit & { + connectionConfig$: Observable; +}; + +export const createObservableDataSource = ({ connectionConfig$, ...rest }: CreateObservableDataSourceProps) => + connectionConfig$.pipe( + switchMap((connectionConfig) => + concat( + from( + (async () => { + const dataSource = createDataSource({ + connectionConfig, + ...rest + }); + await dataSource.initialize(); + return dataSource; + })() + ), + NEVER + ).pipe( + finalizeWithLatest(async (dataSource) => { + try { + await dataSource?.destroy(); + } catch (error) { + rest.logger.error('Failed to destroy data source', error); + } + }) + ) + ) + ); + +export interface TypeormConnection { + queryRunner: QueryRunner; + pgBoss?: PgBossExtension; +} + +const releaseConnection = + ({ logger }: WithLogger) => + async (connection: TypeormConnection | null) => { + if (!connection) return; + if (connection.queryRunner.isTransactionActive) { + try { + await connection.queryRunner.rollbackTransaction(); + } catch (error) { + logger.error('Failed to rollback transaction', error); + } + } + if (!connection.queryRunner.isReleased) { + try { + await connection.queryRunner.release(); + } catch (error) { + logger.error('Failed to "release" query runner', error); + } + } + }; + +export type ConnectProps = Pick; + +const createConnection = async (dataSource: DataSource, { logger, extensions }: ConnectProps) => { + const queryRunner = dataSource.createQueryRunner('master'); + await queryRunner.connect(); + if (extensions?.pgBoss) { + const pgBoss = createPgBossExtension(queryRunner, logger); + return { pgBoss, queryRunner }; + } + return { queryRunner }; +}; + +export const connect = + ({ logger, extensions }: ConnectProps) => + (dataSource$: Observable) => { + const sharedSource$ = dataSource$.pipe(share()); + return sharedSource$.pipe( + switchMap((dataSource) => + concat(from(createConnection(dataSource, { extensions, logger })), NEVER).pipe( + finalizeWithLatest(releaseConnection({ logger })) + ) + ) + ); + }; + +export const createObservableConnection = (props: CreateObservableDataSourceProps): Observable => + createObservableDataSource(props).pipe(connect(props)); diff --git a/packages/projection-typeorm/src/operators/withTypeormTransaction.ts b/packages/projection-typeorm/src/operators/withTypeormTransaction.ts index 18711e8cc9e..18eb0e0ea35 100644 --- a/packages/projection-typeorm/src/operators/withTypeormTransaction.ts +++ b/packages/projection-typeorm/src/operators/withTypeormTransaction.ts @@ -1,8 +1,6 @@ /* eslint-disable func-style */ -import { DataSource, QueryRunner } from 'typeorm'; -import { DataSourceExtensions } from '../createDataSource'; -import { NEVER, Observable, Subject, concat, defer, from, map, mergeMap, switchMap, tap } from 'rxjs'; -import { PgBossExtension, createPgBossExtension } from '../pgBoss'; +import { Observable, Subject, defer, from, map, mergeMap, tap } from 'rxjs'; +import { PgBossExtension } from '../pgBoss'; import { ProjectionEvent, UnifiedExtChainSyncObservable, @@ -10,12 +8,12 @@ import { withEventContext, withStaticContext } from '@cardano-sdk/projection'; -import { WithLogger } from '@cardano-sdk/util'; -import { finalizeWithLatest } from '@cardano-sdk/util-rxjs'; +import { QueryRunner } from 'typeorm'; +import { TypeormConnection } from '../createDataSource'; import omit from 'lodash/omit'; -export interface WithTypeormTransactionDependencies extends WithLogger { - dataSource$: Observable; +export interface WithTypeormTransactionDependencies { + connection$: Observable; } export interface WithTypeormContext { @@ -31,68 +29,28 @@ type TypeormContextProp = keyof (WithTypeormContext & WithPgBoss); const WithTypeormTransactionProps: Array = ['queryRunner', 'transactionCommitted$', 'pgBoss']; export function withTypeormTransaction( - dependencies: WithTypeormTransactionDependencies + dependencies: WithTypeormTransactionDependencies & { pgBoss?: false } ): UnifiedExtChainSyncOperator; + export function withTypeormTransaction( - dependencies: WithTypeormTransactionDependencies, - extensions: { pgBoss: true } + dependencies: WithTypeormTransactionDependencies & { pgBoss: true } ): UnifiedExtChainSyncOperator; -export function withTypeormTransaction( - dependencies: WithTypeormTransactionDependencies, - extensions: DataSourceExtensions -): UnifiedExtChainSyncOperator; + /** * Start a PostgreSQL transaction for each event. * * {pgBoss: true} also adds {@link WithPgBoss} context. */ -export function withTypeormTransaction( - { dataSource$, logger }: WithTypeormTransactionDependencies, - extensions?: DataSourceExtensions -): UnifiedExtChainSyncOperator> { +export function withTypeormTransaction({ + connection$ +}: WithTypeormTransactionDependencies & { pgBoss?: boolean }): UnifiedExtChainSyncOperator< + Props, + Props & WithTypeormContext & Partial +> { // eslint-disable-next-line sonarjs/cognitive-complexity return (evt$: UnifiedExtChainSyncObservable) => evt$.pipe( - withStaticContext( - defer(() => - dataSource$.pipe( - switchMap((dataSource) => - concat( - from( - (async () => { - const queryRunner = dataSource.createQueryRunner('master'); - await queryRunner.connect(); - if (extensions?.pgBoss) { - const pgBoss = createPgBossExtension(queryRunner, logger); - return { pgBoss, queryRunner }; - } - return { queryRunner }; - })() - ), - NEVER - ).pipe( - finalizeWithLatest(async (evt) => { - if (!evt) return; - if (evt.queryRunner.isTransactionActive) { - try { - await evt.queryRunner.rollbackTransaction(); - } catch (error) { - logger.error('Failed to rollback transaction', error); - } - } - if (!evt.queryRunner.isReleased) { - try { - await evt.queryRunner.release(); - } catch (error) { - logger.error('Failed to "release" query runner', error); - } - } - }) - ) - ) - ) - ) - ), + withStaticContext(defer(() => connection$)), withEventContext(({ queryRunner }) => from( // - transactionCommitted$.next is called after COMMIT, it is diff --git a/packages/projection-typeorm/test/TypeormStabilityWindowBuffer.test.ts b/packages/projection-typeorm/test/TypeormStabilityWindowBuffer.test.ts index d4cdc896fac..5b307771e74 100644 --- a/packages/projection-typeorm/test/TypeormStabilityWindowBuffer.test.ts +++ b/packages/projection-typeorm/test/TypeormStabilityWindowBuffer.test.ts @@ -2,6 +2,7 @@ import { BlockDataEntity, BlockEntity, TypeormStabilityWindowBuffer, + createObservableConnection, storeBlock, typeormTransactionCommit, withTypeormTransaction @@ -22,11 +23,12 @@ import { takeWhile, toArray } from 'rxjs'; -import { initializeDataSource } from './util'; +import { connectionConfig$, initializeDataSource } from './util'; const { cardanoNode, networkInfo } = chainSyncData(ChainSyncDataSet.WithStakeKeyDeregistration); describe('TypeormStabilityWindowBuffer', () => { + const entities = [BlockEntity, BlockDataEntity]; const securityParameter = 50; const compactBufferEveryNBlocks = 100; @@ -41,7 +43,7 @@ describe('TypeormStabilityWindowBuffer', () => { const getHeader = (tipOrTail: Cardano.Block | 'origin') => (tipOrTail as Cardano.Block).header; beforeEach(async () => { - dataSource = await initializeDataSource({ entities: [BlockEntity, BlockDataEntity] }); + dataSource = await initializeDataSource({ entities }); queryRunner = dataSource.createQueryRunner(); buffer = new TypeormStabilityWindowBuffer({ allowNonSequentialBlockHeights: false, @@ -62,7 +64,7 @@ describe('TypeormStabilityWindowBuffer', () => { }, logger }).pipe( - withTypeormTransaction({ dataSource$: of(dataSource), logger }), + withTypeormTransaction({ connection$: createObservableConnection({ connectionConfig$, entities, logger }) }), storeBlock(), buffer.storeBlockData(), typeormTransactionCommit(), diff --git a/packages/projection-typeorm/test/operators/storeAddresses.test.ts b/packages/projection-typeorm/test/operators/storeAddresses.test.ts index da43980b1c7..b7d945a95df 100644 --- a/packages/projection-typeorm/test/operators/storeAddresses.test.ts +++ b/packages/projection-typeorm/test/operators/storeAddresses.test.ts @@ -8,6 +8,7 @@ import { StakeKeyRegistrationEntity, TokensEntity, TypeormStabilityWindowBuffer, + createObservableConnection, storeAddresses, storeAssets, storeBlock, @@ -25,8 +26,9 @@ import { generateRandomHexString, logger } from '@cardano-sdk/util-dev'; -import { Observable, defer, firstValueFrom, from } from 'rxjs'; +import { Observable, firstValueFrom } from 'rxjs'; import { QueryRunner, Repository } from 'typeorm'; +import { connectionConfig$, initializeDataSource } from '../util'; import { createProjectorTilFirst, createRollForwardEventBasedOn, @@ -34,7 +36,6 @@ import { createStubProjectionSource, createStubRollForwardEvent } from './util'; -import { initializeDataSource } from '../util'; const isAddressWithBothCredentials = (addr: Mappers.Address) => typeof addr.stakeCredential === 'string' && !!addr.paymentCredentialHash; @@ -55,17 +56,13 @@ describe('storeAddresses', () => { ]; let addressesRepo: Repository; - const dataSource$ = defer(() => - from(initializeDataSource({ devOptions: { dropSchema: false, synchronize: false }, entities })) - ); - const storeData = ( evt$: Observable< ProjectionEvent > ) => evt$.pipe( - withTypeormTransaction({ dataSource$, logger }), + withTypeormTransaction({ connection$: createObservableConnection({ connectionConfig$, entities, logger }) }), storeBlock(), storeAssets(), storeUtxo(), diff --git a/packages/projection-typeorm/test/operators/storeAssets.test.ts b/packages/projection-typeorm/test/operators/storeAssets.test.ts index d297d38ded2..703bad46826 100644 --- a/packages/projection-typeorm/test/operators/storeAssets.test.ts +++ b/packages/projection-typeorm/test/operators/storeAssets.test.ts @@ -4,6 +4,7 @@ import { BlockEntity, NftMetadataEntity, TypeormStabilityWindowBuffer, + createObservableConnection, storeAssets, storeBlock, typeormTransactionCommit, @@ -12,24 +13,21 @@ import { import { Bootstrap, Mappers, requestNext } from '@cardano-sdk/projection'; import { Cardano, ChainSyncEventType } from '@cardano-sdk/core'; import { ChainSyncDataSet, chainSyncData, logger } from '@cardano-sdk/util-dev'; -import { DataSource, QueryRunner } from 'typeorm'; -import { Observable, of } from 'rxjs'; +import { QueryRunner } from 'typeorm'; +import { connectionConfig$, initializeDataSource } from '../util'; import { createProjectorTilFirst } from './util'; -import { initializeDataSource } from '../util'; describe('storeAssets', () => { const stubEvents = chainSyncData(ChainSyncDataSet.WithMint); let queryRunner: QueryRunner; let buffer: TypeormStabilityWindowBuffer; - let dataSource$: Observable; const entities = [BlockEntity, BlockDataEntity, AssetEntity, NftMetadataEntity]; const project$ = () => Bootstrap.fromCardanoNode({ blocksBufferLength: 10, buffer, cardanoNode: stubEvents.cardanoNode, logger }).pipe( Mappers.withMint(), withTypeormTransaction({ - dataSource$, - logger + connection$: createObservableConnection({ connectionConfig$, entities, logger }) }), storeBlock(), storeAssets(), @@ -42,7 +40,6 @@ describe('storeAssets', () => { beforeEach(async () => { const dataSource = await initializeDataSource({ entities }); - dataSource$ = of(dataSource); queryRunner = dataSource.createQueryRunner(); buffer = new TypeormStabilityWindowBuffer({ allowNonSequentialBlockHeights: true, logger }); await buffer.initialize(queryRunner); diff --git a/packages/projection-typeorm/test/operators/storeHandleMetadata.test.ts b/packages/projection-typeorm/test/operators/storeHandleMetadata.test.ts index e3352d78983..64d5e7ab669 100644 --- a/packages/projection-typeorm/test/operators/storeHandleMetadata.test.ts +++ b/packages/projection-typeorm/test/operators/storeHandleMetadata.test.ts @@ -7,6 +7,7 @@ import { OutputEntity, TokensEntity, TypeormStabilityWindowBuffer, + createObservableConnection, storeAssets, storeBlock, storeHandleMetadata, @@ -17,10 +18,10 @@ import { import { Bootstrap, Mappers, ProjectionEvent, requestNext } from '@cardano-sdk/projection'; import { Cardano, ObservableCardanoNode } from '@cardano-sdk/core'; import { ChainSyncDataSet, chainSyncData, logger } from '@cardano-sdk/util-dev'; -import { Observable, defer, firstValueFrom, from } from 'rxjs'; +import { Observable, firstValueFrom } from 'rxjs'; import { QueryRunner, Repository } from 'typeorm'; +import { connectionConfig$, initializeDataSource } from '../util'; import { createProjectorTilFirst, createRollBackwardEventFor, createStubProjectionSource } from './util'; -import { initializeDataSource } from '../util'; describe('storeHandleMetadata', () => { const eventsWithCip68Handle = chainSyncData(ChainSyncDataSet.WithInlineDatum); @@ -40,15 +41,11 @@ describe('storeHandleMetadata', () => { HandleMetadataEntity ]; - const dataSource$ = defer(() => - from(initializeDataSource({ devOptions: { dropSchema: false, synchronize: false }, entities })) - ); - const storeData = ( evt$: Observable> ) => evt$.pipe( - withTypeormTransaction({ dataSource$, logger }), + withTypeormTransaction({ connection$: createObservableConnection({ connectionConfig$, entities, logger }) }), storeBlock(), storeAssets(), storeUtxo(), diff --git a/packages/projection-typeorm/test/operators/storeHandles/util.ts b/packages/projection-typeorm/test/operators/storeHandles/util.ts index c6d6c70a8a0..0b539c25a81 100644 --- a/packages/projection-typeorm/test/operators/storeHandles/util.ts +++ b/packages/projection-typeorm/test/operators/storeHandles/util.ts @@ -10,6 +10,7 @@ import { StakeKeyRegistrationEntity, TokensEntity, TypeormStabilityWindowBuffer, + createObservableConnection, storeAddresses, storeAssets, storeBlock, @@ -22,9 +23,9 @@ import { import { Bootstrap, Mappers, ProjectionEvent, requestNext } from '@cardano-sdk/projection'; import { Cardano, ChainSyncEventType } from '@cardano-sdk/core'; import { ChainSyncDataSet, chainSyncData, logger, mockProviders } from '@cardano-sdk/util-dev'; -import { Observable, defer, from } from 'rxjs'; +import { Observable } from 'rxjs'; +import { connectionConfig$ } from '../../util'; import { createProjectorTilFirst, createStubProjectionSource } from '../util'; -import { initializeDataSource } from '../../util'; export const stubEvents = chainSyncData(ChainSyncDataSet.WithHandle); export const policyId = Cardano.PolicyId('f0ff48bbb7bbe9d59a40f1ce90e9e9d0ff5002ec48f232b49ca0fb9a'); @@ -70,10 +71,6 @@ export const entities = [ NftMetadataEntity ]; -const dataSource$ = defer(() => - from(initializeDataSource({ devOptions: { dropSchema: false, synchronize: false }, entities })) -); - const storeData = (buffer: TypeormStabilityWindowBuffer) => ( @@ -84,7 +81,9 @@ const storeData = > ) => evt$.pipe( - withTypeormTransaction({ dataSource$, logger }), + withTypeormTransaction({ + connection$: createObservableConnection({ connectionConfig$, entities, logger }) + }), storeBlock(), storeAssets(), storeUtxo(), diff --git a/packages/projection-typeorm/test/operators/storeNftMetadata.test.ts b/packages/projection-typeorm/test/operators/storeNftMetadata.test.ts index 875aa6c155a..fa371dca7ac 100644 --- a/packages/projection-typeorm/test/operators/storeNftMetadata.test.ts +++ b/packages/projection-typeorm/test/operators/storeNftMetadata.test.ts @@ -8,6 +8,7 @@ import { OutputEntity, TokensEntity, TypeormStabilityWindowBuffer, + createObservableConnection, storeAssets, storeBlock, storeNftMetadata, @@ -17,8 +18,9 @@ import { } from '../../src'; import { Bootstrap, Mappers, ProjectionEvent, requestNext } from '@cardano-sdk/projection'; import { ChainSyncDataSet, chainSyncData, generateRandomHexString, logger } from '@cardano-sdk/util-dev'; -import { Observable, defer, firstValueFrom, from, lastValueFrom, toArray } from 'rxjs'; +import { Observable, firstValueFrom, lastValueFrom, toArray } from 'rxjs'; import { QueryRunner, Repository } from 'typeorm'; +import { connectionConfig$, initializeDataSource } from '../util'; import { createProjectorTilFirst, createRollBackwardEventFor, @@ -28,7 +30,6 @@ import { createStubRollForwardEvent } from './util'; import { dummyLogger } from 'ts-log'; -import { initializeDataSource } from '../util'; import omit from 'lodash/omit'; const patchNftMetadataNameCip25 = ( @@ -184,15 +185,11 @@ describe('storeNftMetadata', () => { let buffer: TypeormStabilityWindowBuffer; const entities = [BlockEntity, BlockDataEntity, AssetEntity, TokensEntity, OutputEntity, NftMetadataEntity]; - const dataSource$ = defer(() => - from(initializeDataSource({ devOptions: { dropSchema: false, synchronize: false }, entities })) - ); - const storeData = ( evt$: Observable> ) => evt$.pipe( - withTypeormTransaction({ dataSource$, logger }), + withTypeormTransaction({ connection$: createObservableConnection({ connectionConfig$, entities, logger }) }), storeBlock(), storeAssets(), storeUtxo(), diff --git a/packages/projection-typeorm/test/operators/storeStakeKeyRegistrations.test.ts b/packages/projection-typeorm/test/operators/storeStakeKeyRegistrations.test.ts index 25570b1c5d2..a10aeb98db6 100644 --- a/packages/projection-typeorm/test/operators/storeStakeKeyRegistrations.test.ts +++ b/packages/projection-typeorm/test/operators/storeStakeKeyRegistrations.test.ts @@ -4,6 +4,7 @@ import { StakeKeyRegistrationEntity, TypeormStabilityWindowBuffer, certificatePointerToId, + createObservableConnection, storeBlock, storeStakeKeyRegistrations, typeormTransactionCommit, @@ -12,12 +13,13 @@ import { import { Bootstrap, Mappers, ProjectionEvent, requestNext } from '@cardano-sdk/projection'; import { ChainSyncDataSet, chainSyncData, logger } from '@cardano-sdk/util-dev'; import { DataSource, QueryRunner, Repository } from 'typeorm'; -import { Observable, firstValueFrom, of, pairwise, takeWhile } from 'rxjs'; +import { Observable, firstValueFrom, pairwise, takeWhile } from 'rxjs'; +import { connectionConfig$, initializeDataSource } from '../util'; import { createProjectorTilFirst, createRollBackwardEventFor, createStubProjectionSource } from './util'; -import { initializeDataSource } from '../util'; describe('storeStakeKeyRegistrations', () => { const data = chainSyncData(ChainSyncDataSet.WithPoolRetirement); + const entities = [BlockDataEntity, BlockEntity, StakeKeyRegistrationEntity]; let stakeKeyRegistrationsRepo: Repository; let dataSource: DataSource; let queryRunner: QueryRunner; @@ -27,7 +29,7 @@ describe('storeStakeKeyRegistrations', () => { evt$.pipe( Mappers.withCertificates(), Mappers.withStakeKeyRegistrations(), - withTypeormTransaction({ dataSource$: of(dataSource), logger }), + withTypeormTransaction({ connection$: createObservableConnection({ connectionConfig$, entities, logger }) }), storeBlock(), storeStakeKeyRegistrations(), buffer.storeBlockData(), @@ -45,9 +47,7 @@ describe('storeStakeKeyRegistrations', () => { const projectTilFirst = createProjectorTilFirst(project); beforeEach(async () => { - dataSource = await initializeDataSource({ - entities: [BlockDataEntity, BlockEntity, StakeKeyRegistrationEntity] - }); + dataSource = await initializeDataSource({ entities }); queryRunner = dataSource.createQueryRunner(); stakeKeyRegistrationsRepo = queryRunner.manager.getRepository(StakeKeyRegistrationEntity); buffer = new TypeormStabilityWindowBuffer({ allowNonSequentialBlockHeights: true, logger }); diff --git a/packages/projection-typeorm/test/operators/storeStakePoolMetadataJob.test.ts b/packages/projection-typeorm/test/operators/storeStakePoolMetadataJob.test.ts index f5b55e00bfe..500a6f99e6e 100644 --- a/packages/projection-typeorm/test/operators/storeStakePoolMetadataJob.test.ts +++ b/packages/projection-typeorm/test/operators/storeStakePoolMetadataJob.test.ts @@ -3,6 +3,7 @@ import { BlockEntity, STAKE_POOL_METADATA_QUEUE, TypeormStabilityWindowBuffer, + createObservableConnection, storeBlock, storeStakePoolMetadataJob, typeormTransactionCommit, @@ -13,9 +14,9 @@ import { ChainSyncDataSet, chainSyncData, logger } from '@cardano-sdk/util-dev'; import { ChainSyncEventType } from '@cardano-sdk/core'; import { QueryRunner } from 'typeorm'; import { StakePoolMetadataJob, createPgBoss } from '../../src/pgBoss'; +import { connectionConfig, initializeDataSource } from '../util'; import { createProjectorTilFirst } from './util'; -import { defer, filter, from } from 'rxjs'; -import { initializeDataSource } from '../util'; +import { filter, of } from 'rxjs'; const testPromise = () => { let resolvePromise: Function; @@ -39,20 +40,15 @@ describe('storeStakePoolMetadataJob', () => { }), Mappers.withCertificates(), Mappers.withStakePools(), - withTypeormTransaction( - { - dataSource$: defer(() => - from( - initializeDataSource({ - entities: [BlockEntity, BlockDataEntity], - extensions: { pgBoss: true } - }) - ) - ), + withTypeormTransaction({ + connection$: createObservableConnection({ + connectionConfig$: of(connectionConfig), + entities: [BlockEntity, BlockDataEntity], + extensions: { pgBoss: true }, logger - }, - { pgBoss: true } - ), + }), + pgBoss: true + }), storeBlock(), storeStakePoolMetadataJob(), buffer.storeBlockData(), diff --git a/packages/projection-typeorm/test/operators/storeStakePools.test.ts b/packages/projection-typeorm/test/operators/storeStakePools.test.ts index 97a95727579..b2605a7ba09 100644 --- a/packages/projection-typeorm/test/operators/storeStakePools.test.ts +++ b/packages/projection-typeorm/test/operators/storeStakePools.test.ts @@ -7,6 +7,7 @@ import { PoolRetirementEntity, StakePoolEntity, TypeormStabilityWindowBuffer, + createObservableConnection, storeBlock, storeStakePools, typeormTransactionCommit, @@ -16,12 +17,20 @@ import { Bootstrap, Mappers, requestNext } from '@cardano-sdk/projection'; import { Cardano, ChainSyncEventType } from '@cardano-sdk/core'; import { ChainSyncDataSet, chainSyncData, logger } from '@cardano-sdk/util-dev'; import { DataSource, QueryRunner, Repository } from 'typeorm'; +import { connectionConfig$, initializeDataSource } from '../util'; import { createProjectorTilFirst } from './util'; -import { initializeDataSource } from '../util'; -import { of } from 'rxjs'; describe('storeStakePools', () => { const data = chainSyncData(ChainSyncDataSet.WithPoolRetirement); + const entities = [ + BlockDataEntity, + BlockEntity, + CurrentPoolMetricsEntity, + PoolRegistrationEntity, + PoolRetirementEntity, + StakePoolEntity, + PoolMetadataEntity + ]; let poolsRepo: Repository; let dataSource: DataSource; let queryRunner: QueryRunner; @@ -30,7 +39,7 @@ describe('storeStakePools', () => { Bootstrap.fromCardanoNode({ blocksBufferLength: 10, buffer, cardanoNode: data.cardanoNode, logger }).pipe( Mappers.withCertificates(), Mappers.withStakePools(), - withTypeormTransaction({ dataSource$: of(dataSource), logger }), + withTypeormTransaction({ connection$: createObservableConnection({ connectionConfig$, entities, logger }) }), storeBlock(), storeStakePools(), buffer.storeBlockData(), @@ -53,17 +62,7 @@ describe('storeStakePools', () => { }; beforeEach(async () => { - dataSource = await initializeDataSource({ - entities: [ - BlockDataEntity, - BlockEntity, - CurrentPoolMetricsEntity, - PoolRegistrationEntity, - PoolRetirementEntity, - StakePoolEntity, - PoolMetadataEntity - ] - }); + dataSource = await initializeDataSource({ entities }); queryRunner = dataSource.createQueryRunner(); poolsRepo = queryRunner.manager.getRepository(StakePoolEntity); buffer = new TypeormStabilityWindowBuffer({ allowNonSequentialBlockHeights: true, logger }); diff --git a/packages/projection-typeorm/test/operators/storeUtxo.test.ts b/packages/projection-typeorm/test/operators/storeUtxo.test.ts index 9c272b0f08a..9b746e84cad 100644 --- a/packages/projection-typeorm/test/operators/storeUtxo.test.ts +++ b/packages/projection-typeorm/test/operators/storeUtxo.test.ts @@ -6,6 +6,7 @@ import { OutputEntity, TokensEntity, TypeormStabilityWindowBuffer, + createObservableConnection, storeAssets, storeBlock, storeUtxo, @@ -16,9 +17,8 @@ import { Bootstrap, Mappers, requestNext } from '@cardano-sdk/projection'; import { Cardano, ChainSyncEventType } from '@cardano-sdk/core'; import { ChainSyncDataSet, chainSyncData, logger } from '@cardano-sdk/util-dev'; import { IsNull, Not, QueryRunner } from 'typeorm'; +import { connectionConfig$, initializeDataSource } from '../util'; import { createProjectorTilFirst } from './util'; -import { defer, from } from 'rxjs'; -import { initializeDataSource } from '../util'; describe('storeUtxo', () => { const stubEvents = chainSyncData(ChainSyncDataSet.WithMint); @@ -31,8 +31,7 @@ describe('storeUtxo', () => { Mappers.withMint(), Mappers.withUtxo(), withTypeormTransaction({ - dataSource$: defer(() => from(initializeDataSource({ entities }))), - logger + connection$: createObservableConnection({ connectionConfig$, entities, logger }) }), storeBlock(), storeAssets(), diff --git a/packages/projection-typeorm/test/operators/withTypeormTransaction.test.ts b/packages/projection-typeorm/test/operators/withTypeormTransaction.test.ts index b49b3598c4a..1e143217a49 100644 --- a/packages/projection-typeorm/test/operators/withTypeormTransaction.test.ts +++ b/packages/projection-typeorm/test/operators/withTypeormTransaction.test.ts @@ -3,6 +3,7 @@ import { BlockDataEntity, BlockEntity, TypeormStabilityWindowBuffer, + connect, isRecoverableTypeormError, storeBlock, typeormTransactionCommit, @@ -64,8 +65,7 @@ describe('withTypeormTransaction', () => { jest.fn((evt$: Observable) => evt$.pipe( withTypeormTransaction({ - dataSource$, - logger + connection$: dataSource$.pipe(connect({ logger })) }), storeBlock(), buffer.storeBlockData(), diff --git a/packages/projection-typeorm/test/util.ts b/packages/projection-typeorm/test/util.ts index d2f616863fe..4e3ef93fec6 100644 --- a/packages/projection-typeorm/test/util.ts +++ b/packages/projection-typeorm/test/util.ts @@ -1,4 +1,5 @@ import { CreateDataSourceProps, createDataSource } from '../src'; +import { NEVER, concat, of } from 'rxjs'; import { logger } from '@cardano-sdk/util-dev'; export const connectionConfig = { @@ -9,6 +10,8 @@ export const connectionConfig = { username: 'postgres' }; +export const connectionConfig$ = concat(of(connectionConfig), NEVER); + export const initializeDataSource = async ( props: Pick ) => { From 704b5d6c82e6290c5f08800311d36d1b5d7a1eeb Mon Sep 17 00:00:00 2001 From: Martynas Kazlauskas Date: Fri, 13 Oct 2023 09:48:41 +0300 Subject: [PATCH 2/4] refactor!: hoist ReconnectionConfig type from ogmios to util-rxjs --- packages/ogmios/package.json | 1 + .../createObservableInteractionContext.ts | 5 ++--- packages/ogmios/src/tsconfig.json | 3 +++ packages/util-rxjs/src/types.ts | 3 +++ yarn.lock | 2 ++ 5 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/ogmios/package.json b/packages/ogmios/package.json index e49015e4520..93cba5c0fce 100644 --- a/packages/ogmios/package.json +++ b/packages/ogmios/package.json @@ -41,6 +41,7 @@ "@cardano-ogmios/schema": "5.6.0", "@cardano-sdk/cardano-services-client": "workspace:~", "@cardano-sdk/util-dev": "workspace:~", + "@cardano-sdk/util-rxjs": "workspace:~", "@types/lodash": "^4.14.182", "delay": "^5.0.0", "eslint": "^7.32.0", diff --git a/packages/ogmios/src/CardanoNode/OgmiosObservableCardanoNode/createObservableInteractionContext.ts b/packages/ogmios/src/CardanoNode/OgmiosObservableCardanoNode/createObservableInteractionContext.ts index 559ecfc0e3c..4e73ac9e034 100644 --- a/packages/ogmios/src/CardanoNode/OgmiosObservableCardanoNode/createObservableInteractionContext.ts +++ b/packages/ogmios/src/CardanoNode/OgmiosObservableCardanoNode/createObservableInteractionContext.ts @@ -6,10 +6,9 @@ import { createInteractionContext } from '@cardano-ogmios/client'; import { Observable, switchMap } from 'rxjs'; -import { RetryBackoffConfig, retryBackoff } from 'backoff-rxjs'; import { WithLogger, contextLogger, isConnectionError } from '@cardano-sdk/util'; - -export type ReconnectionConfig = Omit; +import { retryBackoff } from 'backoff-rxjs'; +import type { ReconnectionConfig } from '@cardano-sdk/util-rxjs'; const defaultReconnectionConfig: ReconnectionConfig = { initialInterval: 10, maxInterval: 5000 }; diff --git a/packages/ogmios/src/tsconfig.json b/packages/ogmios/src/tsconfig.json index 92e7c7bc679..fc2d46bd822 100644 --- a/packages/ogmios/src/tsconfig.json +++ b/packages/ogmios/src/tsconfig.json @@ -7,6 +7,9 @@ { "path": "../../core/src" }, + { + "path": "../../util-rxjs/src" + }, { "path": "../../crypto/src" } diff --git a/packages/util-rxjs/src/types.ts b/packages/util-rxjs/src/types.ts index e1d06797b09..68c088437f7 100644 --- a/packages/util-rxjs/src/types.ts +++ b/packages/util-rxjs/src/types.ts @@ -1,3 +1,6 @@ import { Observable } from 'rxjs'; +import { RetryBackoffConfig } from 'backoff-rxjs'; export type ObservableType = O extends Observable ? T : unknown; + +export type ReconnectionConfig = Omit; diff --git a/yarn.lock b/yarn.lock index 7eeddc758d2..73b89f542bb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3464,6 +3464,7 @@ __metadata: "@cardano-sdk/crypto": "workspace:~" "@cardano-sdk/util": "workspace:~" "@cardano-sdk/util-dev": "workspace:~" + "@cardano-sdk/util-rxjs": "workspace:~" "@types/lodash": ^4.14.182 backoff-rxjs: ^7.0.0 buffer: 5.7.1 @@ -3495,6 +3496,7 @@ __metadata: "@cardano-sdk/util": "workspace:~" "@cardano-sdk/util-dev": "workspace:~" "@cardano-sdk/util-rxjs": "workspace:~" + backoff-rxjs: ^7.0.0 eslint: ^7.32.0 jest: ^28.1.3 lodash: ^4.17.21 From f7621d7f03b398b584e1f0fb63838dfb39ff0b68 Mon Sep 17 00:00:00 2001 From: Martynas Kazlauskas Date: Fri, 13 Oct 2023 10:08:03 +0300 Subject: [PATCH 3/4] feat(util-dev): add createStubObservable util hoist from wallet TipTracker tests --- packages/util-dev/src/createStubObservable.ts | 13 +++++++++++++ packages/util-dev/src/index.ts | 1 + .../util-dev/test/createStubObservable.test.ts | 15 +++++++++++++++ .../wallet/test/services/TipTracker.test.ts | 18 +++++------------- 4 files changed, 34 insertions(+), 13 deletions(-) create mode 100644 packages/util-dev/src/createStubObservable.ts create mode 100644 packages/util-dev/test/createStubObservable.test.ts diff --git a/packages/util-dev/src/createStubObservable.ts b/packages/util-dev/src/createStubObservable.ts new file mode 100644 index 00000000000..65e0d3598db --- /dev/null +++ b/packages/util-dev/src/createStubObservable.ts @@ -0,0 +1,13 @@ +import { Observable } from 'rxjs'; + +/** + * @returns an Observable that proxies subscriptions to observables provided as arguments. + * Arguments are subscribed to in order they are provided. + */ +export const createStubObservable = (...calls: Observable[]) => { + let numCall = 0; + return new Observable((subscriber) => { + const sub = calls[numCall++].subscribe(subscriber); + return () => sub.unsubscribe(); + }); +}; diff --git a/packages/util-dev/src/index.ts b/packages/util-dev/src/index.ts index 5f0f3bffe3e..9862d24a70b 100644 --- a/packages/util-dev/src/index.ts +++ b/packages/util-dev/src/index.ts @@ -7,6 +7,7 @@ export * from './util'; export * from './createStubStakePoolProvider'; export * from './testScheduler'; export * from './createStubUtxoProvider'; +export * from './createStubObservable'; export * from './createGenericMockServer'; export * from './dataGeneration'; export * from './eraSummaries'; diff --git a/packages/util-dev/test/createStubObservable.test.ts b/packages/util-dev/test/createStubObservable.test.ts new file mode 100644 index 00000000000..5499ba0f2c9 --- /dev/null +++ b/packages/util-dev/test/createStubObservable.test.ts @@ -0,0 +1,15 @@ +import { concat } from 'rxjs'; +import { createStubObservable, createTestScheduler } from '../src'; + +describe('createStubObservable', () => { + it('returns an observable that subscribes to observables provided as arguments in order', () => { + createTestScheduler().run(({ cold, expectObservable, expectSubscriptions }) => { + const a$ = cold('a|'); + const b$ = cold('b|'); + const target$ = createStubObservable(a$, b$); + expectObservable(concat(target$, target$)).toBe('ab|'); + expectSubscriptions(a$.subscriptions).toBe('^!'); + expectSubscriptions(b$.subscriptions).toBe('-^!'); + }); + }); +}); diff --git a/packages/wallet/test/services/TipTracker.test.ts b/packages/wallet/test/services/TipTracker.test.ts index 32b915adbb8..93ed128ed10 100644 --- a/packages/wallet/test/services/TipTracker.test.ts +++ b/packages/wallet/test/services/TipTracker.test.ts @@ -3,17 +3,9 @@ import { ConnectionStatus, TipTracker } from '../../src/services'; import { InMemoryDocumentStore } from '../../src/persistence'; import { Milliseconds, SyncStatus } from '../../src'; import { Observable, firstValueFrom, of } from 'rxjs'; -import { createTestScheduler } from '@cardano-sdk/util-dev'; +import { createStubObservable, createTestScheduler } from '@cardano-sdk/util-dev'; import { dummyLogger } from 'ts-log'; -const stubObservableProvider = (...calls: Observable[]) => { - let numCall = 0; - return new Observable((subscriber) => { - const sub = calls[numCall++].subscribe(subscriber); - return () => sub.unsubscribe(); - }); -}; - const mockTips = { a: { hash: 'ha' }, b: { hash: 'hb' }, @@ -37,7 +29,7 @@ describe('TipTracker', () => { it('calls the provider immediately, only emitting distinct values, with throttling', () => { createTestScheduler().run(({ cold, expectObservable }) => { const syncStatus: Partial = { isSettled$: cold('---a---bc--d|') }; - const provider$ = stubObservableProvider( + const provider$ = createStubObservable( cold('-x|', mockTips), cold('--a|', mockTips), cold('--b|', mockTips), @@ -85,7 +77,7 @@ describe('TipTracker', () => { store.set = jest.fn().mockImplementation(store.set.bind(store)); createTestScheduler().run(({ cold, expectObservable }) => { const syncStatus: Partial = { isSettled$: cold('---a---b|') }; - const provider$ = stubObservableProvider( + const provider$ = createStubObservable( cold('-y|', mockTips), cold('--a|', mockTips), cold('-ab|', mockTips) @@ -108,7 +100,7 @@ describe('TipTracker', () => { it('times out trigger$ with maxPollInterval, then listens for trigger$ again', () => { createTestScheduler().run(({ cold, hot, expectObservable }) => { const syncStatus: Partial = { isSettled$: hot('10ms a|') }; - const provider$ = stubObservableProvider( + const provider$ = createStubObservable( cold('-a|', mockTips), cold('-b|', mockTips), cold('-c|', mockTips) @@ -135,7 +127,7 @@ describe('TipTracker', () => { d: ConnectionStatus.down, p: ConnectionStatus.up }); - const provider$ = stubObservableProvider(cold('x|', mockTips), cold('a|', mockTips)); + const provider$ = createStubObservable(cold('x|', mockTips), cold('a|', mockTips)); const tracker$ = new TipTracker({ connectionStatus$: connectionStatusMock$, logger, From b2244eac56352961c36ef9e80038aead47ee9e52 Mon Sep 17 00:00:00 2001 From: Martynas Kazlauskas Date: Fri, 15 Sep 2023 08:00:33 +0300 Subject: [PATCH 4/4] feat: do not write to stability window buffer til volatile It's implemented by splitting StabilityWindowBuffer into 2 pieces: - Tip observable, which is used to determine local tip - StabilityWindowBuffer is now simplified to just 'getBlock' method BREAKING CHANGE: simplify StabilityWindowBuffer interface to just 'getBlock' - Bootstrap.fromCardanoNode now requires Tip observable parameter --- .../src/Program/programs/projector.ts | 12 +- .../src/Projection/createTypeormProjection.ts | 96 +++++-- .../Projection/prepareTypeormProjection.ts | 10 +- .../createTypeormProjection.test.ts | 46 ++- .../prepareTypeormProjection.test.ts | 36 +-- .../e2e/test/projection/offline-fork.test.ts | 58 ++-- .../projection/single-tenant-utxo.test.ts | 67 +++-- packages/e2e/test/tsconfig.json | 3 + packages/projection-typeorm/package.json | 1 + .../src/TypeormStabilityWindowBuffer.ts | 211 ++++---------- .../src/createDataSource.ts | 12 +- .../src/createTypeormTipTracker.ts | 82 ++++++ packages/projection-typeorm/src/index.ts | 1 + .../src/operators/withTypeormTransaction.ts | 6 +- .../test/TypeormStabilityWindowBuffer.test.ts | 261 +++++++++--------- .../test/createTypeormTipTracker.test.ts | 153 ++++++++++ .../test/operators/storeAddresses.test.ts | 11 +- .../test/operators/storeAssets.test.ts | 19 +- .../operators/storeHandleMetadata.test.ts | 22 +- .../operators/storeHandles/default.test.ts | 33 +-- .../operators/storeHandles/general.test.ts | 22 +- .../operators/storeHandles/ownership.test.ts | 33 +-- .../test/operators/storeHandles/util.ts | 25 +- .../test/operators/storeNftMetadata.test.ts | 11 +- .../storeStakeKeyRegistrations.test.ts | 17 +- .../storeStakePoolMetadataJob.test.ts | 54 ++-- .../test/operators/storeStakePools.test.ts | 18 +- .../test/operators/storeUtxo.test.ts | 29 +- .../projection-typeorm/test/operators/util.ts | 34 +++ .../operators/withTypeormTransaction.test.ts | 39 +-- packages/projection-typeorm/test/util.ts | 17 +- .../src/Bootstrap/fromCardanoNode.ts | 96 +++---- .../InMemory/InMemoryStabilityWindowBuffer.ts | 28 +- packages/projection/src/InMemory/types.ts | 4 +- .../src/operators/withRolledBackBlock.ts | 60 +++- packages/projection/src/types.ts | 11 +- .../InMemoryStabilityWindowBuffer.test.ts | 47 +--- .../test/integration/InMemory.test.ts | 49 ++-- .../operators/withRolledBackBlock.test.ts | 95 ++++++- 39 files changed, 1113 insertions(+), 716 deletions(-) create mode 100644 packages/projection-typeorm/src/createTypeormTipTracker.ts create mode 100644 packages/projection-typeorm/test/createTypeormTipTracker.test.ts diff --git a/packages/cardano-services/src/Program/programs/projector.ts b/packages/cardano-services/src/Program/programs/projector.ts index 88b8d2f51d5..d0c8ee8b125 100644 --- a/packages/cardano-services/src/Program/programs/projector.ts +++ b/packages/cardano-services/src/Program/programs/projector.ts @@ -1,4 +1,3 @@ -import { Bootstrap } from '@cardano-sdk/projection'; import { Cardano } from '@cardano-sdk/core'; import { CommonProgramOptions, OgmiosProgramOptions, PosgresProgramOptions } from '../options'; import { DnsResolver, createDnsResolver } from '../utils'; @@ -12,8 +11,8 @@ import { Logger } from 'ts-log'; import { MissingProgramOption, UnknownServiceName } from '../errors'; import { ProjectionHttpService, ProjectionName, createTypeormProjection, storeOperators } from '../../Projection'; import { SrvRecord } from 'dns'; -import { TypeormStabilityWindowBuffer, createStorePoolMetricsUpdateJob } from '@cardano-sdk/projection-typeorm'; import { createLogger } from 'bunyan'; +import { createStorePoolMetricsUpdateJob } from '@cardano-sdk/projection-typeorm'; import { getConnectionConfig, getOgmiosObservableCardanoNode } from '../services'; export const BLOCKS_BUFFER_LENGTH_DEFAULT = 10; @@ -50,11 +49,10 @@ const createProjectionHttpService = async (options: ProjectionMapFactoryOptions) ogmiosUrl: args.ogmiosUrl }); const connectionConfig$ = getConnectionConfig(dnsResolver, 'projector', '', args); - const buffer = new TypeormStabilityWindowBuffer({ logger }); const { blocksBufferLength, dropSchema, dryRun, exitAtBlockNo, handlePolicyIds, projectionNames, synchronize } = args; const projection$ = createTypeormProjection({ blocksBufferLength, - buffer, + cardanoNode, connectionConfig$, devOptions: { dropSchema, synchronize }, exitAtBlockNo, @@ -62,12 +60,6 @@ const createProjectionHttpService = async (options: ProjectionMapFactoryOptions) projectionOptions: { handlePolicyIds }, - projectionSource$: Bootstrap.fromCardanoNode({ - blocksBufferLength, - buffer, - cardanoNode, - logger - }), projections: projectionNames }); return new ProjectionHttpService({ dryRun, projection$, projectionNames }, { logger }); diff --git a/packages/cardano-services/src/Projection/createTypeormProjection.ts b/packages/cardano-services/src/Projection/createTypeormProjection.ts index ddc46eb87cc..f8b93433e93 100644 --- a/packages/cardano-services/src/Projection/createTypeormProjection.ts +++ b/packages/cardano-services/src/Projection/createTypeormProjection.ts @@ -1,14 +1,17 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable prefer-spread */ -import { Cardano } from '@cardano-sdk/core'; +import { Bootstrap, ProjectionEvent, logProjectionProgress, requestNext } from '@cardano-sdk/projection'; +import { Cardano, ObservableCardanoNode } from '@cardano-sdk/core'; import { Logger } from 'ts-log'; -import { Observable, takeWhile } from 'rxjs'; +import { Observable, concat, defer, take, takeWhile } from 'rxjs'; import { PgConnectionConfig, TypeormDevOptions, + TypeormOptions, TypeormStabilityWindowBuffer, WithTypeormContext, createObservableConnection, + createTypeormTipTracker, isRecoverableTypeormError, typeormTransactionCommit, withTypeormTransaction @@ -19,19 +22,22 @@ import { ProjectionOptions, prepareTypeormProjection } from './prepareTypeormProjection'; -import { ProjectionEvent, logProjectionProgress, requestNext } from '@cardano-sdk/projection'; +import { ReconnectionConfig, passthrough, shareRetryBackoff, toEmpty } from '@cardano-sdk/util-rxjs'; import { migrations } from './migrations'; -import { passthrough, shareRetryBackoff } from '@cardano-sdk/util-rxjs'; + +const reconnectionConfig: ReconnectionConfig = { + initialInterval: 50, + maxInterval: 5000 +}; export interface CreateTypeormProjectionProps { projections: ProjectionName[]; blocksBufferLength: number; - buffer?: TypeormStabilityWindowBuffer; - projectionSource$: Observable; connectionConfig$: Observable; devOptions?: TypeormDevOptions; exitAtBlockNo?: Cardano.BlockNo; logger: Logger; + cardanoNode: ObservableCardanoNode; projectionOptions?: ProjectionOptions; } @@ -54,12 +60,11 @@ const applyStores = export const createTypeormProjection = ({ blocksBufferLength, projections, - projectionSource$, connectionConfig$, logger, - devOptions, + devOptions: requestedDevOptions, + cardanoNode, exitAtBlockNo, - buffer, projectionOptions }: CreateTypeormProjectionProps) => { const { handlePolicyIds } = { handlePolicyIds: [], ...projectionOptions }; @@ -69,32 +74,65 @@ export const createTypeormProjection = ({ const { mappers, entities, stores, extensions } = prepareTypeormProjection( { - buffer, options: projectionOptions, projections }, { logger } ); - const connection$ = createObservableConnection({ - connectionConfig$, - devOptions, - entities, - extensions, + const connect = (options?: TypeormOptions, devOptions?: TypeormDevOptions) => + createObservableConnection({ + connectionConfig$, + devOptions, + entities, + extensions, + logger, + options + }); + + const tipTracker = createTypeormTipTracker({ + connection$: connect(), + reconnectionConfig + }); + const buffer = new TypeormStabilityWindowBuffer({ + connection$: connect(), + logger, + reconnectionConfig + }); + const projectionSource$ = Bootstrap.fromCardanoNode({ + blocksBufferLength, + buffer, + cardanoNode, logger, - options: { - installExtensions: true, - migrations: migrations.filter(({ entity }) => entities.includes(entity as any)), - migrationsRun: !devOptions?.synchronize - } + projectedTip$: tipTracker.tip$ }); - return projectionSource$.pipe( - applyMappers(mappers), - shareRetryBackoff( - (evt$) => evt$.pipe(withTypeormTransaction({ connection$ }), applyStores(stores), typeormTransactionCommit()), - { shouldRetry: isRecoverableTypeormError } - ), - requestNext(), - logProjectionProgress(logger), - exitAtBlockNo ? takeWhile((event) => event.block.header.blockNo < exitAtBlockNo) : passthrough() + return concat( + // initialize database before starting the projector + connect( + { + installExtensions: true, + migrations: migrations.filter(({ entity }) => entities.includes(entity as any)), + migrationsRun: !requestedDevOptions?.synchronize + }, + requestedDevOptions + ).pipe(take(1), toEmpty), + defer(() => + projectionSource$.pipe( + applyMappers(mappers), + shareRetryBackoff( + (evt$) => + evt$.pipe( + withTypeormTransaction({ connection$: connect() }), + applyStores(stores), + buffer.storeBlockData(), + typeormTransactionCommit() + ), + { shouldRetry: isRecoverableTypeormError } + ), + tipTracker.trackProjectedTip(), + requestNext(), + logProjectionProgress(logger), + exitAtBlockNo ? takeWhile((event) => event.block.header.blockNo < exitAtBlockNo) : passthrough() + ) + ) ); }; diff --git a/packages/cardano-services/src/Projection/prepareTypeormProjection.ts b/packages/cardano-services/src/Projection/prepareTypeormProjection.ts index 5b6dd74e6b5..8055c12e84a 100644 --- a/packages/cardano-services/src/Projection/prepareTypeormProjection.ts +++ b/packages/cardano-services/src/Projection/prepareTypeormProjection.ts @@ -15,7 +15,6 @@ import { StakeKeyRegistrationEntity, StakePoolEntity, TokensEntity, - TypeormStabilityWindowBuffer, createStorePoolMetricsUpdateJob, storeAddresses, storeAssets, @@ -139,7 +138,7 @@ type Entity = Entities[EntityName]; const storeEntities: Partial> = { storeAddresses: ['address'], storeAssets: ['asset'], - storeBlock: ['block'], + storeBlock: ['block', 'blockData'], storeHandleMetadata: ['handleMetadata', 'output'], storeHandles: ['handle', 'asset', 'tokens', 'output'], storeNftMetadata: ['asset'], @@ -303,7 +302,6 @@ const keyOf = (obj: T, value: unknown): keyof T | null => { export interface PrepareTypeormProjectionProps { projections: ProjectionName[]; - buffer?: TypeormStabilityWindowBuffer; options?: ProjectionOptions; } @@ -312,7 +310,7 @@ export interface PrepareTypeormProjectionProps { * based on 'projections' and presence of 'buffer': */ export const prepareTypeormProjection = ( - { projections, buffer, options = {} }: PrepareTypeormProjectionProps, + { projections, options = {} }: PrepareTypeormProjectionProps, dependencies: WithLogger ) => { const mapperSorter = new Sorter(); @@ -329,10 +327,6 @@ export const prepareTypeormProjection = ( const selectedEntities = entitySorter.nodes; const selectedMappers = mapperSorter.nodes; const selectedStores = storeSorter.nodes; - if (buffer) { - selectedEntities.push(BlockDataEntity); - selectedStores.push(buffer.storeBlockData()); - } const extensions = requiredExtensions(projections); return { __debug: { diff --git a/packages/cardano-services/test/Projection/createTypeormProjection.test.ts b/packages/cardano-services/test/Projection/createTypeormProjection.test.ts index 623d8da2ac0..ab519cf61f3 100644 --- a/packages/cardano-services/test/Projection/createTypeormProjection.test.ts +++ b/packages/cardano-services/test/Projection/createTypeormProjection.test.ts @@ -1,11 +1,4 @@ -import { - AssetEntity, - OutputEntity, - TokensEntity, - TypeormStabilityWindowBuffer, - createDataSource -} from '@cardano-sdk/projection-typeorm'; -import { Bootstrap } from '@cardano-sdk/projection'; +import { AssetEntity, OutputEntity, TokensEntity, createDataSource } from '@cardano-sdk/projection-typeorm'; import { ChainSyncDataSet, chainSyncData, logger } from '@cardano-sdk/util-dev'; import { ProjectionName, createTypeormProjection, prepareTypeormProjection } from '../../src'; import { lastValueFrom } from 'rxjs'; @@ -13,44 +6,37 @@ import { projectorConnectionConfig, projectorConnectionConfig$ } from '../util'; describe('createTypeormProjection', () => { it('creates a projection to PostgreSQL based on requested projection names', async () => { - // Setup + // Setup projector const projections = [ProjectionName.UTXO]; - const buffer = new TypeormStabilityWindowBuffer({ allowNonSequentialBlockHeights: true, logger }); - const { entities } = prepareTypeormProjection({ buffer, projections }, { logger }); - const dataSource = createDataSource({ - connectionConfig: projectorConnectionConfig, - devOptions: { dropSchema: true, synchronize: true }, - entities, - logger - }); - await dataSource.initialize(); - const queryRunner = dataSource.createQueryRunner(); - await queryRunner.connect(); const data = chainSyncData(ChainSyncDataSet.WithMint); - await buffer.initialize(queryRunner); - const projection$ = createTypeormProjection({ blocksBufferLength: 10, - buffer, + cardanoNode: data.cardanoNode, connectionConfig$: projectorConnectionConfig$, - devOptions: { dropSchema: true, synchronize: true }, + devOptions: { dropSchema: true }, logger, - projectionSource$: Bootstrap.fromCardanoNode({ - blocksBufferLength: 10, - buffer, - cardanoNode: data.cardanoNode, - logger - }), projections }); // Project await lastValueFrom(projection$); + // Setup query runner for assertions + const { entities } = prepareTypeormProjection({ projections }, { logger }); + const dataSource = createDataSource({ + connectionConfig: projectorConnectionConfig, + entities, + logger + }); + await dataSource.initialize(); + const queryRunner = dataSource.createQueryRunner(); + await queryRunner.connect(); + // Check data in the database expect(await queryRunner.manager.count(AssetEntity)).toBeGreaterThan(0); expect(await queryRunner.manager.count(TokensEntity)).toBeGreaterThan(0); expect(await queryRunner.manager.count(OutputEntity)).toBeGreaterThan(0); + await queryRunner.release(); await dataSource.destroy(); }); diff --git a/packages/cardano-services/test/Projection/prepareTypeormProjection.test.ts b/packages/cardano-services/test/Projection/prepareTypeormProjection.test.ts index 51b488dc5b2..09d91ea2c9f 100644 --- a/packages/cardano-services/test/Projection/prepareTypeormProjection.test.ts +++ b/packages/cardano-services/test/Projection/prepareTypeormProjection.test.ts @@ -1,40 +1,30 @@ import { ProjectionName, prepareTypeormProjection } from '../../src'; -import { TypeormStabilityWindowBuffer } from '@cardano-sdk/projection-typeorm'; import { dummyLogger } from 'ts-log'; -const prepare = (projections: ProjectionName[], useBuffer?: boolean) => { - const { __debug } = prepareTypeormProjection( - { - buffer: useBuffer ? new TypeormStabilityWindowBuffer({ logger: dummyLogger }) : undefined, - projections - }, - { logger: dummyLogger } - ); - return __debug; -}; +const prepare = (projections: ProjectionName[]) => + prepareTypeormProjection({ projections }, { logger: dummyLogger }).__debug; describe('prepareTypeormProjection', () => { describe('computes required entities, mappers and stores based on selected projections and presence of a buffer', () => { - test('utxo (without buffer)', () => { + test('utxo', () => { const { entities, mappers, stores } = prepare([ProjectionName.UTXO]); - expect(new Set(entities)).toEqual(new Set(['tokens', 'block', 'asset', 'nftMetadata', 'output'])); - expect(mappers).toEqual(['withMint', 'withUtxo']); - expect(stores).toEqual(['storeBlock', 'storeAssets', 'storeUtxo']); - }); - - test('utxo (with buffer)', () => { - const { entities, mappers, stores } = prepare([ProjectionName.UTXO], true); expect(new Set(entities)).toEqual(new Set(['tokens', 'block', 'asset', 'nftMetadata', 'output', 'blockData'])); expect(mappers).toEqual(['withMint', 'withUtxo']); - // 'null' is expected here because buffer.storeBlockData is not a common operator, - // but is a method of the buffer. As a result it's not part of the predefined operators object. - expect(stores).toEqual(['storeBlock', 'storeAssets', 'storeUtxo', null]); + expect(stores).toEqual(['storeBlock', 'storeAssets', 'storeUtxo']); }); test('stake-pool,stake-pool-metadata', () => { const { entities, mappers, stores } = prepare([ProjectionName.StakePool, ProjectionName.StakePoolMetadataJob]); expect(new Set(entities)).toEqual( - new Set(['block', 'stakePool', 'poolRegistration', 'poolRetirement', 'poolMetadata', 'currentPoolMetrics']) + new Set([ + 'block', + 'blockData', + 'stakePool', + 'poolRegistration', + 'poolRetirement', + 'poolMetadata', + 'currentPoolMetrics' + ]) ); expect(mappers).toEqual(['withCertificates', 'withStakePools']); expect(stores).toEqual(['storeBlock', 'storeStakePools', 'storeStakePoolMetadataJob']); diff --git a/packages/e2e/test/projection/offline-fork.test.ts b/packages/e2e/test/projection/offline-fork.test.ts index b6bdcc1c6d2..4865e90c709 100644 --- a/packages/e2e/test/projection/offline-fork.test.ts +++ b/packages/e2e/test/projection/offline-fork.test.ts @@ -1,10 +1,10 @@ -/* eslint-disable promise/always-return */ import * as Postgres from '@cardano-sdk/projection-typeorm'; import { BlockDataEntity, BlockEntity, StakeKeyEntity } from '@cardano-sdk/projection-typeorm'; import { Bootstrap, InMemory, Mappers, + ProjectionEvent, ProjectionOperator, StabilityWindowBuffer, WithBlock, @@ -17,13 +17,14 @@ import { ChainSyncEventType, ChainSyncRollForward, ObservableCardanoNode, - Point + Point, + TipOrOrigin } from '@cardano-sdk/core'; import { ChainSyncDataSet, chainSyncData, logger } from '@cardano-sdk/util-dev'; import { ConnectionConfig } from '@cardano-ogmios/client'; -import { Observable, defer, filter, firstValueFrom, lastValueFrom, of, take, takeWhile, toArray } from 'rxjs'; +import { Observable, filter, firstValueFrom, lastValueFrom, map, of, take, takeWhile, toArray } from 'rxjs'; import { OgmiosObservableCardanoNode } from '@cardano-sdk/ogmios'; -import { QueryRunner } from 'typeorm'; +import { ReconnectionConfig } from '@cardano-sdk/util-rxjs'; import { createDatabase } from 'typeorm-extension'; import { getEnv } from '../../src'; @@ -110,6 +111,14 @@ const createForkProjectionSource = ( healthCheck$: new Observable() }); +const fakeTip = (evt$: Observable): Observable => + evt$.pipe( + map((evt) => ({ + ...evt, + tip: evt.block.header + })) + ); + describe('resuming projection when intersection is not local tip', () => { let ogmiosCardanoNode: ObservableCardanoNode; @@ -120,9 +129,11 @@ describe('resuming projection when intersection is not local tip', () => { const project = ( cardanoNode: ObservableCardanoNode, buffer: StabilityWindowBuffer, + projectedTip$: Observable, into: ProjectionOperator ) => - Bootstrap.fromCardanoNode({ blocksBufferLength: 10, buffer, cardanoNode, logger }).pipe( + Bootstrap.fromCardanoNode({ blocksBufferLength: 10, buffer, cardanoNode, logger, projectedTip$ }).pipe( + fakeTip, Mappers.withCertificates(), Mappers.withStakeKeys(), into, @@ -131,13 +142,14 @@ describe('resuming projection when intersection is not local tip', () => { const testRollbackAndContinue = ( buffer: StabilityWindowBuffer, + tip$: Observable, into: ProjectionOperator, getNumberOfLocalStakeKeys: () => Promise ) => { it('rolls back local data to intersection and resumes projection from there', async () => { // Project some events until we find at least 1 stake key registration const firstEventWithKeyRegistrations = await firstValueFrom( - project(ogmiosCardanoNode, buffer, into).pipe(filter((evt) => evt.stakeKeys.insert.length > 0)) + project(ogmiosCardanoNode, buffer, tip$, into).pipe(filter((evt) => evt.stakeKeys.insert.length > 0)) ); const lastEventFromOriginalSync = firstEventWithKeyRegistrations; const numStakeKeysBeforeFork = await getNumberOfLocalStakeKeys(); @@ -145,13 +157,13 @@ describe('resuming projection when intersection is not local tip', () => { // Simulate a fork by adding some blocks that are not on the ogmios chain const stubForkCardanoNode = createForkProjectionSource(ogmiosCardanoNode, lastEventFromOriginalSync); - await lastValueFrom(project(stubForkCardanoNode, buffer, into).pipe(take(4))); + await lastValueFrom(project(stubForkCardanoNode, buffer, tip$, into).pipe(take(4))); const numStakeKeysAfterFork = await getNumberOfLocalStakeKeys(); expect(numStakeKeysAfterFork).toBeGreaterThan(numStakeKeysBeforeFork); // Continue projection from ogmios const eventsTilStakeKeyRollback = await firstValueFrom( - project(ogmiosCardanoNode, buffer, into).pipe( + project(ogmiosCardanoNode, buffer, tip$, into).pipe( takeWhile((evt) => evt.stakeKeys.del.length === 0 && evt.stakeKeys.insert.length === 0, true), toArray() ) @@ -169,7 +181,7 @@ describe('resuming projection when intersection is not local tip', () => { // Continue projection from ogmios const firstRollForwardEvent = await lastValueFrom( - project(ogmiosCardanoNode, buffer, into).pipe( + project(ogmiosCardanoNode, buffer, tip$, into).pipe( takeWhile((evt) => evt.eventType === ChainSyncEventType.RollBackward, true) ) ); @@ -184,26 +196,34 @@ describe('resuming projection when intersection is not local tip', () => { const buffer = new InMemory.InMemoryStabilityWindowBuffer(); testRollbackAndContinue( buffer, + buffer.tip$, (evt$) => evt$.pipe(withStaticContext({ store }), InMemory.storeStakeKeys(), buffer.handleEvents()), async () => store.stakeKeys.size ); }); describe('typeorm', () => { - const buffer = new Postgres.TypeormStabilityWindowBuffer({ logger }); + const reconnectionConfig: ReconnectionConfig = { initialInterval: 10 }; + const entities = [BlockEntity, BlockDataEntity, StakeKeyEntity]; + const connection$ = Postgres.createObservableConnection({ + connectionConfig$: of(pgConnectionConfig), + entities, + logger + }); + const buffer = new Postgres.TypeormStabilityWindowBuffer({ connection$, logger, reconnectionConfig }); + const tipTracker = Postgres.createTypeormTipTracker({ connection$, reconnectionConfig }); const dataSource = Postgres.createDataSource({ connectionConfig: pgConnectionConfig, devOptions: { dropSchema: true, synchronize: true }, - entities: [BlockEntity, BlockDataEntity, StakeKeyEntity], + entities, logger, options: { installExtensions: true } }); - let queryRunner: QueryRunner; const getNumberOfLocalStakeKeys = async () => { const repository = dataSource.getRepository(Postgres.StakeKeyEntity); @@ -219,20 +239,24 @@ describe('resuming projection when intersection is not local tip', () => { } }); await dataSource.initialize(); - queryRunner = dataSource.createQueryRunner(); - await buffer.initialize(queryRunner); }); - afterAll(() => dataSource.destroy()); + + afterAll(async () => { + await dataSource.destroy(); + tipTracker.shutdown(); + }); testRollbackAndContinue( buffer, + tipTracker.tip$, (evt$) => evt$.pipe( - Postgres.withTypeormTransaction({ connection$: defer(() => of({ queryRunner })) }), + Postgres.withTypeormTransaction({ connection$ }), Postgres.storeBlock(), Postgres.storeStakeKeys(), buffer.storeBlockData(), - Postgres.typeormTransactionCommit() + Postgres.typeormTransactionCommit(), + tipTracker.trackProjectedTip() ), getNumberOfLocalStakeKeys ); diff --git a/packages/e2e/test/projection/single-tenant-utxo.test.ts b/packages/e2e/test/projection/single-tenant-utxo.test.ts index 2fe941105af..1df647b36b2 100644 --- a/packages/e2e/test/projection/single-tenant-utxo.test.ts +++ b/packages/e2e/test/projection/single-tenant-utxo.test.ts @@ -4,12 +4,22 @@ import { Bootstrap, Mappers, ProjectionEvent, requestNext } from '@cardano-sdk/p import { Cardano, ObservableCardanoNode } from '@cardano-sdk/core'; import { ConnectionConfig } from '@cardano-ogmios/client'; import { DataSource, QueryRunner } from 'typeorm'; -import { Observable, defer, filter, firstValueFrom, lastValueFrom, of, scan, takeWhile } from 'rxjs'; +import { Observable, filter, firstValueFrom, lastValueFrom, of, scan, takeWhile } from 'rxjs'; import { OgmiosObservableCardanoNode } from '@cardano-sdk/ogmios'; +import { ReconnectionConfig } from '@cardano-sdk/util-rxjs'; import { createDatabase, dropDatabase } from 'typeorm-extension'; import { getEnv } from '../../src'; import { logger } from '@cardano-sdk/util-dev'; +const entities = [ + Postgres.BlockEntity, + Postgres.BlockDataEntity, + Postgres.AssetEntity, + Postgres.TokensEntity, + Postgres.OutputEntity, + Postgres.NftMetadataEntity +]; + const ogmiosConnectionConfig = ((): ConnectionConfig => { const { OGMIOS_URL } = getEnv(['OGMIOS_URL']); const url = new URL(OGMIOS_URL); @@ -42,14 +52,7 @@ const createDataSource = () => dropSchema: true, synchronize: true }, - entities: [ - Postgres.BlockEntity, - Postgres.BlockDataEntity, - Postgres.AssetEntity, - Postgres.TokensEntity, - Postgres.OutputEntity, - Postgres.NftMetadataEntity - ], + entities, logger, options: { installExtensions: true @@ -74,23 +77,30 @@ const countUniqueOutputAddresses = (queryRunner: QueryRunner) => describe('single-tenant utxo projection', () => { let cardanoNode: ObservableCardanoNode; + let connection$: Observable; let buffer: Postgres.TypeormStabilityWindowBuffer; + let tipTracker: Postgres.TypeormTipTracker; let queryRunner: QueryRunner; let dataSource: DataSource; const initialize = async () => { - buffer = new Postgres.TypeormStabilityWindowBuffer({ logger }); await createDatabase(databaseOptions); dataSource = createDataSource(); await dataSource.initialize(); queryRunner = dataSource.createQueryRunner('slave'); - await buffer.initialize(queryRunner); + connection$ = Postgres.createObservableConnection({ + connectionConfig$: of(pgConnectionConfig), + entities, + logger + }); + const reconnectionConfig: ReconnectionConfig = { initialInterval: 10 }; + buffer = new Postgres.TypeormStabilityWindowBuffer({ connection$, logger, reconnectionConfig }); + tipTracker = Postgres.createTypeormTipTracker({ connection$, reconnectionConfig }); }; const cleanup = async () => { await queryRunner.release(); await dataSource.destroy(); - buffer.shutdown(); }; beforeEach(async () => { @@ -100,22 +110,9 @@ describe('single-tenant utxo projection', () => { afterEach(async () => cleanup()); - const projectMultiTenant = () => - Bootstrap.fromCardanoNode({ blocksBufferLength: 10, buffer, cardanoNode, logger }).pipe( - Mappers.withMint(), - Mappers.withUtxo(), - Postgres.withTypeormTransaction({ connection$: defer(() => of({ queryRunner })) }), - Postgres.storeBlock(), - Postgres.storeAssets(), - Postgres.storeUtxo(), - buffer.storeBlockData(), - Postgres.typeormTransactionCommit(), - requestNext() - ); - const storeUtxo = (evt$: Observable>) => evt$.pipe( - Postgres.withTypeormTransaction({ connection$: defer(() => of({ queryRunner })) }), + Postgres.withTypeormTransaction({ connection$ }), Postgres.storeBlock(), Postgres.storeAssets(), Postgres.storeUtxo(), @@ -123,12 +120,28 @@ describe('single-tenant utxo projection', () => { Postgres.typeormTransactionCommit() ); + const projectMultiTenant = () => + Bootstrap.fromCardanoNode({ + blocksBufferLength: 10, + buffer, + cardanoNode, + logger, + projectedTip$: tipTracker.tip$ + }).pipe(Mappers.withMint(), Mappers.withUtxo(), storeUtxo, requestNext(), tipTracker.trackProjectedTip()); + const projectSingleTenant = (addresses: Cardano.PaymentAddress[]) => - Bootstrap.fromCardanoNode({ blocksBufferLength: 10, buffer, cardanoNode, logger }).pipe( + Bootstrap.fromCardanoNode({ + blocksBufferLength: 10, + buffer, + cardanoNode, + logger, + projectedTip$: tipTracker.tip$ + }).pipe( Mappers.withMint(), Mappers.withUtxo(), Mappers.filterProducedUtxoByAddresses({ addresses }), storeUtxo, + tipTracker.trackProjectedTip(), requestNext() ); diff --git a/packages/e2e/test/tsconfig.json b/packages/e2e/test/tsconfig.json index ceb39f783cb..a961d31ef96 100644 --- a/packages/e2e/test/tsconfig.json +++ b/packages/e2e/test/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../util-dev/src" }, + { + "path": "../../util-rxjs/src" + }, { "path": "../../wallet/src" }, diff --git a/packages/projection-typeorm/package.json b/packages/projection-typeorm/package.json index f3a893cad3d..4ae0a265f4c 100644 --- a/packages/projection-typeorm/package.json +++ b/packages/projection-typeorm/package.json @@ -43,6 +43,7 @@ "@cardano-sdk/projection": "workspace:~", "@cardano-sdk/util": "workspace:~", "@cardano-sdk/util-rxjs": "workspace:~", + "backoff-rxjs": "^7.0.0", "lodash": "^4.17.21", "pg": "^8.9.0", "pg-boss": "8.4.2", diff --git a/packages/projection-typeorm/src/TypeormStabilityWindowBuffer.ts b/packages/projection-typeorm/src/TypeormStabilityWindowBuffer.ts index 41922119381..9713c54f610 100644 --- a/packages/projection-typeorm/src/TypeormStabilityWindowBuffer.ts +++ b/packages/projection-typeorm/src/TypeormStabilityWindowBuffer.ts @@ -2,36 +2,16 @@ /* eslint-disable brace-style */ import { BlockDataEntity } from './entity'; import { Cardano, ChainSyncEventType } from '@cardano-sdk/core'; -import { FindOptionsSelect, LessThan, QueryRunner, Repository } from 'typeorm'; +import { LessThan, QueryRunner } from 'typeorm'; import { Logger } from 'ts-log'; -import { Observable, ReplaySubject, concatMap, from, map } from 'rxjs'; -import { - ProjectionEvent, - RollBackwardEvent, - RollForwardEvent, - StabilityWindowBuffer, - WithBlock, - WithNetworkInfo -} from '@cardano-sdk/projection'; +import { Observable, catchError, concatMap, from, map, of, switchMap, take } from 'rxjs'; +import { ProjectionEvent, RollForwardEvent, StabilityWindowBuffer, WithNetworkInfo } from '@cardano-sdk/projection'; +import { ReconnectionConfig } from '@cardano-sdk/util-rxjs'; +import { RetryBackoffConfig, retryBackoff } from 'backoff-rxjs'; +import { TypeormConnection } from './createDataSource'; import { WithLogger, contextLogger } from '@cardano-sdk/util'; import { WithTypeormContext } from './operators'; - -const pointEquals = (point1: 'origin' | Cardano.Block | undefined, point2: 'origin' | Cardano.Block | undefined) => { - if (typeof point1 !== 'object') { - return point1 === point2; - } - if (typeof point2 !== 'object') { - return false; - } - return point1.header.hash === point2.header.hash; -}; - -const blockDataSelect: FindOptionsSelect = { - // Using 'transformers' breaks the types. - // Types seem to expect model to have fields that match database types: - // If it's an 'object', it will recursively apply FindOptionsSelect. - data: true as any -}; +import { isRecoverableTypeormError } from './isRecoverableTypeormError'; export interface TypeormStabilityWindowBufferProps extends WithLogger { /** @@ -39,77 +19,76 @@ export interface TypeormStabilityWindowBufferProps extends WithLogger { */ compactBufferEveryNBlocks?: number; /** - * Useful for testing with cherry-picked blocks + * Used for getBlock, which is called at the time of bootstrap or rollback + */ + connection$: Observable; + /** + * Retry strategy for getBlock. Buffer will re-subscribe to connection$ on each retry. */ - allowNonSequentialBlockHeights?: boolean; + reconnectionConfig: ReconnectionConfig; } export class TypeormStabilityWindowBuffer implements StabilityWindowBuffer { - #tail?: Cardano.Block | 'origin'; - #tip?: Cardano.Block | 'origin'; - readonly #tip$: ReplaySubject = new ReplaySubject(1); - readonly #tail$: ReplaySubject = new ReplaySubject(1); - readonly tip$: Observable; - readonly tail$: Observable; + readonly #queryRunner$: Observable; + readonly #retryBackoffConfig: RetryBackoffConfig; readonly #logger: Logger; readonly #compactEvery: number; - readonly #allowNonSequentialBlockHeights?: boolean; constructor({ - allowNonSequentialBlockHeights, compactBufferEveryNBlocks = 100, - logger + connection$, + logger, + reconnectionConfig }: TypeormStabilityWindowBufferProps) { this.#compactEvery = compactBufferEveryNBlocks; - this.#allowNonSequentialBlockHeights = allowNonSequentialBlockHeights; this.#logger = contextLogger(logger, 'TypeormStabilityWindowBuffer'); - this.tip$ = this.#tip$.asObservable(); - this.tail$ = this.#tail$.asObservable(); + this.#queryRunner$ = connection$.pipe(map(({ queryRunner }) => queryRunner)); + this.#retryBackoffConfig = { + ...reconnectionConfig, + shouldRetry: isRecoverableTypeormError + }; + } + + getBlock(id: Cardano.BlockId): Observable { + this.#logger.debug('getBlock', id); + return this.#queryRunner$.pipe( + switchMap((queryRunner) => { + this.#logger.debug('getBlock query runner'); + const repository = queryRunner.manager.getRepository(BlockDataEntity); + return from( + (async () => { + const blockDataEntity = await repository.findOne({ where: { block: { hash: id } } }); + this.#logger.debug('getBlock found', blockDataEntity); + return blockDataEntity?.data || null; + })() + ); + }), + take(1), + catchError((err) => { + this.#logger.error(err); + throw err; + }), + retryBackoff(this.#retryBackoffConfig) + ); } storeBlockData>() { return (evt$: Observable) => evt$.pipe( - concatMap((evt) => - from( - evt.eventType === ChainSyncEventType.RollForward ? this.#rollForward(evt) : this.#rollBackward(evt) - ).pipe(map(() => evt)) - ) + concatMap((evt) => { + if ( + evt.eventType === ChainSyncEventType.RollForward && + evt.block.header.blockNo >= evt.tip.blockNo - evt.genesisParameters.securityParameter + ) { + return from(this.#rollForward(evt)).pipe(map(() => evt)); + } + return of(evt); + }) ); } - async initialize(queryRunner: QueryRunner): Promise { - const repository = queryRunner.manager.getRepository(BlockDataEntity); - const [tip, tail] = await Promise.all([ - // findOne fails without `where:`, so using find(). - // It makes 2 queries so is not very efficient, - // but it should be fine for `initialize`. - repository.find({ - order: { blockHeight: 'DESC' }, - select: blockDataSelect, - take: 1 - }), - this.#findTail(repository) - ]); - this.#setTip(tip[0]?.data || 'origin'); - this.#setTail(tail[0]?.data || 'origin'); - } - - shutdown(): void { - this.#tip$.complete(); - this.#tail$.complete(); - } - - async #findTail(repository: Repository) { - return repository.find({ - order: { blockHeight: 'ASC' }, - select: blockDataSelect, - take: 1 - }); - } - async #rollForward(evt: RollForwardEvent) { - const { eventType, transactionCommitted$, queryRunner, block } = evt; + const { eventType, queryRunner, block } = evt; const { header: { blockNo } } = block; @@ -123,91 +102,23 @@ export class TypeormStabilityWindowBuffer implements StabilityWindowBuffer { data: block }); await Promise.all([repository.insert(blockData), this.#deleteOldBlockData(evt)]); - transactionCommitted$.subscribe(() => { - this.#setTip(block); - if (this.#tail === 'origin') { - this.#setTail(block); - } - }); } } - async #rollBackward({ - transactionCommitted$, - queryRunner, - block: { - header: { blockNo } - } - }: RollBackwardEvent) { - const repository = queryRunner.manager.getRepository(BlockDataEntity); - // No need to delete rolled back block here, as it should cascade when the block entity gets deleted - const prevTip = await repository.findOne({ - order: { - blockHeight: 'DESC' - }, - select: blockDataSelect, - where: { - blockHeight: LessThan(blockNo) - } - }); - if (!this.#allowNonSequentialBlockHeights && prevTip?.data && blockNo !== prevTip?.data.header.blockNo + 1) { - throw new Error('Assert: inconsistent PgStabilityWindowBuffer at rollBackward'); - } - transactionCommitted$.subscribe(() => { - this.#setTip(prevTip?.data || 'origin'); - if (!prevTip?.data) { - this.#setTail('origin'); - } - }); - } - async #deleteOldBlockData({ genesisParameters: { securityParameter }, block: { header: { blockNo } }, - queryRunner, - transactionCommitted$ + queryRunner }: RollForwardEvent) { - if (blockNo < securityParameter || blockNo % this.#compactEvery !== 0) { + if (blockNo % this.#compactEvery !== 0) { return; } const repository = queryRunner.manager.getRepository(BlockDataEntity); const nextTailBlockHeight = blockNo - securityParameter; - let [nextTailEntity] = await Promise.all([ - repository.findOne({ - select: { - data: true as any - }, - where: { - blockHeight: nextTailBlockHeight - } - }), - repository.delete({ - blockHeight: LessThan(nextTailBlockHeight) - }) - ]); - if (!nextTailEntity) { - if (this.#allowNonSequentialBlockHeights) { - [nextTailEntity] = await this.#findTail(repository); - } else { - throw new Error('Assert: inconsistent PgStabilityWindowBuffer at #deleteOldBlockData'); - } - } - transactionCommitted$.subscribe(() => this.#setTail(nextTailEntity!.data!)); - } - - #setTail(tail: Cardano.Block | 'origin') { - if (!pointEquals(tail, this.#tail)) { - this.#tail = tail; - this.#tail$.next(tail); - } - } - - #setTip(tip: Cardano.Block | 'origin') { - if (!pointEquals(tip, this.#tip)) { - this.#tip = tip; - this.#tip$.next(tip); - } + await repository.delete({ + blockHeight: LessThan(nextTailBlockHeight) + }); } } diff --git a/packages/projection-typeorm/src/createDataSource.ts b/packages/projection-typeorm/src/createDataSource.ts index 8df73bbcb99..13512205e3e 100644 --- a/packages/projection-typeorm/src/createDataSource.ts +++ b/packages/projection-typeorm/src/createDataSource.ts @@ -1,7 +1,7 @@ import 'reflect-metadata'; import { DataSource, DataSourceOptions, DefaultNamingStrategy, NamingStrategyInterface, QueryRunner } from 'typeorm'; import { Logger } from 'ts-log'; -import { NEVER, Observable, concat, from, share, switchMap } from 'rxjs'; +import { NEVER, Observable, concat, from, switchMap } from 'rxjs'; import { PgBossExtension, createPgBoss, createPgBossExtension } from './pgBoss'; import { WithLogger, contextLogger, patchObject } from '@cardano-sdk/util'; import { finalizeWithLatest } from '@cardano-sdk/util-rxjs'; @@ -204,14 +204,14 @@ const releaseConnection = try { await connection.queryRunner.rollbackTransaction(); } catch (error) { - logger.error('Failed to rollback transaction', error); + logger.warn('Failed to rollback transaction', error); } } if (!connection.queryRunner.isReleased) { try { await connection.queryRunner.release(); } catch (error) { - logger.error('Failed to "release" query runner', error); + logger.warn('Failed to "release" query runner', error); } } }; @@ -230,16 +230,14 @@ const createConnection = async (dataSource: DataSource, { logger, extensions }: export const connect = ({ logger, extensions }: ConnectProps) => - (dataSource$: Observable) => { - const sharedSource$ = dataSource$.pipe(share()); - return sharedSource$.pipe( + (dataSource$: Observable) => + dataSource$.pipe( switchMap((dataSource) => concat(from(createConnection(dataSource, { extensions, logger })), NEVER).pipe( finalizeWithLatest(releaseConnection({ logger })) ) ) ); - }; export const createObservableConnection = (props: CreateObservableDataSourceProps): Observable => createObservableDataSource(props).pipe(connect(props)); diff --git a/packages/projection-typeorm/src/createTypeormTipTracker.ts b/packages/projection-typeorm/src/createTypeormTipTracker.ts new file mode 100644 index 00000000000..b1289af8985 --- /dev/null +++ b/packages/projection-typeorm/src/createTypeormTipTracker.ts @@ -0,0 +1,82 @@ +import { BaseProjectionEvent } from '@cardano-sdk/projection'; +import { BlockEntity } from './entity'; +import { ChainSyncEventType, TipOrOrigin } from '@cardano-sdk/core'; +import { Observable, ReplaySubject, from, map, of, switchMap, take, tap } from 'rxjs'; +import { ReconnectionConfig } from '@cardano-sdk/util-rxjs'; +import { RetryBackoffConfig, retryBackoff } from 'backoff-rxjs'; +import { TypeormConnection } from './createDataSource'; +import { isRecoverableTypeormError } from './isRecoverableTypeormError'; + +export interface CreateTypeormTipTrackerProps { + connection$: Observable; + /** + * Retry strategy for tip query. Tracker will re-subscribe to connection$ on each retry. + */ + reconnectionConfig: ReconnectionConfig; +} + +export const createTypeormTipTracker = ({ connection$, reconnectionConfig }: CreateTypeormTipTrackerProps) => { + const retryBackoffConfig: RetryBackoffConfig = { + ...reconnectionConfig, + shouldRetry: isRecoverableTypeormError + }; + const queryLocalTip$ = connection$.pipe( + switchMap(({ queryRunner }) => { + const blockRepo = queryRunner.manager.getRepository(BlockEntity); + return from( + (async (): Promise => { + // findOne fails without `where:`, so using find(). + // It makes 2 queries so is not very efficient, + // but it should be fine for `initialize` and rollbacks. + const tipQueryResult = await blockRepo.find({ + order: { slot: 'DESC' }, + take: 1 + }); + if (tipQueryResult.length === 0) { + return 'origin'; + } + return { + blockNo: tipQueryResult[0].height!, + hash: tipQueryResult[0].hash!, + slot: tipQueryResult[0].slot! + }; + })() + ); + }), + take(1), + retryBackoff(retryBackoffConfig) + ); + const tip$ = new ReplaySubject(1); + const trackProjectedTip = + () => + (evt$: Observable) => + evt$.pipe( + switchMap((evt) => { + if (evt.eventType === ChainSyncEventType.RollForward) { + tip$.next(evt.block.header); + return of(evt); + } + return queryLocalTip$.pipe( + tap((tip) => tip$.next(tip)), + map(() => evt) + ); + }) + ); + return { + shutdown: tip$.complete.bind(tip$), + tip$: (() => { + let initialized = false; + return new Observable((subscriber) => { + if (!initialized) { + // Lazily initialize on 1st subscription + queryLocalTip$.subscribe((next) => tip$.next(next)); + initialized = true; + } + return tip$.subscribe(subscriber); + }); + })(), + trackProjectedTip + }; +}; + +export type TypeormTipTracker = ReturnType; diff --git a/packages/projection-typeorm/src/index.ts b/packages/projection-typeorm/src/index.ts index 556b01fae8c..b1bc6e581ca 100644 --- a/packages/projection-typeorm/src/index.ts +++ b/packages/projection-typeorm/src/index.ts @@ -5,6 +5,7 @@ export * from './entity'; export * from './operators'; export * from './TypeormStabilityWindowBuffer'; export * from './isRecoverableTypeormError'; +export * from './createTypeormTipTracker'; export { STAKE_POOL_METADATA_QUEUE, STAKE_POOL_METRICS_UPDATE, diff --git a/packages/projection-typeorm/src/operators/withTypeormTransaction.ts b/packages/projection-typeorm/src/operators/withTypeormTransaction.ts index 18eb0e0ea35..f5b410b0e7e 100644 --- a/packages/projection-typeorm/src/operators/withTypeormTransaction.ts +++ b/packages/projection-typeorm/src/operators/withTypeormTransaction.ts @@ -1,5 +1,5 @@ /* eslint-disable func-style */ -import { Observable, Subject, defer, from, map, mergeMap, tap } from 'rxjs'; +import { Observable, Subject, defer, from, map, mergeMap } from 'rxjs'; import { PgBossExtension } from '../pgBoss'; import { ProjectionEvent, @@ -18,7 +18,6 @@ export interface WithTypeormTransactionDependencies { export interface WithTypeormContext { queryRunner: QueryRunner; - transactionCommitted$: Subject; } export interface WithPgBoss { @@ -26,7 +25,7 @@ export interface WithPgBoss { } type TypeormContextProp = keyof (WithTypeormContext & WithPgBoss); -const WithTypeormTransactionProps: Array = ['queryRunner', 'transactionCommitted$', 'pgBoss']; +const WithTypeormTransactionProps: Array = ['queryRunner', 'pgBoss']; export function withTypeormTransaction( dependencies: WithTypeormTransactionDependencies & { pgBoss?: false } @@ -74,7 +73,6 @@ export const typeormTransactionCommit = evt$.pipe( mergeMap((evt) => from(evt.queryRunner.commitTransaction()).pipe( - tap(() => evt.transactionCommitted$.next()), map(() => { // The explicit cast is (probably) needed because typecript can't check that // we're not removing any properties overlapping with T diff --git a/packages/projection-typeorm/test/TypeormStabilityWindowBuffer.test.ts b/packages/projection-typeorm/test/TypeormStabilityWindowBuffer.test.ts index 5b307771e74..909297b399d 100644 --- a/packages/projection-typeorm/test/TypeormStabilityWindowBuffer.test.ts +++ b/packages/projection-typeorm/test/TypeormStabilityWindowBuffer.test.ts @@ -2,30 +2,26 @@ import { BlockDataEntity, BlockEntity, TypeormStabilityWindowBuffer, - createObservableConnection, - storeBlock, - typeormTransactionCommit, - withTypeormTransaction + WithTypeormContext, + createObservableConnection } from '../src'; -import { Bootstrap, ProjectionEvent, requestNext } from '@cardano-sdk/projection'; import { Cardano, ChainSyncEventType } from '@cardano-sdk/core'; -import { ChainSyncDataSet, chainSyncData, logger } from '@cardano-sdk/util-dev'; -import { DataSource, QueryRunner } from 'typeorm'; -import { - Observable, - combineLatest, - defer, - filter, - firstValueFrom, - lastValueFrom, - of, - take, - takeWhile, - toArray -} from 'rxjs'; -import { connectionConfig$, initializeDataSource } from './util'; - -const { cardanoNode, networkInfo } = chainSyncData(ChainSyncDataSet.WithStakeKeyDeregistration); +import { DataSource, NoConnectionForRepositoryError, QueryRunner, Repository } from 'typeorm'; +import { ProjectionEvent } from '@cardano-sdk/projection'; +import { connectionConfig$, createBlockEntity, createBlockHeader, initializeDataSource } from './util'; +import { createStubObservable, logger } from '@cardano-sdk/util-dev'; +import { firstValueFrom, of, throwError } from 'rxjs'; + +const createBlock = (height: number): Cardano.Block => + ({ + header: createBlockHeader(height) + } as Cardano.Block); + +const createBlockDataEntity = (block: Cardano.Block, blockEntity: BlockEntity): BlockDataEntity => ({ + block: blockEntity, + blockHeight: blockEntity.height, + data: block +}); describe('TypeormStabilityWindowBuffer', () => { const entities = [BlockEntity, BlockDataEntity]; @@ -34,129 +30,144 @@ describe('TypeormStabilityWindowBuffer', () => { let dataSource: DataSource; let queryRunner: QueryRunner; + let blockDataRepo: Repository; + let blockRepo: Repository; let buffer: TypeormStabilityWindowBuffer; - let project$: Observable>; - const getBufferSize = () => queryRunner.manager.count(BlockDataEntity); - const getNumBlocks = () => queryRunner.manager.count(BlockEntity); - // eslint-disable-next-line unicorn/consistent-function-scoping - const getHeader = (tipOrTail: Cardano.Block | 'origin') => (tipOrTail as Cardano.Block).header; + const insertBlock = async (header: Cardano.PartialBlockHeader) => { + const blockEntity = createBlockEntity(header); + await blockRepo.insert(blockEntity); + return blockEntity; + }; + + const insertBlockAndData = async (block: Cardano.Block) => { + const blockEntity = await insertBlock(block.header); + const blockDataEntity = createBlockDataEntity(block, blockEntity); + await blockDataRepo.insert(blockDataEntity); + return { blockDataEntity, blockEntity }; + }; + + const queryBlockData = (height: number) => blockDataRepo.findOne({ where: { blockHeight: height } }); + + const getBlockFromBuffer = (id: Cardano.BlockId) => firstValueFrom(buffer.getBlock(id)); + const storeBlockDataToBuffer = (evt: ProjectionEvent) => + firstValueFrom(of(evt).pipe(buffer.storeBlockData())); beforeEach(async () => { dataSource = await initializeDataSource({ entities }); queryRunner = dataSource.createQueryRunner(); - buffer = new TypeormStabilityWindowBuffer({ - allowNonSequentialBlockHeights: false, - compactBufferEveryNBlocks, - logger - }); - await buffer.initialize(queryRunner); - project$ = defer(() => - Bootstrap.fromCardanoNode({ - blocksBufferLength: 10, - buffer, - cardanoNode: { - ...cardanoNode, - genesisParameters$: of({ - ...networkInfo.genesisParameters, - securityParameter - }) - }, - logger - }).pipe( - withTypeormTransaction({ connection$: createObservableConnection({ connectionConfig$, entities, logger }) }), - storeBlock(), - buffer.storeBlockData(), - typeormTransactionCommit(), - requestNext() - ) - ); + blockDataRepo = queryRunner.manager.getRepository(BlockDataEntity); + blockRepo = queryRunner.manager.getRepository(BlockEntity); }); afterEach(async () => { - buffer.shutdown(); await queryRunner.release(); await dataSource.destroy(); }); - it("calling initialize() again does not reemit tip and tail if they haven't changed", async () => { - const tips = firstValueFrom(buffer.tip$.pipe(toArray())); - const tails = firstValueFrom(buffer.tail$.pipe(toArray())); - await buffer.initialize(queryRunner); - buffer.shutdown(); - expect(await tips).toHaveLength(1); - expect(await tails).toHaveLength(1); - }); - - // eslint-disable-next-line unicorn/consistent-function-scoping - describe('when there are no blocks in the buffer', () => { - it('emits "origin" for both tip and tail', async () => { - const [tip, tail] = await firstValueFrom(combineLatest([buffer.tip$, buffer.tail$])); - expect(tip).toEqual('origin'); - expect(tail).toEqual('origin'); + describe('with successful connection', () => { + beforeEach(() => { + const connection$ = createObservableConnection({ + connectionConfig$, + entities, + logger + }); + buffer = new TypeormStabilityWindowBuffer({ + compactBufferEveryNBlocks, + connection$, + logger, + reconnectionConfig: { initialInterval: 1 } + }); }); - }); - describe('with 1 block in the buffer', () => { - it('emits that block as both tip$ and tail$', async () => { - await firstValueFrom(project$); - const lastTipAndTail = firstValueFrom(combineLatest([buffer.tip$, buffer.tail$])); - const [tip, tail] = await lastTipAndTail; - expect(typeof tip).toEqual('object'); - expect(tail).toEqual(tip); + describe('getBlock', () => { + describe('when block data is found', () => { + it('emits the block', async () => { + const block = createBlock(1); + await insertBlockAndData(block); + await expect(getBlockFromBuffer(block.header.hash)).resolves.toEqual(block); + }); + }); + + describe('when block data is not found', () => { + it('emits the null', async () => { + const { hash } = createBlockHeader(1); + await expect(getBlockFromBuffer(hash)).resolves.toBeNull(); + }); + }); }); - }); - describe('with 3 blocks in the buffer', () => { - it('emits tip$ for every new block, tail$ only for origin and the 1st block', async () => { - const tipsReady = firstValueFrom(buffer.tip$.pipe(toArray())); - const tailsReady = firstValueFrom(buffer.tail$.pipe(toArray())); - await lastValueFrom(project$.pipe(take(3))); - expect(await getBufferSize()).toEqual(3); - buffer.shutdown(); - const tips = await tipsReady; - expect(tips.length).toEqual(4); - expect(tips[0]).toEqual('origin'); - expect(getHeader(tips[1]).hash).not.toEqual(getHeader(tips[2]).hash); - expect(getHeader(tips[2]).hash).not.toEqual(getHeader(tips[3]).hash); - const tails = await tailsReady; - expect(tails.length).toEqual(2); - expect(tails[0]).toEqual('origin'); - expect(getHeader(tails[1]).hash).toEqual(getHeader(tips[1]).hash); + describe('storeBlockData', () => { + const tip = createBlockHeader(securityParameter * 20); + const createEvent = (height: number) => + ({ + block: createBlock(height), + eventType: ChainSyncEventType.RollForward, + genesisParameters: { securityParameter }, + queryRunner, + tip + } as ProjectionEvent); + + describe('when block is within stability window', () => { + it('inserts block data', async () => { + const event = createEvent(tip.blockNo - securityParameter); + await insertBlock(event.block.header); + await storeBlockDataToBuffer(event); + await expect(queryBlockData(event.block.header.blockNo)).resolves.toBeTruthy(); + }); + + describe('when block height is a multiple of compactBufferEveryNBlocks parameter', () => { + it('deletes block data that is outside of stability window', async () => { + await insertBlockAndData(createBlock(tip.blockNo - securityParameter - 1)); + await insertBlockAndData(createBlock(tip.blockNo - securityParameter)); + await insertBlockAndData(createBlock(tip.blockNo - securityParameter + 1)); + + expect(tip.blockNo % compactBufferEveryNBlocks).toBe(0); + const event = createEvent(tip.blockNo); + + await insertBlock(event.block.header); + await storeBlockDataToBuffer(event); + + await expect(queryBlockData(tip.blockNo - securityParameter - 1)).resolves.not.toBeTruthy(); + await expect(queryBlockData(tip.blockNo - securityParameter)).resolves.toBeTruthy(); + await expect(queryBlockData(tip.blockNo - securityParameter + 1)).resolves.toBeTruthy(); + }); + }); + }); + + describe('when block is outside stability window', () => { + it('does not insert block data', async () => { + const event = createEvent(tip.blockNo - securityParameter - 1); + await insertBlock(event.block.header); + await storeBlockDataToBuffer(event); + await expect(queryBlockData(event.block.header.blockNo)).resolves.not.toBeTruthy(); + }); + }); }); }); - it('rollback pops the tip$', async () => { - const tipsReady = firstValueFrom(buffer.tip$.pipe(toArray())); - await firstValueFrom(project$.pipe(filter(({ eventType }) => eventType === ChainSyncEventType.RollBackward))); - buffer.shutdown(); - const tips = await tipsReady; - expect(getHeader(tips[tips.length - 1]).blockNo).toBeLessThan(getHeader(tips[tips.length - 2]).blockNo); - }); + describe('with failing connection', () => { + describe('getBlock', () => { + it('reconnects and eventually emits the block', async () => { + const connection$ = createStubObservable( + throwError(() => new NoConnectionForRepositoryError('conn')), + createObservableConnection({ + connectionConfig$, + entities, + logger + }) + ); + buffer = new TypeormStabilityWindowBuffer({ + compactBufferEveryNBlocks, + connection$, + logger, + reconnectionConfig: { initialInterval: 1 } + }); - it('clears old block_data every 100 blocks and emits new tail$', async () => { - await lastValueFrom( - project$.pipe( - // stop one block before the expected clear - takeWhile( - ({ - block: { - header: { blockNo } - } - }) => (blockNo + 1) % compactBufferEveryNBlocks !== 0 - ) - ) - ); - const preClearTail = firstValueFrom(buffer.tail$); - const preClearBufferSize = await getBufferSize(); - const preClearNumBlocks = await getNumBlocks(); - // next event should trigger the clear - await firstValueFrom(project$); - const postClearTail = firstValueFrom(buffer.tail$); - expect(await getBufferSize()).toBeLessThan(preClearBufferSize); - expect(await getNumBlocks()).toEqual(preClearNumBlocks + 1); - const preClearTailHeight = getHeader(await preClearTail).blockNo; - const postClearTailHeight = getHeader(await postClearTail).blockNo; - expect(postClearTailHeight).toBeGreaterThan(preClearTailHeight); + const block = createBlock(1); + await insertBlockAndData(block); + await expect(getBlockFromBuffer(block.header.hash)).resolves.toEqual(block); + }); + }); }); }); diff --git a/packages/projection-typeorm/test/createTypeormTipTracker.test.ts b/packages/projection-typeorm/test/createTypeormTipTracker.test.ts new file mode 100644 index 00000000000..f9989e6c48a --- /dev/null +++ b/packages/projection-typeorm/test/createTypeormTipTracker.test.ts @@ -0,0 +1,153 @@ +import { BaseProjectionEvent } from '@cardano-sdk/projection'; +import { + BlockEntity, + TypeormConnection, + TypeormTipTracker, + createObservableConnection, + createTypeormTipTracker +} from '../src'; +import { Cardano, ChainSyncEventType } from '@cardano-sdk/core'; +import { DataSource, NoConnectionForRepositoryError, QueryRunner, Repository } from 'typeorm'; +import { Observable, firstValueFrom, of, throwError } from 'rxjs'; +import { RetryBackoffConfig } from 'backoff-rxjs'; +import { connectionConfig$, createBlockEntity, createBlockHeader, initializeDataSource } from './util'; +import { createStubObservable, logger } from '@cardano-sdk/util-dev'; + +const stubSingleEventProjection = (eventType: ChainSyncEventType, header: Cardano.PartialBlockHeader) => + of({ + block: { header }, + eventType + } as BaseProjectionEvent); + +describe('createTypeormTipTracker', () => { + const entities = [BlockEntity]; + const retryBackoffConfig: RetryBackoffConfig = { initialInterval: 1 }; + + let dataSource: DataSource; + let queryRunner: QueryRunner; + let connection$: Observable; + let blockRepo: Repository; + let tipTracker: TypeormTipTracker; + + beforeEach(async () => { + dataSource = await initializeDataSource({ entities }); + queryRunner = dataSource.createQueryRunner(); + blockRepo = queryRunner.manager.getRepository(BlockEntity); + }); + + afterEach(async () => { + await queryRunner.release(); + await dataSource.destroy(); + }); + + describe('with successful connection', () => { + beforeEach(() => { + connection$ = createObservableConnection({ + connectionConfig$, + entities, + logger + }); + }); + + describe('when there are no blocks in the buffer', () => { + beforeEach(() => { + tipTracker = createTypeormTipTracker({ connection$, reconnectionConfig: retryBackoffConfig }); + }); + + it('tip$ emits "origin"', async () => { + await expect(firstValueFrom(tipTracker.tip$)).resolves.toBe('origin'); + }); + + describe('piping a block through returned operator', () => { + it('tip$ emits new block', async () => { + const header = createBlockHeader(1); + await firstValueFrom( + stubSingleEventProjection(ChainSyncEventType.RollForward, header).pipe(tipTracker.trackProjectedTip()) + ); + await expect(firstValueFrom(tipTracker.tip$)).resolves.toEqual(header); + }); + }); + }); + + describe('with 1 block in the buffer', () => { + let header: Cardano.PartialBlockHeader; + + beforeEach(async () => { + header = createBlockHeader(1); + await blockRepo.insert(createBlockEntity(header)); + tipTracker = createTypeormTipTracker({ connection$, reconnectionConfig: retryBackoffConfig }); + }); + + it('tip$ emits that block', async () => { + await expect(firstValueFrom(tipTracker.tip$)).resolves.toEqual(header); + }); + + describe('rolling back that block', () => { + it('tip$ emits "origin"', async () => { + await blockRepo.delete(header.slot); + await firstValueFrom( + stubSingleEventProjection(ChainSyncEventType.RollBackward, header).pipe(tipTracker.trackProjectedTip()) + ); + await expect(firstValueFrom(tipTracker.tip$)).resolves.toBe('origin'); + }); + }); + + describe('piping a block through returned operator', () => { + it('tip$ emits new block', async () => { + const newBlockHeader = createBlockHeader(2); + await firstValueFrom( + of({ + block: { header: newBlockHeader }, + eventType: ChainSyncEventType.RollForward + } as BaseProjectionEvent).pipe(tipTracker.trackProjectedTip()) + ); + await expect(firstValueFrom(tipTracker.tip$)).resolves.toEqual(newBlockHeader); + }); + }); + }); + + describe('with 2 blocks in the buffer', () => { + let header1: Cardano.PartialBlockHeader; + let header2: Cardano.PartialBlockHeader; + + beforeEach(async () => { + header1 = createBlockHeader(1); + header2 = createBlockHeader(2); + await blockRepo.insert([createBlockEntity(header1), createBlockEntity(header2)]); + tipTracker = createTypeormTipTracker({ connection$, reconnectionConfig: retryBackoffConfig }); + }); + + it('tip$ emits latest block', async () => { + await expect(firstValueFrom(tipTracker.tip$)).resolves.toEqual(header2); + }); + + describe('rolling back latest block', () => { + it('tip$ emits first block', async () => { + await blockRepo.delete(header2.slot); + await firstValueFrom( + stubSingleEventProjection(ChainSyncEventType.RollBackward, header2).pipe(tipTracker.trackProjectedTip()) + ); + await expect(firstValueFrom(tipTracker.tip$)).resolves.toEqual(header1); + }); + }); + }); + }); + + describe('with failing connection', () => { + it('reconnects and eventually emits the tip', async () => { + connection$ = createStubObservable( + throwError(() => new NoConnectionForRepositoryError('conn')), + createObservableConnection({ + connectionConfig$, + entities, + logger + }) + ); + const header = createBlockHeader(1); + await blockRepo.insert(createBlockEntity(header)); + tipTracker = createTypeormTipTracker({ connection$, reconnectionConfig: retryBackoffConfig }); + + await expect(firstValueFrom(tipTracker.tip$)).resolves.toEqual(header); + }); + }); +}); diff --git a/packages/projection-typeorm/test/operators/storeAddresses.test.ts b/packages/projection-typeorm/test/operators/storeAddresses.test.ts index b7d945a95df..321cb21345a 100644 --- a/packages/projection-typeorm/test/operators/storeAddresses.test.ts +++ b/packages/projection-typeorm/test/operators/storeAddresses.test.ts @@ -8,6 +8,7 @@ import { StakeKeyRegistrationEntity, TokensEntity, TypeormStabilityWindowBuffer, + TypeormTipTracker, createObservableConnection, storeAddresses, storeAssets, @@ -30,6 +31,7 @@ import { Observable, firstValueFrom } from 'rxjs'; import { QueryRunner, Repository } from 'typeorm'; import { connectionConfig$, initializeDataSource } from '../util'; import { + createProjectorContext, createProjectorTilFirst, createRollForwardEventBasedOn, createStubBlockHeader, @@ -44,6 +46,7 @@ describe('storeAddresses', () => { const stubEvents = chainSyncData(ChainSyncDataSet.WithStakeKeyDeregistration); let queryRunner: QueryRunner; let buffer: TypeormStabilityWindowBuffer; + let tipTracker: TypeormTipTracker; const entities = [ BlockEntity, BlockDataEntity, @@ -80,6 +83,7 @@ describe('storeAddresses', () => { Mappers.withStakeKeyRegistrations(), Mappers.withAddresses(), storeData, + tipTracker.trackProjectedTip(), requestNext() ); @@ -88,7 +92,8 @@ describe('storeAddresses', () => { blocksBufferLength: 1, buffer, cardanoNode: stubEvents.cardanoNode, - logger + logger, + projectedTip$: tipTracker.tip$ }).pipe(applyOperators); const projectTilFirst = createProjectorTilFirst(project$); @@ -97,13 +102,11 @@ describe('storeAddresses', () => { const dataSource = await initializeDataSource({ entities }); queryRunner = dataSource.createQueryRunner(); addressesRepo = queryRunner.manager.getRepository(AddressEntity); - buffer = new TypeormStabilityWindowBuffer({ allowNonSequentialBlockHeights: true, logger }); - await buffer.initialize(queryRunner); + ({ buffer, tipTracker } = createProjectorContext(entities)); }); afterEach(async () => { await queryRunner.release(); - buffer.shutdown(); }); it('inserts addresses with their type, payment credential and stake credential', async () => { diff --git a/packages/projection-typeorm/test/operators/storeAssets.test.ts b/packages/projection-typeorm/test/operators/storeAssets.test.ts index 703bad46826..3584d7cae62 100644 --- a/packages/projection-typeorm/test/operators/storeAssets.test.ts +++ b/packages/projection-typeorm/test/operators/storeAssets.test.ts @@ -4,6 +4,7 @@ import { BlockEntity, NftMetadataEntity, TypeormStabilityWindowBuffer, + TypeormTipTracker, createObservableConnection, storeAssets, storeBlock, @@ -15,16 +16,23 @@ import { Cardano, ChainSyncEventType } from '@cardano-sdk/core'; import { ChainSyncDataSet, chainSyncData, logger } from '@cardano-sdk/util-dev'; import { QueryRunner } from 'typeorm'; import { connectionConfig$, initializeDataSource } from '../util'; -import { createProjectorTilFirst } from './util'; +import { createProjectorContext, createProjectorTilFirst } from './util'; describe('storeAssets', () => { const stubEvents = chainSyncData(ChainSyncDataSet.WithMint); let queryRunner: QueryRunner; let buffer: TypeormStabilityWindowBuffer; + let tipTracker: TypeormTipTracker; const entities = [BlockEntity, BlockDataEntity, AssetEntity, NftMetadataEntity]; const project$ = () => - Bootstrap.fromCardanoNode({ blocksBufferLength: 10, buffer, cardanoNode: stubEvents.cardanoNode, logger }).pipe( + Bootstrap.fromCardanoNode({ + blocksBufferLength: 10, + buffer, + cardanoNode: stubEvents.cardanoNode, + logger, + projectedTip$: tipTracker.tip$ + }).pipe( Mappers.withMint(), withTypeormTransaction({ connection$: createObservableConnection({ connectionConfig$, entities, logger }) @@ -33,6 +41,7 @@ describe('storeAssets', () => { storeAssets(), buffer.storeBlockData(), typeormTransactionCommit(), + tipTracker.trackProjectedTip(), requestNext() ); @@ -41,13 +50,12 @@ describe('storeAssets', () => { beforeEach(async () => { const dataSource = await initializeDataSource({ entities }); queryRunner = dataSource.createQueryRunner(); - buffer = new TypeormStabilityWindowBuffer({ allowNonSequentialBlockHeights: true, logger }); - await buffer.initialize(queryRunner); + ({ buffer, tipTracker } = createProjectorContext(entities)); + tipTracker.tip$.subscribe((tip) => logger.info('NEW TIP', tip)); }); afterEach(async () => { await queryRunner.release(); - buffer.shutdown(); }); it('inserts assets on mint, deletes when 1st mint block is rolled back', async () => { @@ -91,6 +99,7 @@ describe('storeAssets', () => { )![0] as Cardano.AssetId; const totalSupplyAfterSecondMint = secondMintEvent.mintedAssetTotalSupplies[assetIdThatWasMintedTwice]!; expect(totalSupplyAfterSecondMint).not.toBeUndefined(); + logger.info('Before 2nd project'); const rollbackEvent = await projectTilFirst( (evt) => evt.eventType === ChainSyncEventType.RollBackward && evt.block.header.hash === secondMintEvent.block.header.hash diff --git a/packages/projection-typeorm/test/operators/storeHandleMetadata.test.ts b/packages/projection-typeorm/test/operators/storeHandleMetadata.test.ts index 64d5e7ab669..82fc17b5380 100644 --- a/packages/projection-typeorm/test/operators/storeHandleMetadata.test.ts +++ b/packages/projection-typeorm/test/operators/storeHandleMetadata.test.ts @@ -7,6 +7,7 @@ import { OutputEntity, TokensEntity, TypeormStabilityWindowBuffer, + TypeormTipTracker, createObservableConnection, storeAssets, storeBlock, @@ -21,7 +22,12 @@ import { ChainSyncDataSet, chainSyncData, logger } from '@cardano-sdk/util-dev'; import { Observable, firstValueFrom } from 'rxjs'; import { QueryRunner, Repository } from 'typeorm'; import { connectionConfig$, initializeDataSource } from '../util'; -import { createProjectorTilFirst, createRollBackwardEventFor, createStubProjectionSource } from './util'; +import { + createProjectorContext, + createProjectorTilFirst, + createRollBackwardEventFor, + createStubProjectionSource +} from './util'; describe('storeHandleMetadata', () => { const eventsWithCip68Handle = chainSyncData(ChainSyncDataSet.WithInlineDatum); @@ -31,6 +37,7 @@ describe('storeHandleMetadata', () => { let queryRunner: QueryRunner; let buffer: TypeormStabilityWindowBuffer; + let tipTracker: TypeormTipTracker; const entities = [ BlockEntity, BlockDataEntity, @@ -63,11 +70,18 @@ describe('storeHandleMetadata', () => { Mappers.withNftMetadata({ logger }), Mappers.withHandleMetadata({ policyIds }, logger), storeData, + tipTracker.trackProjectedTip(), requestNext() ); const project$ = (cardanoNode: ObservableCardanoNode) => () => - Bootstrap.fromCardanoNode({ blocksBufferLength: 1, buffer, cardanoNode, logger }).pipe(applyOperators()); + Bootstrap.fromCardanoNode({ + blocksBufferLength: 1, + buffer, + cardanoNode, + logger, + projectedTip$: tipTracker.tip$ + }).pipe(applyOperators()); const projectTilFirst = (cardanoNode: ObservableCardanoNode) => createProjectorTilFirst(project$(cardanoNode)); let repository: Repository; @@ -75,14 +89,12 @@ describe('storeHandleMetadata', () => { beforeEach(async () => { const dataSource = await initializeDataSource({ entities }); queryRunner = dataSource.createQueryRunner(); - buffer = new TypeormStabilityWindowBuffer({ allowNonSequentialBlockHeights: true, logger }); + ({ buffer, tipTracker } = createProjectorContext(entities)); repository = queryRunner.manager.getRepository(HandleMetadataEntity); - await buffer.initialize(queryRunner); }); afterEach(async () => { await queryRunner.release(); - buffer.shutdown(); }); const testRollForwardAndBackward = async ( diff --git a/packages/projection-typeorm/test/operators/storeHandles/default.test.ts b/packages/projection-typeorm/test/operators/storeHandles/default.test.ts index 8b94e5a85d4..3f5f17b8d34 100644 --- a/packages/projection-typeorm/test/operators/storeHandles/default.test.ts +++ b/packages/projection-typeorm/test/operators/storeHandles/default.test.ts @@ -1,45 +1,42 @@ -/* eslint-disable unicorn/consistent-function-scoping */ -import { - AddressEntity, - AssetEntity, - BlockEntity, - HandleEntity, - HandleMetadataEntity, - TypeormStabilityWindowBuffer -} from '../../../src'; +import { AddressEntity, AssetEntity, BlockEntity, HandleEntity, HandleMetadataEntity } from '../../../src'; import { Cardano, Handle, util } from '@cardano-sdk/core'; import { DefaultHandleParamsQueryResponse, queryHandlesByAddressCredentials, sortHandles } from '../../../src/operators/storeHandles'; +import { + ProjectorContext, + createProjectorContext, + createStubBlockHeader, + createStubProjectionSource, + createStubRollForwardEvent, + createStubTx +} from '../util'; import { QueryRunner, Repository } from 'typeorm'; -import { applyOperators, entities, policyId, projectTilFirst, stubEvents } from './util'; -import { cip19TestVectors, generateRandomHexString, logger } from '@cardano-sdk/util-dev'; -import { createStubBlockHeader, createStubProjectionSource, createStubRollForwardEvent, createStubTx } from '../util'; +import { cip19TestVectors, generateRandomHexString } from '@cardano-sdk/util-dev'; +import { entities, mapAndStore, policyId, projectTilFirst, stubEvents } from './util'; import { firstValueFrom } from 'rxjs'; import { initializeDataSource } from '../../util'; describe('storeHandles', () => { let queryRunner: QueryRunner; - let buffer: TypeormStabilityWindowBuffer; + let context: ProjectorContext; let handleRepository: Repository; beforeEach(async () => { const dataSource = await initializeDataSource({ entities }); queryRunner = dataSource.createQueryRunner(); - buffer = new TypeormStabilityWindowBuffer({ allowNonSequentialBlockHeights: true, logger }); - await buffer.initialize(queryRunner); + context = createProjectorContext(entities); handleRepository = queryRunner.manager.getRepository(HandleEntity); }); afterEach(async () => { await queryRunner.release(); - buffer.shutdown(); }); const mintFirstHandle = async () => { - const firstHandleEvent = await projectTilFirst(buffer)((evt) => evt.handles.length > 0); + const firstHandleEvent = await projectTilFirst(context)((evt) => evt.handles.length > 0); const firstHandle = firstHandleEvent.handles[0]; const firstHandleStored = await handleRepository.findOneOrFail({ where: { handle: firstHandle.handle } })!; return { firstHandle, firstHandleEvent, firstHandleStored }; @@ -104,7 +101,7 @@ describe('storeHandles', () => { stubEvents.networkInfo ); - const event = await firstValueFrom(createStubProjectionSource([createOgHandle]).pipe(applyOperators(buffer))); + const event = await firstValueFrom(createStubProjectionSource([createOgHandle]).pipe(mapAndStore(context))); const handleOwnership = event.handles[0]; const storedHandle = await handleRepository.findOneOrFail({ where: { handle: handleOwnership.handle } })!; return { event, handleOwnership, storedHandle }; diff --git a/packages/projection-typeorm/test/operators/storeHandles/general.test.ts b/packages/projection-typeorm/test/operators/storeHandles/general.test.ts index 05fee8e8b8f..656521e9797 100644 --- a/packages/projection-typeorm/test/operators/storeHandles/general.test.ts +++ b/packages/projection-typeorm/test/operators/storeHandles/general.test.ts @@ -1,30 +1,28 @@ import { Asset, Cardano, ChainSyncEventType } from '@cardano-sdk/core'; -import { AssetEntity, HandleEntity, OutputEntity, TypeormStabilityWindowBuffer } from '../../../src'; +import { AssetEntity, HandleEntity, OutputEntity } from '../../../src'; +import { ProjectorContext, createProjectorContext } from '../util'; import { QueryRunner } from 'typeorm'; -import { applyOperators, createMultiTxProjectionSource, entities, policyId, projectTilFirst } from './util'; +import { createMultiTxProjectionSource, entities, mapAndStore, policyId, projectTilFirst } from './util'; import { firstValueFrom } from 'rxjs'; import { initializeDataSource } from '../../util'; -import { logger } from '@cardano-sdk/util-dev'; describe('storeHandles', () => { let queryRunner: QueryRunner; - let buffer: TypeormStabilityWindowBuffer; + let context: ProjectorContext; beforeEach(async () => { const dataSource = await initializeDataSource({ entities }); queryRunner = dataSource.createQueryRunner(); - buffer = new TypeormStabilityWindowBuffer({ allowNonSequentialBlockHeights: true, logger }); - await buffer.initialize(queryRunner); + context = createProjectorContext(entities); }); afterEach(async () => { await queryRunner.release(); - buffer.shutdown(); }); it('inserts handle on mint', async () => { const repository = queryRunner.manager.getRepository(HandleEntity); - const mintEvent = await projectTilFirst(buffer)((evt) => evt.handles.length > 0); + const mintEvent = await projectTilFirst(context)((evt) => evt.handles.length > 0); expect(await repository.count()).toBe(mintEvent.handles.length); expect(mintEvent.handles.length).toBeGreaterThan(0); }); @@ -32,7 +30,7 @@ describe('storeHandles', () => { it('when combined with filter operators, stores only relevant Output and Asset (per handle)', async () => { const outputRepository = queryRunner.manager.getRepository(OutputEntity); const assetRepository = queryRunner.manager.getRepository(AssetEntity); - const { handles } = await projectTilFirst(buffer)((evt) => evt.handles.length > 0); + const { handles } = await projectTilFirst(context)((evt) => evt.handles.length > 0); expect(await outputRepository.count()).toBe(handles.length); expect(await assetRepository.count()).toBe(handles.length); }); @@ -40,11 +38,11 @@ describe('storeHandles', () => { it('deletes handle on rollback', async () => { const handleRepository = queryRunner.manager.getRepository(HandleEntity); const initialCount = await handleRepository.count(); - const mintEvent = await projectTilFirst(buffer)( + const mintEvent = await projectTilFirst(context)( ({ handles, eventType }) => eventType === ChainSyncEventType.RollForward && handles.length > 0 ); expect(await handleRepository.count()).toEqual(initialCount + mintEvent.handles.length); - await projectTilFirst(buffer)( + await projectTilFirst(context)( ({ eventType, block: { @@ -84,7 +82,7 @@ describe('storeHandles', () => { } ]); const numHandles = await repository.count(); - await firstValueFrom(source$.pipe(applyOperators(buffer))); + await firstValueFrom(source$.pipe(mapAndStore(context))); expect(await repository.count()).toBe(numHandles); }); diff --git a/packages/projection-typeorm/test/operators/storeHandles/ownership.test.ts b/packages/projection-typeorm/test/operators/storeHandles/ownership.test.ts index ffb3fd7cb6e..f3b8c9c2114 100644 --- a/packages/projection-typeorm/test/operators/storeHandles/ownership.test.ts +++ b/packages/projection-typeorm/test/operators/storeHandles/ownership.test.ts @@ -1,38 +1,35 @@ import { BaseProjectionEvent } from '@cardano-sdk/projection'; import { Cardano, ChainSyncEventType } from '@cardano-sdk/core'; -import { HandleEntity, TypeormStabilityWindowBuffer } from '../../../src'; +import { HandleEntity } from '../../../src'; +import { ProjectorContext, createProjectorContext, createStubProjectionSource } from '../util'; import { QueryRunner } from 'typeorm'; -import { applyOperators, createMultiTxProjectionSource, entities, projectTilFirst } from './util'; -import { createStubProjectionSource } from '../util'; +import { createMultiTxProjectionSource, entities, mapAndStore, projectTilFirst } from './util'; import { firstValueFrom } from 'rxjs'; import { initializeDataSource } from '../../util'; -import { logger } from '@cardano-sdk/util-dev'; describe('storeHandles', () => { let queryRunner: QueryRunner; - let buffer: TypeormStabilityWindowBuffer; + let context: ProjectorContext; beforeEach(async () => { const dataSource = await initializeDataSource({ entities }); queryRunner = dataSource.createQueryRunner(); - buffer = new TypeormStabilityWindowBuffer({ allowNonSequentialBlockHeights: true, logger }); - await buffer.initialize(queryRunner); + context = createProjectorContext(entities); }); afterEach(async () => { await queryRunner.release(); - buffer.shutdown(); }); it(`minting an existing handle sets address to null, rolling back a transaction that mint an existing handle sets address to the original owner`, async () => { const repository = queryRunner.manager.getRepository(HandleEntity); - const firstMintEvent = await projectTilFirst(buffer)( + const firstMintEvent = await projectTilFirst(context)( ({ handles, eventType }) => eventType === ChainSyncEventType.RollForward && handles[0]?.handle === 'bob' ); const firstAddress = firstMintEvent.handles[0].latestOwnerAddress; expect(firstMintEvent.handles.length).toBe(1); - const secondMintEvent = await projectTilFirst(buffer)( + const secondMintEvent = await projectTilFirst(context)( ({ handles, eventType, mintedAssetTotalSupplies }) => eventType === ChainSyncEventType.RollForward && handles[0]?.handle === 'bob' && @@ -47,7 +44,7 @@ describe('storeHandles', () => { expect(secondMintEvent.handles.length).toBe(1); expect(secondMintEvent.handles[0].latestOwnerAddress).not.toEqual(firstAddress); - await projectTilFirst(buffer)( + await projectTilFirst(context)( ({ block: { header }, eventType }) => eventType === ChainSyncEventType.RollBackward && header.hash === secondMintEvent.block.header.hash ); @@ -62,7 +59,7 @@ describe('storeHandles', () => { it('burning a handle with supply >1 sets address and datum to the 1 remaining owner', async () => { const repository = queryRunner.manager.getRepository(HandleEntity); - const burnEvent = await projectTilFirst(buffer)( + const burnEvent = await projectTilFirst(context)( ({ eventType, mint }) => eventType === ChainSyncEventType.RollForward && mint[0]?.quantity === -1n ); expect(burnEvent.handles.length).toBe(1); @@ -81,7 +78,7 @@ describe('storeHandles', () => { it('rolling back a transaction that burned a handle with supply >1 sets address to null', async () => { const repository = queryRunner.manager.getRepository(HandleEntity); - const mintEvent1 = await projectTilFirst(buffer)( + const mintEvent1 = await projectTilFirst(context)( ({ eventType, mint }) => eventType === ChainSyncEventType.RollBackward && mint[0]?.quantity === -1n ); expect(mintEvent1.handles.length).toBe(1); @@ -95,7 +92,7 @@ describe('storeHandles', () => { it('transferring handle updates the address to the new owner, rolling back sets it to original owner', async () => { const repository = queryRunner.manager.getRepository(HandleEntity); - const mintEvt = await projectTilFirst(buffer)((evt) => evt.handles.length > 0); + const mintEvt = await projectTilFirst(context)((evt) => evt.handles.length > 0); const newOwnerAddress = Cardano.PaymentAddress( 'addr_test1qpfhhfy2qgls50r9u4yh0l7z67xpg0a5rrhkmvzcuqrd0znuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475q9gw0lz' ); @@ -142,7 +139,7 @@ describe('storeHandles', () => { tip: header }; const transferEvt = await firstValueFrom( - createStubProjectionSource([transferSourceEvt]).pipe(applyOperators(buffer)) + createStubProjectionSource([transferSourceEvt]).pipe(mapAndStore(context)) ); expect(transferEvt.handles[0].handle).toEqual(mintEvt.handles[0].handle); expect(transferEvt.handles[0].latestOwnerAddress).toEqual(newOwnerAddress); @@ -154,7 +151,7 @@ describe('storeHandles', () => { eventType: ChainSyncEventType.RollBackward, point: mintEvt.block.header }; - await firstValueFrom(createStubProjectionSource([rollbackSourceEvt]).pipe(applyOperators(buffer))); + await firstValueFrom(createStubProjectionSource([rollbackSourceEvt]).pipe(mapAndStore(context))); const handleInDbAfterTransferRollback = await repository.findOneBy({ handle: transferEvt.handles[0].handle }); expect(handleInDbAfterTransferRollback?.cardanoAddress).toEqual(originalOwnerAddress); }); @@ -207,7 +204,7 @@ describe('storeHandles', () => { } ]); - const mintAndTransferEvt = await firstValueFrom(source$.pipe(applyOperators(buffer))); + const mintAndTransferEvt = await firstValueFrom(source$.pipe(mapAndStore(context))); expect(mintAndTransferEvt.handles[0].handle).toEqual(handle); expect(await repository.findOne({ select: { cardanoAddress: true, handle: true }, where: { handle } })).toEqual({ cardanoAddress: bobAddress, @@ -259,7 +256,7 @@ describe('storeHandles', () => { id: Cardano.TransactionId('0000000000000000000000000000000000000000000000000000000000000001') } ]); - await firstValueFrom(source$.pipe(applyOperators(buffer))); + await firstValueFrom(source$.pipe(mapAndStore(context))); expect(await repository.findOne({ select: { cardanoAddress: true, handle: true }, where: { handle } })).toEqual({ cardanoAddress: maryAddress, handle diff --git a/packages/projection-typeorm/test/operators/storeHandles/util.ts b/packages/projection-typeorm/test/operators/storeHandles/util.ts index 0b539c25a81..3eab0e2b46f 100644 --- a/packages/projection-typeorm/test/operators/storeHandles/util.ts +++ b/packages/projection-typeorm/test/operators/storeHandles/util.ts @@ -24,8 +24,8 @@ import { Bootstrap, Mappers, ProjectionEvent, requestNext } from '@cardano-sdk/p import { Cardano, ChainSyncEventType } from '@cardano-sdk/core'; import { ChainSyncDataSet, chainSyncData, logger, mockProviders } from '@cardano-sdk/util-dev'; import { Observable } from 'rxjs'; +import { ProjectorContext, createProjectorTilFirst, createStubProjectionSource } from '../util'; import { connectionConfig$ } from '../../util'; -import { createProjectorTilFirst, createStubProjectionSource } from '../util'; export const stubEvents = chainSyncData(ChainSyncDataSet.WithHandle); export const policyId = Cardano.PolicyId('f0ff48bbb7bbe9d59a40f1ce90e9e9d0ff5002ec48f232b49ca0fb9a'); @@ -107,13 +107,20 @@ const applyMappers = (evt$: Observable>) => Mappers.withHandles({ policyIds }, logger) ); -// eslint-disable-next-line unicorn/consistent-function-scoping -export const applyOperators = (buffer: TypeormStabilityWindowBuffer) => (evt$: Observable>) => - evt$.pipe(applyMappers, storeData(buffer), requestNext()); +export const mapAndStore = + ({ buffer }: ProjectorContext) => + (evt$: Observable>) => + evt$.pipe(applyMappers, storeData(buffer)); -export const project$ = (buffer: TypeormStabilityWindowBuffer) => () => - Bootstrap.fromCardanoNode({ blocksBufferLength: 10, buffer, cardanoNode: stubEvents.cardanoNode, logger }).pipe( - applyOperators(buffer) - ); +export const project$ = + ({ buffer, tipTracker }: ProjectorContext) => + () => + Bootstrap.fromCardanoNode({ + blocksBufferLength: 10, + buffer, + cardanoNode: stubEvents.cardanoNode, + logger, + projectedTip$: tipTracker.tip$ + }).pipe(mapAndStore({ buffer, tipTracker }), tipTracker.trackProjectedTip(), requestNext()); -export const projectTilFirst = (buffer: TypeormStabilityWindowBuffer) => createProjectorTilFirst(project$(buffer)); +export const projectTilFirst = (context: ProjectorContext) => createProjectorTilFirst(project$(context)); diff --git a/packages/projection-typeorm/test/operators/storeNftMetadata.test.ts b/packages/projection-typeorm/test/operators/storeNftMetadata.test.ts index fa371dca7ac..8524c50809f 100644 --- a/packages/projection-typeorm/test/operators/storeNftMetadata.test.ts +++ b/packages/projection-typeorm/test/operators/storeNftMetadata.test.ts @@ -8,6 +8,7 @@ import { OutputEntity, TokensEntity, TypeormStabilityWindowBuffer, + TypeormTipTracker, createObservableConnection, storeAssets, storeBlock, @@ -22,6 +23,7 @@ import { Observable, firstValueFrom, lastValueFrom, toArray } from 'rxjs'; import { QueryRunner, Repository } from 'typeorm'; import { connectionConfig$, initializeDataSource } from '../util'; import { + createProjectorContext, createProjectorTilFirst, createRollBackwardEventFor, createRollForwardEventBasedOn, @@ -183,6 +185,7 @@ describe('storeNftMetadata', () => { let nftMetadataRepo: Repository; let assetRepo: Repository; let buffer: TypeormStabilityWindowBuffer; + let tipTracker: TypeormTipTracker; const entities = [BlockEntity, BlockDataEntity, AssetEntity, TokensEntity, OutputEntity, NftMetadataEntity]; const storeData = ( @@ -206,6 +209,7 @@ describe('storeNftMetadata', () => { Mappers.withCIP67(), Mappers.withNftMetadata({ logger: dummyLogger }), storeData, + tipTracker.trackProjectedTip(), requestNext() ); @@ -214,7 +218,8 @@ describe('storeNftMetadata', () => { blocksBufferLength: 1, buffer, cardanoNode: events.cardanoNode, - logger + logger, + projectedTip$: tipTracker.tip$ }).pipe(applyOperators()); const createProjectTilFirst = (events: typeof withHandleEvents) => createProjectorTilFirst(() => project$(events)); @@ -224,13 +229,11 @@ describe('storeNftMetadata', () => { queryRunner = dataSource.createQueryRunner(); nftMetadataRepo = queryRunner.manager.getRepository(NftMetadataEntity); assetRepo = queryRunner.manager.getRepository(AssetEntity); - buffer = new TypeormStabilityWindowBuffer({ allowNonSequentialBlockHeights: true, logger }); - await buffer.initialize(queryRunner); + ({ buffer, tipTracker } = createProjectorContext(entities)); }); afterEach(async () => { await queryRunner.release(); - buffer.shutdown(); }); const testBasicNftProjectionFeatures = ( diff --git a/packages/projection-typeorm/test/operators/storeStakeKeyRegistrations.test.ts b/packages/projection-typeorm/test/operators/storeStakeKeyRegistrations.test.ts index a10aeb98db6..733cbcdda0f 100644 --- a/packages/projection-typeorm/test/operators/storeStakeKeyRegistrations.test.ts +++ b/packages/projection-typeorm/test/operators/storeStakeKeyRegistrations.test.ts @@ -3,6 +3,7 @@ import { BlockEntity, StakeKeyRegistrationEntity, TypeormStabilityWindowBuffer, + TypeormTipTracker, certificatePointerToId, createObservableConnection, storeBlock, @@ -15,7 +16,12 @@ import { ChainSyncDataSet, chainSyncData, logger } from '@cardano-sdk/util-dev'; import { DataSource, QueryRunner, Repository } from 'typeorm'; import { Observable, firstValueFrom, pairwise, takeWhile } from 'rxjs'; import { connectionConfig$, initializeDataSource } from '../util'; -import { createProjectorTilFirst, createRollBackwardEventFor, createStubProjectionSource } from './util'; +import { + createProjectorContext, + createProjectorTilFirst, + createRollBackwardEventFor, + createStubProjectionSource +} from './util'; describe('storeStakeKeyRegistrations', () => { const data = chainSyncData(ChainSyncDataSet.WithPoolRetirement); @@ -24,6 +30,7 @@ describe('storeStakeKeyRegistrations', () => { let dataSource: DataSource; let queryRunner: QueryRunner; let buffer: TypeormStabilityWindowBuffer; + let tipTracker: TypeormTipTracker; const applyOperators = (evt$: Observable>) => evt$.pipe( @@ -34,6 +41,7 @@ describe('storeStakeKeyRegistrations', () => { storeStakeKeyRegistrations(), buffer.storeBlockData(), typeormTransactionCommit(), + tipTracker.trackProjectedTip(), requestNext() ); @@ -42,7 +50,8 @@ describe('storeStakeKeyRegistrations', () => { blocksBufferLength: 1, buffer, cardanoNode: data.cardanoNode, - logger + logger, + projectedTip$: tipTracker.tip$ }).pipe(applyOperators); const projectTilFirst = createProjectorTilFirst(project); @@ -50,14 +59,12 @@ describe('storeStakeKeyRegistrations', () => { dataSource = await initializeDataSource({ entities }); queryRunner = dataSource.createQueryRunner(); stakeKeyRegistrationsRepo = queryRunner.manager.getRepository(StakeKeyRegistrationEntity); - buffer = new TypeormStabilityWindowBuffer({ allowNonSequentialBlockHeights: true, logger }); - await buffer.initialize(queryRunner); + ({ buffer, tipTracker } = createProjectorContext(entities)); }); afterEach(async () => { await queryRunner.release(); await dataSource.destroy(); - buffer.shutdown(); }); it('inserts and deletes stake key registrations', async () => { diff --git a/packages/projection-typeorm/test/operators/storeStakePoolMetadataJob.test.ts b/packages/projection-typeorm/test/operators/storeStakePoolMetadataJob.test.ts index 500a6f99e6e..8b767139cc8 100644 --- a/packages/projection-typeorm/test/operators/storeStakePoolMetadataJob.test.ts +++ b/packages/projection-typeorm/test/operators/storeStakePoolMetadataJob.test.ts @@ -3,20 +3,21 @@ import { BlockEntity, STAKE_POOL_METADATA_QUEUE, TypeormStabilityWindowBuffer, + TypeormTipTracker, createObservableConnection, storeBlock, storeStakePoolMetadataJob, typeormTransactionCommit, withTypeormTransaction } from '../../src'; -import { Bootstrap, Mappers, requestNext } from '@cardano-sdk/projection'; +import { Bootstrap, Mappers, ProjectionEvent, requestNext } from '@cardano-sdk/projection'; import { ChainSyncDataSet, chainSyncData, logger } from '@cardano-sdk/util-dev'; import { ChainSyncEventType } from '@cardano-sdk/core'; +import { Observable, filter, of } from 'rxjs'; import { QueryRunner } from 'typeorm'; import { StakePoolMetadataJob, createPgBoss } from '../../src/pgBoss'; import { connectionConfig, initializeDataSource } from '../util'; -import { createProjectorTilFirst } from './util'; -import { filter, of } from 'rxjs'; +import { createProjectorContext, createProjectorTilFirst } from './util'; const testPromise = () => { let resolvePromise: Function; @@ -26,20 +27,13 @@ const testPromise = () => { describe('storeStakePoolMetadataJob', () => { const stubEvents = chainSyncData(ChainSyncDataSet.WithPoolRetirement); + const entities = [BlockEntity, BlockDataEntity]; let queryRunner: QueryRunner; let buffer: TypeormStabilityWindowBuffer; - const project$ = () => - Bootstrap.fromCardanoNode({ blocksBufferLength: 10, buffer, cardanoNode: stubEvents.cardanoNode, logger }).pipe( - // skipping 1st event because it's not rolled back - filter((evt) => { - const SKIP = 32_159; - if (evt.block.header.blockNo <= SKIP) { - evt.requestNext(); - } - return evt.block.header.blockNo > SKIP; - }), - Mappers.withCertificates(), - Mappers.withStakePools(), + let tipTracker: TypeormTipTracker; + + const storeData = (evt$: Observable>) => + evt$.pipe( withTypeormTransaction({ connection$: createObservableConnection({ connectionConfig$: of(connectionConfig), @@ -52,7 +46,29 @@ describe('storeStakePoolMetadataJob', () => { storeBlock(), storeStakePoolMetadataJob(), buffer.storeBlockData(), - typeormTransactionCommit(), + typeormTransactionCommit() + ); + + const project$ = () => + Bootstrap.fromCardanoNode({ + blocksBufferLength: 10, + buffer, + cardanoNode: stubEvents.cardanoNode, + logger, + projectedTip$: tipTracker.tip$ + }).pipe( + // skipping 1st event because it's not rolled back + filter((evt) => { + const SKIP = 32_159; + if (evt.block.header.blockNo <= SKIP) { + evt.requestNext(); + } + return evt.block.header.blockNo > SKIP; + }), + Mappers.withCertificates(), + Mappers.withStakePools(), + storeData, + tipTracker.trackProjectedTip(), requestNext() ); const projectTilFirst = createProjectorTilFirst(project$); @@ -61,17 +77,15 @@ describe('storeStakePoolMetadataJob', () => { beforeEach(async () => { const dataSource = await initializeDataSource({ - entities: [BlockEntity, BlockDataEntity], + entities, extensions: { pgBoss: true } }); queryRunner = dataSource.createQueryRunner(); - buffer = new TypeormStabilityWindowBuffer({ allowNonSequentialBlockHeights: true, logger }); - await buffer.initialize(queryRunner); + ({ buffer, tipTracker } = createProjectorContext(entities)); }); afterEach(async () => { await queryRunner.release(); - buffer.shutdown(); }); it('creates jobs referencing Block table that can be picked up by a worker', async () => { diff --git a/packages/projection-typeorm/test/operators/storeStakePools.test.ts b/packages/projection-typeorm/test/operators/storeStakePools.test.ts index b2605a7ba09..279819b121b 100644 --- a/packages/projection-typeorm/test/operators/storeStakePools.test.ts +++ b/packages/projection-typeorm/test/operators/storeStakePools.test.ts @@ -7,6 +7,7 @@ import { PoolRetirementEntity, StakePoolEntity, TypeormStabilityWindowBuffer, + TypeormTipTracker, createObservableConnection, storeBlock, storeStakePools, @@ -18,7 +19,7 @@ import { Cardano, ChainSyncEventType } from '@cardano-sdk/core'; import { ChainSyncDataSet, chainSyncData, logger } from '@cardano-sdk/util-dev'; import { DataSource, QueryRunner, Repository } from 'typeorm'; import { connectionConfig$, initializeDataSource } from '../util'; -import { createProjectorTilFirst } from './util'; +import { createProjectorContext, createProjectorTilFirst } from './util'; describe('storeStakePools', () => { const data = chainSyncData(ChainSyncDataSet.WithPoolRetirement); @@ -35,8 +36,16 @@ describe('storeStakePools', () => { let dataSource: DataSource; let queryRunner: QueryRunner; let buffer: TypeormStabilityWindowBuffer; + let tipTracker: TypeormTipTracker; + const project = () => - Bootstrap.fromCardanoNode({ blocksBufferLength: 10, buffer, cardanoNode: data.cardanoNode, logger }).pipe( + Bootstrap.fromCardanoNode({ + blocksBufferLength: 10, + buffer, + cardanoNode: data.cardanoNode, + logger, + projectedTip$: tipTracker.tip$ + }).pipe( Mappers.withCertificates(), Mappers.withStakePools(), withTypeormTransaction({ connection$: createObservableConnection({ connectionConfig$, entities, logger }) }), @@ -44,6 +53,7 @@ describe('storeStakePools', () => { storeStakePools(), buffer.storeBlockData(), typeormTransactionCommit(), + tipTracker.trackProjectedTip(), requestNext() ); const projectTilFirst = createProjectorTilFirst(project); @@ -65,14 +75,12 @@ describe('storeStakePools', () => { dataSource = await initializeDataSource({ entities }); queryRunner = dataSource.createQueryRunner(); poolsRepo = queryRunner.manager.getRepository(StakePoolEntity); - buffer = new TypeormStabilityWindowBuffer({ allowNonSequentialBlockHeights: true, logger }); - await buffer.initialize(queryRunner); + ({ buffer, tipTracker } = createProjectorContext(entities)); }); afterEach(async () => { await queryRunner.release(); await dataSource.destroy(); - buffer.shutdown(); }); it('typeorm loads correctly typed properties', async () => { diff --git a/packages/projection-typeorm/test/operators/storeUtxo.test.ts b/packages/projection-typeorm/test/operators/storeUtxo.test.ts index 9b746e84cad..f48f8a44ba6 100644 --- a/packages/projection-typeorm/test/operators/storeUtxo.test.ts +++ b/packages/projection-typeorm/test/operators/storeUtxo.test.ts @@ -6,6 +6,7 @@ import { OutputEntity, TokensEntity, TypeormStabilityWindowBuffer, + TypeormTipTracker, createObservableConnection, storeAssets, storeBlock, @@ -13,23 +14,23 @@ import { typeormTransactionCommit, withTypeormTransaction } from '../../src'; -import { Bootstrap, Mappers, requestNext } from '@cardano-sdk/projection'; +import { Bootstrap, Mappers, ProjectionEvent, requestNext } from '@cardano-sdk/projection'; import { Cardano, ChainSyncEventType } from '@cardano-sdk/core'; import { ChainSyncDataSet, chainSyncData, logger } from '@cardano-sdk/util-dev'; import { IsNull, Not, QueryRunner } from 'typeorm'; +import { Observable } from 'rxjs'; import { connectionConfig$, initializeDataSource } from '../util'; -import { createProjectorTilFirst } from './util'; +import { createProjectorContext, createProjectorTilFirst } from './util'; describe('storeUtxo', () => { const stubEvents = chainSyncData(ChainSyncDataSet.WithMint); let queryRunner: QueryRunner; let buffer: TypeormStabilityWindowBuffer; + let tipTracker: TypeormTipTracker; const entities = [BlockEntity, BlockDataEntity, AssetEntity, NftMetadataEntity, TokensEntity, OutputEntity]; - const project$ = () => - Bootstrap.fromCardanoNode({ blocksBufferLength: 10, buffer, cardanoNode: stubEvents.cardanoNode, logger }).pipe( - Mappers.withMint(), - Mappers.withUtxo(), + const storeData = (evt$: Observable>) => + evt$.pipe( withTypeormTransaction({ connection$: createObservableConnection({ connectionConfig$, entities, logger }) }), @@ -37,22 +38,28 @@ describe('storeUtxo', () => { storeAssets(), storeUtxo(), buffer.storeBlockData(), - typeormTransactionCommit(), - requestNext() + typeormTransactionCommit() ); + const project$ = () => + Bootstrap.fromCardanoNode({ + blocksBufferLength: 10, + buffer, + cardanoNode: stubEvents.cardanoNode, + logger, + projectedTip$: tipTracker.tip$ + }).pipe(Mappers.withMint(), Mappers.withUtxo(), storeData, tipTracker.trackProjectedTip(), requestNext()); + const projectTilFirst = createProjectorTilFirst(project$); beforeEach(async () => { const dataSource = await initializeDataSource({ entities }); queryRunner = dataSource.createQueryRunner(); - buffer = new TypeormStabilityWindowBuffer({ allowNonSequentialBlockHeights: true, logger }); - await buffer.initialize(queryRunner); + ({ buffer, tipTracker } = createProjectorContext(entities)); }); afterEach(async () => { await queryRunner.release(); - buffer.shutdown(); }); it('hydrates event object with storedProducedUtxo map', async () => { diff --git a/packages/projection-typeorm/test/operators/util.ts b/packages/projection-typeorm/test/operators/util.ts index 92eb6342c15..85f5df776e4 100644 --- a/packages/projection-typeorm/test/operators/util.ts +++ b/packages/projection-typeorm/test/operators/util.ts @@ -9,8 +9,42 @@ import { import { BigIntMath } from '@cardano-sdk/util'; import { Cardano, ChainSyncEventType, Point } from '@cardano-sdk/core'; import { Observable, lastValueFrom, takeWhile } from 'rxjs'; +import { RetryBackoffConfig } from 'backoff-rxjs'; +import { + TypeormStabilityWindowBuffer, + TypeormTipTracker, + createObservableConnection, + createTypeormTipTracker +} from '../../src'; +import { connectionConfig$ } from '../util'; import { generateRandomHexString, logger } from '@cardano-sdk/util-dev'; +export interface ProjectorContext { + buffer: TypeormStabilityWindowBuffer; + tipTracker: TypeormTipTracker; +} + +const retryBackoffConfig: RetryBackoffConfig = { + initialInterval: 10, + maxInterval: 100 +}; + +export const createProjectorContext = (entities: Function[]): ProjectorContext => { + const connection$ = createObservableConnection({ + connectionConfig$, + entities, + logger + }); + return { + buffer: new TypeormStabilityWindowBuffer({ + connection$, + logger, + reconnectionConfig: retryBackoffConfig + }), + tipTracker: createTypeormTipTracker({ connection$, reconnectionConfig: retryBackoffConfig }) + }; +}; + export const createProjectorTilFirst = (project: () => Observable) => async (filter: (evt: T) => boolean) => diff --git a/packages/projection-typeorm/test/operators/withTypeormTransaction.test.ts b/packages/projection-typeorm/test/operators/withTypeormTransaction.test.ts index 1e143217a49..d194ba1dd7f 100644 --- a/packages/projection-typeorm/test/operators/withTypeormTransaction.test.ts +++ b/packages/projection-typeorm/test/operators/withTypeormTransaction.test.ts @@ -3,6 +3,7 @@ import { BlockDataEntity, BlockEntity, TypeormStabilityWindowBuffer, + TypeormTipTracker, connect, isRecoverableTypeormError, storeBlock, @@ -18,7 +19,8 @@ import { } from '@cardano-sdk/projection'; import { ChainSyncDataSet, chainSyncData, logger } from '@cardano-sdk/util-dev'; import { ConnectionNotFoundError, DataSource, QueryFailedError, QueryRunner } from 'typeorm'; -import { Observable, defer, firstValueFrom, lastValueFrom, map, of, take } from 'rxjs'; +import { Observable, defer, firstValueFrom, lastValueFrom, map, of, take, toArray } from 'rxjs'; +import { createProjectorContext } from './util'; import { initializeDataSource } from '../util'; import { patchObject } from '@cardano-sdk/util'; import { shareRetryBackoff } from '@cardano-sdk/util-rxjs'; @@ -56,8 +58,10 @@ const mockDataSource = (dataSources: DataSource[]) => { }; describe('withTypeormTransaction', () => { + const entities = [BlockEntity, BlockDataEntity]; let project$: Observable>; let buffer: TypeormStabilityWindowBuffer; + let tipTracker: TypeormTipTracker; let dataSource: DataSource; let queryRunner: QueryRunner; @@ -69,17 +73,17 @@ describe('withTypeormTransaction', () => { }), storeBlock(), buffer.storeBlockData(), - typeormTransactionCommit() + typeormTransactionCommit(), + tipTracker.trackProjectedTip() ) ); beforeEach(async () => { - dataSource = await initializeDataSource({ entities: [BlockEntity, BlockDataEntity] }); + dataSource = await initializeDataSource({ entities }); queryRunner = dataSource.createQueryRunner(); }); afterEach(async () => { - buffer.shutdown(); await queryRunner.release(); await dataSource.destroy(); }); @@ -88,8 +92,7 @@ describe('withTypeormTransaction', () => { let numChainSyncSubscriptions: number; beforeEach(async () => { - buffer = new TypeormStabilityWindowBuffer({ logger }); - await buffer.initialize(queryRunner); + ({ buffer, tipTracker } = createProjectorContext(entities)); }); const project = (projection: ProjectionOperator) => @@ -110,7 +113,8 @@ describe('withTypeormTransaction', () => { ) ) }), - logger + logger, + projectedTip$: tipTracker.tip$ }).pipe(projection, requestNext()) ); @@ -135,13 +139,15 @@ describe('withTypeormTransaction', () => { describe('with a successful database connection', () => { beforeEach(async () => { - buffer = new TypeormStabilityWindowBuffer({ - allowNonSequentialBlockHeights: false, - logger - }); - await buffer.initialize(queryRunner); + ({ buffer, tipTracker } = createProjectorContext(entities)); project$ = defer(() => - Bootstrap.fromCardanoNode({ blocksBufferLength: 10, buffer, cardanoNode, logger }).pipe( + Bootstrap.fromCardanoNode({ + blocksBufferLength: 10, + buffer, + cardanoNode, + logger, + projectedTip$: tipTracker.tip$ + }).pipe( shareRetryBackoff(createProjection(of(dataSource)), { shouldRetry: isRecoverableTypeormError }), requestNext() ) @@ -163,11 +169,10 @@ describe('withTypeormTransaction', () => { }); it('does not retry unrecoverable errors', async () => { - const lastEvent = await lastValueFrom(project$.pipe(take(2))); - // deleting last block from the buffer creates an inconsistency: resumed projection will + const [previousTip] = await lastValueFrom(project$.pipe(take(2), toArray())); + // setting local tip to tip-1 creates an inconsistency: resumed projection will // try to insert a 'block' with an already existing 'height' which has a unique constraint. - await queryRunner.manager.getRepository(BlockDataEntity).delete(lastEvent.block.header.blockNo); - await buffer.initialize(queryRunner); + await firstValueFrom(tipTracker.trackProjectedTip()(of(previousTip))); // ADP-2807 await expect(firstValueFrom(project$)).rejects.toThrowError(QueryFailedError); }); diff --git a/packages/projection-typeorm/test/util.ts b/packages/projection-typeorm/test/util.ts index 4e3ef93fec6..a05286c9c84 100644 --- a/packages/projection-typeorm/test/util.ts +++ b/packages/projection-typeorm/test/util.ts @@ -1,6 +1,7 @@ -import { CreateDataSourceProps, createDataSource } from '../src'; +import { BlockEntity, CreateDataSourceProps, createDataSource } from '../src'; +import { Cardano } from '@cardano-sdk/core'; import { NEVER, concat, of } from 'rxjs'; -import { logger } from '@cardano-sdk/util-dev'; +import { generateRandomHexString, logger } from '@cardano-sdk/util-dev'; export const connectionConfig = { database: 'projection', @@ -24,3 +25,15 @@ export const initializeDataSource = async ( await dataSource.initialize(); return dataSource; }; + +export const createBlockHeader = (height: number): Cardano.PartialBlockHeader => ({ + blockNo: Cardano.BlockNo(height), + hash: Cardano.BlockId(generateRandomHexString(64)), + slot: Cardano.Slot(height * 20) +}); + +export const createBlockEntity = (header: Cardano.PartialBlockHeader): BlockEntity => ({ + hash: header.hash, + height: header.blockNo, + slot: header.slot +}); diff --git a/packages/projection/src/Bootstrap/fromCardanoNode.ts b/packages/projection/src/Bootstrap/fromCardanoNode.ts index 31cb1ce0ef3..eb14ef10499 100644 --- a/packages/projection/src/Bootstrap/fromCardanoNode.ts +++ b/packages/projection/src/Bootstrap/fromCardanoNode.ts @@ -3,14 +3,14 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { Cardano, - CardanoNodeErrors, ChainSyncEventType, Intersection, ObservableCardanoNode, - ObservableChainSync + ObservableChainSync, + TipOrOrigin } from '@cardano-sdk/core'; import { Logger } from 'ts-log'; -import { Observable, combineLatest, concat, defer, map, mergeMap, noop, of, take, takeWhile, tap } from 'rxjs'; +import { Observable, concat, defer, map, mergeMap, noop, of, switchMap, take, takeWhile, tap } from 'rxjs'; import { ProjectionEvent, StabilityWindowBuffer, UnifiedExtChainSyncEvent } from '../types'; import { contextLogger } from '@cardano-sdk/util'; import { pointDescription } from '../util'; @@ -24,13 +24,11 @@ const isIntersectionBlock = (block: Cardano.Block, intersection: Intersection) = return block.header.hash === intersection.point.hash; }; -const blocksToPoints = (blocks: Array) => - uniq([...blocks.map((p) => (p === 'origin' ? p : p.header)), 'origin' as const]); - const syncFromIntersection = ({ blocksBufferLength, buffer, cardanoNode, + projectedTip$, chainSync: { intersection, chainSync$ }, logger }: { @@ -38,6 +36,7 @@ const syncFromIntersection = ({ buffer: StabilityWindowBuffer; cardanoNode: ObservableCardanoNode; chainSync: ObservableChainSync; + projectedTip$: Observable; logger: Logger; }) => new Observable((observer) => { @@ -45,7 +44,7 @@ const syncFromIntersection = ({ return chainSync$ .pipe( ObservableCardanoNode.bufferChainSyncEvent(blocksBufferLength), - withRolledBackBlock(buffer), + withRolledBackBlock(projectedTip$, buffer), withNetworkInfo(cardanoNode), withEpochNo(), withEpochBoundary(intersection) @@ -59,33 +58,38 @@ const rollbackAndSyncFromIntersection = ({ cardanoNode, initialChainSync, logger, - tail + projectedTip$ }: { blocksBufferLength: number; buffer: StabilityWindowBuffer; cardanoNode: ObservableCardanoNode; initialChainSync: ObservableChainSync; logger: Logger; - tail: Cardano.Block | 'origin'; + projectedTip$: Observable; }) => new Observable((subscriber) => { logger.warn('Rolling back to find intersection'); let skipFindingNewIntersection = true; let chainSync = initialChainSync; - const rollback$ = buffer.tip$.pipe( + const rollback$ = projectedTip$.pipe( // Use the initial tip as intersection point for withEpochBoundary take(1), mergeMap((initialTip) => - buffer.tip$.pipe( - takeWhile((block): block is Cardano.Block => block !== 'origin'), + projectedTip$.pipe( + takeWhile((tip): tip is Cardano.PartialBlockHeader => tip !== 'origin'), + switchMap((tip) => buffer.getBlock(tip.hash)), mergeMap((block): Observable => { + if (!block) { + // TODO: replace with a ChainSyncError after reworking CardanoNodeErrors + throw new Error('Block not found in the buffer'); + } // we already have an intersection for the 1st tip if (skipFindingNewIntersection) { skipFindingNewIntersection = false; return of(block); } // try to find intersection with new tip - return cardanoNode.findIntersect(blocksToPoints([block, tail, 'origin'])).pipe( + return cardanoNode.findIntersect([block.header, 'origin']).pipe( take(1), tap((newChainSync) => { chainSync = newChainSync; @@ -109,27 +113,26 @@ const rollbackAndSyncFromIntersection = ({ ), withNetworkInfo(cardanoNode), withEpochNo(), - withEpochBoundary({ point: initialTip === 'origin' ? initialTip : initialTip.header }) + withEpochBoundary({ point: initialTip }) ) ) ); return concat( rollback$, - defer(() => syncFromIntersection({ blocksBufferLength, buffer, cardanoNode, chainSync, logger })) + defer(() => syncFromIntersection({ blocksBufferLength, buffer, cardanoNode, chainSync, logger, projectedTip$ })) ).subscribe(subscriber); }); /** - * Finds intersection with provider StabilityWindowBuffer. + * Finds intersection with local projectedTip$. * If bootstrapping from a forked local state: * - Will emit RollBackward events with block from buffer tip one by one until it finds intersection. - * - Expects buffer to emit new tip$ after processing each RollBackward event - * - * @throws InvalidIntersectionError when no intersection with provided {@link StabilityWindowBuffer} is found. + * - Expects projectedTip$ to emit after processing each RollBackward event */ export const fromCardanoNode = ({ blocksBufferLength, buffer, + projectedTip$, cardanoNode, logger: baseLogger }: { @@ -137,54 +140,41 @@ export const fromCardanoNode = ({ buffer: StabilityWindowBuffer; cardanoNode: ObservableCardanoNode; logger: Logger; + projectedTip$: Observable; }): Observable => { const logger = contextLogger(baseLogger, 'Bootstrap'); - return combineLatest([buffer.tip$, buffer.tail$]).pipe( + return projectedTip$.pipe( take(1), - mergeMap((blocks) => { - const points = blocksToPoints(blocks); - logger.info(`Starting projector with local tip at ${pointDescription(points[0])}`); - + mergeMap((tip) => { + logger.info(`Starting projector with local tip at ${pointDescription(tip)}`); + const points = uniq([tip, 'origin' as const]); return cardanoNode.findIntersect(points).pipe( take(1), mergeMap((initialChainSync) => { - if (initialChainSync.intersection.point === 'origin') { - if (blocks[0] !== 'origin') { - throw new CardanoNodeErrors.CardanoClientErrors.IntersectionNotFoundError( - // TODO: CardanoClientErrors are currently coupled to ogmios types. - // This would be cleaner if errors were mapped to use our core objects. - points.map((point) => - point === 'origin' - ? 'origin' - : { - hash: point.hash, - slot: point.slot - } - ) - ); - } - // buffer is empty, sync from origin + if ( + tip === 'origin' || + (initialChainSync.intersection.point !== 'origin' && initialChainSync.intersection.point.hash === tip.hash) + ) { + logger.info('syncFromIntersection'); + // either sync from origin, or start sync from local tip return syncFromIntersection({ blocksBufferLength, buffer, cardanoNode, chainSync: initialChainSync, - logger - }); - } - if (blocks[0] !== 'origin' && initialChainSync.intersection.point.hash !== blocks[0].header.hash) { - // rollback to intersection, then sync from intersection - return rollbackAndSyncFromIntersection({ - blocksBufferLength, - buffer, - cardanoNode, - initialChainSync, logger, - tail: blocks[1] + projectedTip$ }); } - // intersection is at tip$ - no rollback, just sync from intersection - return syncFromIntersection({ blocksBufferLength, buffer, cardanoNode, chainSync: initialChainSync, logger }); + // no intersection at local tip + return rollbackAndSyncFromIntersection({ + blocksBufferLength, + buffer, + cardanoNode, + initialChainSync, + logger, + projectedTip$ + }); }) ); }) diff --git a/packages/projection/src/InMemory/InMemoryStabilityWindowBuffer.ts b/packages/projection/src/InMemory/InMemoryStabilityWindowBuffer.ts index f20823fefe6..8c8072a0c64 100644 --- a/packages/projection/src/InMemory/InMemoryStabilityWindowBuffer.ts +++ b/packages/projection/src/InMemory/InMemoryStabilityWindowBuffer.ts @@ -1,11 +1,14 @@ -import { BehaviorSubject, tap } from 'rxjs'; -import { Cardano, ChainSyncEventType } from '@cardano-sdk/core'; +import { BehaviorSubject, Observable, of, tap } from 'rxjs'; +import { Cardano, ChainSyncEventType, TipOrOrigin } from '@cardano-sdk/core'; import { StabilityWindowBuffer, UnifiedExtChainSyncObservable, WithNetworkInfo } from '../types'; export class InMemoryStabilityWindowBuffer implements StabilityWindowBuffer { readonly #blocks: Cardano.Block[] = []; - readonly tip$ = new BehaviorSubject('origin'); - readonly tail$ = new BehaviorSubject('origin'); + readonly tip$: BehaviorSubject = new BehaviorSubject('origin'); + + getBlock(id: Cardano.BlockId): Observable { + return of(this.#blocks.find((block) => block.header.hash === id) || null); + } handleEvents() { return (evt$: UnifiedExtChainSyncObservable) => @@ -16,28 +19,15 @@ export class InMemoryStabilityWindowBuffer implements StabilityWindowBuffer { while (this.#blocks.length > securityParameter) this.#blocks.shift(); // add current block to cache and return the event unchanged this.#blocks.push(block); - this.tip$.next(block); - this.#setTail(this.#blocks[0]); + this.tip$.next(block.header); } else if (eventType === ChainSyncEventType.RollBackward) { const lastBlock = this.#blocks.pop(); if (lastBlock?.header.hash !== block.header.hash) { throw new Error('Assert: inconsistent stability window buffer at RollBackward'); } - this.tip$.next(this.#blocks[this.#blocks.length - 1] || 'origin'); - this.#setTail(this.#blocks[0] || 'origin'); + this.tip$.next(this.#blocks[this.#blocks.length - 1]?.header || 'origin'); } }) ); } - - shutdown(): void { - this.tip$.complete(); - this.tail$.complete(); - } - - #setTail(tail: Cardano.Block | 'origin') { - if (this.tail$.value !== tail) { - this.tail$.next(tail); - } - } } diff --git a/packages/projection/src/InMemory/types.ts b/packages/projection/src/InMemory/types.ts index eecccb8ebe4..a2420d22ae5 100644 --- a/packages/projection/src/InMemory/types.ts +++ b/packages/projection/src/InMemory/types.ts @@ -7,8 +7,8 @@ export type InMemoryStore = { stakePools: Map< Cardano.PoolId, { - updates: Mappers.PoolUpdate[]; - retirements: Mappers.PoolRetirement[]; + updates: Array; + retirements: Array; } >; }; diff --git a/packages/projection/src/operators/withRolledBackBlock.ts b/packages/projection/src/operators/withRolledBackBlock.ts index 408f6657141..3bc2fead1a2 100644 --- a/packages/projection/src/operators/withRolledBackBlock.ts +++ b/packages/projection/src/operators/withRolledBackBlock.ts @@ -1,26 +1,69 @@ -import { Cardano, ChainSyncEvent, ChainSyncEventType } from '@cardano-sdk/core'; +import { Cardano, ChainSyncEvent, ChainSyncEventType, TipOrOrigin } from '@cardano-sdk/core'; +import { + EMPTY, + Observable, + concatMap, + filter, + finalize, + map, + mergeMap, + noop, + of, + switchMap, + take, + takeWhile +} from 'rxjs'; import { ExtChainSyncOperator, StabilityWindowBuffer, WithBlock } from '../types'; -import { Observable, concatMap, finalize, map, noop, of, takeWhile } from 'rxjs'; + +const syncFromOrigin = (chainSyncEvent: ChainSyncEvent, projectedTip$: Observable) => + projectedTip$.pipe( + take(1), + mergeMap((tip) => { + if (tip !== 'origin') { + // TODO: replace with a ChainSyncError after reworking CardanoNodeErrors + throw new Error('Rollback to origin: wrong network?'); + } else { + // Rollback to origin while local tip is at origin is a no-op + chainSyncEvent.requestNext(); + return EMPTY; + } + }) + ); /** * Transforms rollback event into a stream of granular rollback events, each containing a single rolled back block. * Intended to be used as the 1st projection operator. */ export const withRolledBackBlock = - (buffer: StabilityWindowBuffer): ExtChainSyncOperator<{}, {}, {}, WithBlock> => + ( + projectedTip$: Observable, + buffer: StabilityWindowBuffer + ): ExtChainSyncOperator<{}, {}, {}, WithBlock> => (evt$: Observable) => evt$.pipe( concatMap((chainSyncEvent) => { switch (chainSyncEvent.eventType) { case ChainSyncEventType.RollForward: return of(chainSyncEvent); - case ChainSyncEventType.RollBackward: - return buffer.tip$.pipe( + case ChainSyncEventType.RollBackward: { + const rollbackPoint = chainSyncEvent.point; + if (rollbackPoint === 'origin') { + return syncFromOrigin(chainSyncEvent, projectedTip$); + } + return projectedTip$.pipe( takeWhile( - (block): block is Cardano.Block => - block !== 'origin' && - (chainSyncEvent.point === 'origin' || chainSyncEvent.point.hash !== block.header.hash) + (tip): tip is Cardano.PartialBlockHeader => tip !== 'origin' && tip.hash !== rollbackPoint.hash ), + switchMap((tip) => buffer.getBlock(tip.hash)), + filter((block): block is Cardano.Block => { + if (!block) { + // TODO: replace with a ChainSyncError after reworking CardanoNodeErrors + throw new Error( + `Could not rollback to ${rollbackPoint.hash}: tip block not found in stability window buffer` + ); + } + return true; + }), map((block) => ({ ...chainSyncEvent, block, @@ -29,6 +72,7 @@ export const withRolledBackBlock = // Call requestNext() once all rolled back blocks are processed finalize(chainSyncEvent.requestNext) ); + } } }) ); diff --git a/packages/projection/src/types.ts b/packages/projection/src/types.ts index 6cf34c0a6da..5c05cd6bee0 100644 --- a/packages/projection/src/types.ts +++ b/packages/projection/src/types.ts @@ -74,16 +74,9 @@ export type ProjectionOperator = UnifiedExtCha */ export interface StabilityWindowBuffer { /** - * Observable that emits current tip stored in stability window buffer. - * 'origin' when buffer is empty. - * Calling methods of the buffer should make this observable to emit. + * @returns an Observable that emits once and completes */ - tip$: Observable; - /** - * Observable that emits current tail (the first block) stored in stability window buffer. - * 'origin' when buffer is empty. - */ - tail$: Observable; + getBlock(id: Cardano.BlockId): Observable; } export type BaseProjectionEvent = diff --git a/packages/projection/test/InMemory/InMemoryStabilityWindowBuffer.test.ts b/packages/projection/test/InMemory/InMemoryStabilityWindowBuffer.test.ts index 0d47ab34278..8c3e7410903 100644 --- a/packages/projection/test/InMemory/InMemoryStabilityWindowBuffer.test.ts +++ b/packages/projection/test/InMemory/InMemoryStabilityWindowBuffer.test.ts @@ -1,6 +1,6 @@ import { Cardano, ChainSyncEventType, Seconds } from '@cardano-sdk/core'; import { InMemory, UnifiedExtChainSyncEvent, WithNetworkInfo } from '../../src'; -import { firstValueFrom, from, take, toArray } from 'rxjs'; +import { firstValueFrom, from } from 'rxjs'; import { genesisToEraSummary } from '@cardano-sdk/util-dev'; import { stubBlockId } from '../util'; @@ -32,36 +32,19 @@ describe('InMemory.InMemoryStabilityWindowBuffer', () => { buffer = new InMemory.InMemoryStabilityWindowBuffer(); }); - it('emits tip$ and tail$ when adding and deleting blocks', async () => { - const tips = firstValueFrom(buffer.tip$.pipe(take(10), toArray())); - const tails = firstValueFrom(buffer.tail$.pipe(take(5), toArray())); - buffer - .handleEvents()( - from([ - event(1, ChainSyncEventType.RollForward), - event(1, ChainSyncEventType.RollBackward), - event(1, ChainSyncEventType.RollForward), - event(2, ChainSyncEventType.RollForward), - event(2, ChainSyncEventType.RollBackward), - event(2, ChainSyncEventType.RollForward), - event(3, ChainSyncEventType.RollForward), - event(4, ChainSyncEventType.RollForward), - event(5, ChainSyncEventType.RollForward) - ]) - ) - .subscribe(); - expect(await tips).toEqual([ - 'origin', - event(1).block, - 'origin', - event(1).block, - event(2).block, - event(1).block, - event(2).block, - event(3).block, - event(4).block, - event(5).block - ]); - expect(await tails).toEqual(['origin', event(1).block, 'origin', event(1).block, event(2).block]); + describe('getBlock', () => { + it('emits the block when it exists in the buffer', async () => { + buffer + .handleEvents()(from([event(1, ChainSyncEventType.RollForward)])) + .subscribe(); + await expect(firstValueFrom(buffer.getBlock(stubBlockId(1)))).resolves.not.toBeNull(); + }); + + it('emits `null` when block does not exist in the buffer', async () => { + buffer + .handleEvents()(from([event(1, ChainSyncEventType.RollForward), event(1, ChainSyncEventType.RollBackward)])) + .subscribe(); + await expect(firstValueFrom(buffer.getBlock(stubBlockId(1)))).resolves.toBeNull(); + }); }); }); diff --git a/packages/projection/test/integration/InMemory.test.ts b/packages/projection/test/integration/InMemory.test.ts index eb54b5880e0..8815760e430 100644 --- a/packages/projection/test/integration/InMemory.test.ts +++ b/packages/projection/test/integration/InMemory.test.ts @@ -10,9 +10,9 @@ import { requestNext, withStaticContext } from '../../src'; -import { Cardano, CardanoNodeErrors, ChainSyncEventType, ChainSyncRollForward } from '@cardano-sdk/core'; +import { Cardano, ChainSyncEventType, ChainSyncRollForward } from '@cardano-sdk/core'; import { ChainSyncDataSet, StubChainSyncData, chainSyncData, logger } from '@cardano-sdk/util-dev'; -import { from, lastValueFrom, of, toArray } from 'rxjs'; +import { from, lastValueFrom, of, take, toArray } from 'rxjs'; const dataWithPoolRetirement = chainSyncData(ChainSyncDataSet.WithPoolRetirement); const dataWithStakeKeyDeregistration = chainSyncData(ChainSyncDataSet.WithStakeKeyDeregistration); @@ -26,20 +26,22 @@ describe('integration/InMemory', () => { let store: InMemory.InMemoryStore; let buffer: InMemory.InMemoryStabilityWindowBuffer; - const projectAll = ( + const project = ( { cardanoNode }: StubChainSyncData, projection: ProjectionOperator ) => - lastValueFrom( - Bootstrap.fromCardanoNode({ blocksBufferLength: 10, buffer, cardanoNode, logger }).pipe( - withStaticContext({ store }), - Mappers.withCertificates(), - projection, - buffer.handleEvents(), - requestNext(), - toArray() - ) - ); + Bootstrap.fromCardanoNode({ + blocksBufferLength: 10, + buffer, + cardanoNode, + logger, + projectedTip$: buffer.tip$ + }).pipe(withStaticContext({ store }), Mappers.withCertificates(), projection, buffer.handleEvents(), requestNext()); + + const projectAll = ( + data: StubChainSyncData, + projection: ProjectionOperator + ) => lastValueFrom(project(data, projection).pipe(toArray())); beforeEach(() => { store = { stakeKeys: new Set(), stakePools: new Map() }; @@ -139,19 +141,22 @@ describe('integration/InMemory', () => { ]) ) .subscribe(); - const [firstBlock] = await projectAll(dataWithStakeKeyDeregistration, projectStakeKeys); - expect(firstBlock.block.header.blockNo).toBe(22_622); - expect(firstBlock.crossEpochBoundary).toBe(false); + const [firstEvent, secondEvent] = await projectAll(dataWithStakeKeyDeregistration, projectStakeKeys); + expect(firstEvent.block.header.blockNo).toBe(22_622); + expect(firstEvent.crossEpochBoundary).toBe(false); + expect(firstEvent.eventType).toBe(ChainSyncEventType.RollBackward); + expect(secondEvent.eventType).toBe(ChainSyncEventType.RollForward); expect(store.stakeKeys.size).toBe(1); }); - it('errors with a buffer from another network', async () => { + it('rolls back all the way to origin when no intersection is found', async () => { buffer .handleEvents()( // No intersection, both block hashes are not present in the dataset from([ { block: { + body: [] as Cardano.OnChainTx[], header: { blockNo: Cardano.BlockNo(22_621), hash: Cardano.BlockId('c75e9fdb8c24caf2e8d10d1a066c1157572c4ce769378d6708ff2e0aa87ba2de'), @@ -163,6 +168,7 @@ describe('integration/InMemory', () => { } as RollForwardEvent, { block: { + body: [] as Cardano.OnChainTx[], header: { blockNo: Cardano.BlockNo(22_622), hash: Cardano.BlockId('c75e9fdb8c24caf2e8d10d1a066c1157572c4ce769378d6708ff2e0aa87ba2df'), @@ -175,8 +181,13 @@ describe('integration/InMemory', () => { ]) ) .subscribe(); - await expect(projectAll(dataWithStakeKeyDeregistration, projectStakeKeys)).rejects.toThrowError( - CardanoNodeErrors.CardanoClientErrors.IntersectionNotFoundError + const events = await lastValueFrom( + project(dataWithStakeKeyDeregistration, projectStakeKeys).pipe(take(3), toArray()) ); + expect(events.map((evt) => evt.eventType)).toEqual([ + ChainSyncEventType.RollBackward, + ChainSyncEventType.RollBackward, + ChainSyncEventType.RollForward + ]); }); }); diff --git a/packages/projection/test/operators/withRolledBackBlock.test.ts b/packages/projection/test/operators/withRolledBackBlock.test.ts index 36ebd955898..57cab7070ec 100644 --- a/packages/projection/test/operators/withRolledBackBlock.test.ts +++ b/packages/projection/test/operators/withRolledBackBlock.test.ts @@ -1,13 +1,18 @@ -import { Cardano, ChainSyncEventType, ChainSyncRollBackward } from '@cardano-sdk/core'; +import { Cardano, ChainSyncEventType, ChainSyncRollBackward, TipOrOrigin } from '@cardano-sdk/core'; import { ChainSyncDataSet, chainSyncData, createTestScheduler } from '@cardano-sdk/util-dev'; import { InMemory, UnifiedExtChainSyncEvent, withNetworkInfo, withRolledBackBlock } from '../../src'; import { stubBlockId } from '../util'; const dataWithStakeKeyDeregistration = chainSyncData(ChainSyncDataSet.WithPoolRetirement); +const createBlockHeader = (slot: number): Cardano.PartialBlockHeader => ({ + blockNo: Cardano.BlockNo(slot), + hash: stubBlockId(slot), + slot: Cardano.Slot(slot) +}); const createEvent = (eventType: ChainSyncEventType, slot: number, tipSlot = slot) => ({ - block: { header: { blockNo: Cardano.BlockNo(slot), hash: stubBlockId(slot), slot: Cardano.Slot(slot) } }, + block: { header: createBlockHeader(slot) }, eventType, point: eventType === ChainSyncEventType.RollForward @@ -17,7 +22,7 @@ const createEvent = (eventType: ChainSyncEventType, slot: number, tipSlot = slot tip: { blockNo: Cardano.BlockNo(tipSlot), hash: stubBlockId(tipSlot), slot: Cardano.Slot(tipSlot) } } as UnifiedExtChainSyncEvent<{}>); -const sourceRollback = (slot: number): ChainSyncRollBackward => { +const sourceRollbackToPoint = (slot: number): ChainSyncRollBackward => { const point = { hash: stubBlockId(slot), slot: Cardano.Slot(slot) }; return { eventType: ChainSyncEventType.RollBackward, @@ -27,16 +32,28 @@ const sourceRollback = (slot: number): ChainSyncRollBackward => { }; }; -describe('withRolledBackBlocks', () => { +const sourceRollbackToOrigin = (): ChainSyncRollBackward => ({ + eventType: ChainSyncEventType.RollBackward, + point: 'origin', + requestNext: jest.fn(), + tip: 'origin' +}); + +describe('withRolledBackBlock', () => { let buffer: InMemory.InMemoryStabilityWindowBuffer; beforeEach(() => { buffer = new InMemory.InMemoryStabilityWindowBuffer(); }); - it('re-emits rolled back blocks one by one and calls requestNext on original event', () => { - createTestScheduler().run(({ hot, expectObservable, expectSubscriptions, flush }) => { - const originalRollbackEvent = sourceRollback(1); + it('re-emits rolled back blocks til rollback point one by one and calls requestNext on original event', () => { + createTestScheduler().run(({ cold, hot, expectObservable, expectSubscriptions, flush }) => { + const originalRollbackEvent = sourceRollbackToPoint(1); + const projectedTip$ = cold('dcb', { + b: createBlockHeader(1), + c: createBlockHeader(2), + d: createBlockHeader(3) + }); const source$ = hot('abcde', { a: createEvent(ChainSyncEventType.RollForward, 0), b: createEvent(ChainSyncEventType.RollForward, 1), @@ -46,11 +63,11 @@ describe('withRolledBackBlocks', () => { }); expectObservable( source$.pipe( - withRolledBackBlock(buffer), + withRolledBackBlock(projectedTip$, buffer), withNetworkInfo(dataWithStakeKeyDeregistration.cardanoNode), buffer.handleEvents() ) - ).toBe('abcd(ef)', { + ).toBe('abcdef', { a: { ...createEvent(ChainSyncEventType.RollForward, 0), ...dataWithStakeKeyDeregistration.networkInfo }, b: { ...createEvent(ChainSyncEventType.RollForward, 1), ...dataWithStakeKeyDeregistration.networkInfo }, c: { ...createEvent(ChainSyncEventType.RollForward, 2), ...dataWithStakeKeyDeregistration.networkInfo }, @@ -63,4 +80,64 @@ describe('withRolledBackBlocks', () => { expect(originalRollbackEvent.requestNext).toBeCalledTimes(1); }); }); + + describe('rollback to origin', () => { + describe('when local tip at origin', () => { + it('calls requestNext without emitting', () => { + createTestScheduler().run(({ cold, hot, expectObservable, expectSubscriptions, flush }) => { + const rollbackEvent = sourceRollbackToOrigin(); + const projectedTip$ = cold('a', { + a: 'origin' + }); + const source$ = hot('a|', { + a: rollbackEvent + }); + expectObservable(source$.pipe(withRolledBackBlock(projectedTip$, buffer))).toBe('-|'); + expectSubscriptions(source$.subscriptions).toBe('^!'); + flush(); + expect(rollbackEvent.requestNext).toBeCalledTimes(1); + }); + }); + }); + + describe('when local tip is not at origin', () => { + it('assumes we connected to the wrong network and throws', () => { + createTestScheduler().run(({ cold, hot, expectObservable, flush }) => { + const rollbackEvent = sourceRollbackToOrigin(); + const projectedTip$ = cold('a', { + a: createBlockHeader(1) + }); + const source$ = hot('a|', { + a: rollbackEvent + }); + expectObservable(source$.pipe(withRolledBackBlock(projectedTip$, buffer))).toBe( + '#', + {}, + new Error('Rollback to origin: wrong network?') + ); + flush(); + }); + }); + }); + }); + + it('throws when block is not found in the buffer', () => { + createTestScheduler().run(({ cold, hot, expectObservable, flush }) => { + const rollbackEvent = sourceRollbackToPoint(1); + const projectedTip$ = cold('a', { + a: createBlockHeader(2) + }); + const source$ = hot('a|', { + a: rollbackEvent + }); + expectObservable(source$.pipe(withRolledBackBlock(projectedTip$, buffer))).toBe( + '#', + {}, + new Error( + 'Could not rollback to 0000000000000000000000000000000000000000000000000000000000000001: tip block not found in stability window buffer' + ) + ); + flush(); + }); + }); });