Update CLI to compare only updated entities and verify IPLD state (#161)

* Change compare CLI to verify only updated entities

* Implement IPLD state verification in compare CLI

* Changes to IPLD state to match with GQL result entity
This commit is contained in:
2022-08-17 19:11:40 +05:30
committed by GitHub
parent ec56de057f
commit 7238f614c0
33 changed files with 518 additions and 68 deletions
+16 -3
View File
@@ -75,9 +75,9 @@
./bin/compare-blocks --config-file environments/compare-cli-config.toml --start-block 1 --end-block 10
```
* For comparing entities after fetching ids from one of the endpoints and then querying individually by ids:
* For comparing entities after fetching updated entity ids from watcher database:
* Set the `idsEndpoint` to choose which endpoint the ids should be fetched from.
* Set the watcher config file path and entities directory.
```toml
[endpoints]
@@ -90,7 +90,20 @@
"author",
"blog"
]
idsEndpoint = "gqlEndpoint1"
[watcher]
configPath = "../../graph-test-watcher/environments/local.toml"
entitiesDir = "../../graph-test-watcher/dist/entity/*"
```
* To verify diff IPLD state generated at each block, set the watcher endpoint and `verifyState` flag to true
```toml
[watcher]
configPath = "../../graph-test-watcher/environments/local.toml"
entitiesDir = "../../graph-test-watcher/dist/entity/*"
endpoint = "gqlEndpoint2"
verifyState = true
```
* Run the CLI with `fetch-ids` flag set to true:\
@@ -6,6 +6,13 @@
queryDir = "../graph-test-watcher/src/gql/queries"
names = []
idsEndpoint = "gqlEndpoint1"
blockDelayInMs = 250
[watcher]
configpath = "../../graph-test-watcher/environments/local.toml"
entitiesDir = "../../graph-test-watcher/src/entity"
endpoint = "gqlEndpoint2"
verifyState = true
[cache]
endpoint = "gqlEndpoint1"
+1
View File
@@ -58,6 +58,7 @@
"js-yaml": "^4.1.0",
"json-bigint": "^1.0.0",
"json-diff": "^0.5.4",
"omit-deep": "^0.3.0",
"pluralize": "^8.0.0",
"reflect-metadata": "^0.1.13",
"toml": "^3.0.0",
@@ -5,10 +5,17 @@
import yargs from 'yargs';
import 'reflect-metadata';
import debug from 'debug';
import path from 'path';
import assert from 'assert';
import { SnakeNamingStrategy } from 'typeorm-naming-strategies';
import _ from 'lodash';
import omitDeep from 'omit-deep';
import { getConfig as getWatcherConfig, wait } from '@vulcanize/util';
import { GraphQLClient } from '@vulcanize/ipld-eth-client';
import { compareQuery, Config, getClients, getConfig } from './utils';
import { Client } from './client';
import { compareObjects, compareQuery, Config, getBlockIPLDState as getIPLDStateByBlock, getClients, getConfig } from './utils';
import { Database } from '../../database';
import { getSubgraphConfig } from '../../utils';
const log = debug('vulcanize:compare-blocks');
@@ -50,48 +57,109 @@ export const main = async (): Promise<void> => {
}
}).argv;
const config: Config = await getConfig(argv.configFile);
const { startBlock, endBlock, rawJson, queryDir, fetchIds } = argv;
const { startBlock, endBlock, rawJson, queryDir, fetchIds, configFile } = argv;
const config: Config = await getConfig(configFile);
const snakeNamingStrategy = new SnakeNamingStrategy();
const clients = await getClients(config, queryDir);
const queryNames = config.queries.names;
let diffFound = false;
let blockDelay = wait(0);
let subgraphContracts: string[] = [];
let db: Database | undefined, subgraphGQLClient: GraphQLClient | undefined;
const clients = await getClients(config, queryDir);
if (config.watcher) {
const watcherConfigPath = path.resolve(path.dirname(configFile), config.watcher.configPath);
const entitiesDir = path.resolve(path.dirname(configFile), config.watcher.entitiesDir);
const watcherConfig = await getWatcherConfig(watcherConfigPath);
db = new Database(watcherConfig.database, entitiesDir);
await db.init();
if (config.watcher.verifyState) {
const { dataSources } = await getSubgraphConfig(watcherConfig.server.subgraphPath);
subgraphContracts = dataSources.map((dataSource: any) => dataSource.source.address);
const watcherEndpoint = config.endpoints[config.watcher.endpoint] as string;
subgraphGQLClient = new GraphQLClient({ gqlEndpoint: watcherEndpoint });
}
}
for (let blockNumber = startBlock; blockNumber <= endBlock; blockNumber++) {
const block = { number: blockNumber };
let updatedEntityIds: string[][] = [];
let ipldStateByBlock = {};
console.time(`time:compare-block-${blockNumber}`);
for (const queryName of queryNames) {
if (fetchIds) {
// Fetch entity ids updated at block.
console.time(`time:fetch-updated-ids-${blockNumber}`);
const updatedEntityIdPromises = queryNames.map(
queryName => {
assert(db);
return db.getEntityIdsAtBlockNumber(
blockNumber,
snakeNamingStrategy.tableName(queryName, '')
);
}
);
updatedEntityIds = await Promise.all(updatedEntityIdPromises);
console.timeEnd(`time:fetch-updated-ids-${blockNumber}`);
}
if (config.watcher.verifyState) {
assert(db);
const [block] = await db?.getBlocksAtHeight(blockNumber, false);
assert(subgraphGQLClient);
ipldStateByBlock = await getIPLDStateByBlock(subgraphGQLClient, subgraphContracts, block.blockHash);
}
await blockDelay;
for (const [index, queryName] of queryNames.entries()) {
try {
log(`At block ${blockNumber} for query ${queryName}:`);
let resultDiff = '';
if (fetchIds) {
const { idsEndpoint } = config.queries;
assert(idsEndpoint, 'Specify endpoint for fetching ids when fetchId is true');
const client = Object.values(clients).find(client => client.endpoint === config.endpoints[idsEndpoint]);
assert(client);
const ids = await client.getIds(queryName, blockNumber);
for (const id of updatedEntityIds[index]) {
const { diff, result1: result } = await compareQuery(
clients,
queryName,
{ block, id },
rawJson
);
for (const id of ids) {
const isDiff = await compareAndLog(clients, queryName, { block, id }, rawJson);
if (config.watcher.verifyState) {
await checkEntityInIPLDState(ipldStateByBlock, queryName, result, id, rawJson);
}
if (isDiff) {
diffFound = isDiff;
if (diff) {
resultDiff = diff;
}
}
} else {
const isDiff = await compareAndLog(clients, queryName, { block }, rawJson);
({ diff: resultDiff } = await compareQuery(
clients,
queryName,
{ block },
rawJson
));
}
if (isDiff) {
diffFound = isDiff;
}
if (resultDiff) {
log('Results mismatch:', resultDiff);
diffFound = true;
} else {
log('Results match.');
}
} catch (err: any) {
log('Error:', err.message);
}
}
// Set delay between requests for a block.
blockDelay = wait(config.queries.blockDelayInMs || 0);
console.timeEnd(`time:compare-block-${blockNumber}`);
}
@@ -100,24 +168,21 @@ export const main = async (): Promise<void> => {
}
};
const compareAndLog = async (
clients: { client1: Client, client2: Client },
const checkEntityInIPLDState = async (
ipldState: {[key: string]: any},
queryName: string,
params: { [key: string]: any },
entityResult: {[key: string]: any},
id: string,
rawJson: boolean
): Promise<boolean> => {
const resultDiff = await compareQuery(
clients,
queryName,
params,
rawJson
);
) => {
const entityName = _.startCase(queryName);
const ipldEntity = ipldState[entityName][id];
if (resultDiff) {
log('Results mismatch:', resultDiff);
return true;
// Filter __typename key in GQL result.
const resultEntity = omitDeep(entityResult[queryName], '__typename');
const diff = compareObjects(ipldEntity, resultEntity, rawJson);
if (diff) {
log('Results mismatch for IPLD state:', diff);
}
log('Results match.');
return false;
};
@@ -4,9 +4,12 @@
import yargs from 'yargs';
import 'reflect-metadata';
import debug from 'debug';
import { compareQuery, Config, getClients, getConfig } from './utils';
const log = debug('vulcanize:compare-entity');
export const main = async (): Promise<void> => {
const argv = await yargs.parserConfiguration({
'parse-numbers': false
@@ -62,10 +65,10 @@ export const main = async (): Promise<void> => {
const clients = await getClients(config, argv.queryDir);
const resultDiff = await compareQuery(clients, queryName, { id, block }, argv.rawJson);
const { diff } = await compareQuery(clients, queryName, { id, block }, argv.rawJson);
if (resultDiff) {
console.log(resultDiff);
if (diff) {
log(diff);
process.exit(1);
}
};
+73 -17
View File
@@ -8,25 +8,43 @@ import path from 'path';
import toml from 'toml';
import fs from 'fs-extra';
import { diffString, diff } from 'json-diff';
import _ from 'lodash';
import { Config as CacheConfig, getCache } from '@vulcanize/cache';
import { GraphQLClient } from '@vulcanize/ipld-eth-client';
import { gql } from '@apollo/client/core';
import { Client } from './client';
const IPLD_STATE_QUERY = `
query getState($blockHash: String!, $contractAddress: String!, $kind: String){
getState(blockHash: $blockHash, contractAddress: $contractAddress, kind: $kind){
data
}
}
`;
interface EndpointConfig {
gqlEndpoint1: string;
gqlEndpoint2: string;
requestDelayInMs: number;
}
interface QueryConfig {
queryDir: string;
names: string[];
idsEndpoint: keyof EndpointConfig;
blockDelayInMs: number;
}
export interface Config {
endpoints: EndpointConfig;
queries: QueryConfig;
watcher: {
configPath: string;
entitiesDir: string;
verifyState: boolean;
endpoint: keyof EndpointConfig;
}
cache: {
endpoint: keyof EndpointConfig;
config: CacheConfig;
@@ -52,6 +70,12 @@ export const getConfig = async (configFile: string): Promise<Config> => {
return config;
};
interface CompareResult {
diff: string,
result1: any,
result2: any
}
export const compareQuery = async (
clients: {
client1: Client,
@@ -60,27 +84,22 @@ export const compareQuery = async (
queryName: string,
params: { [key: string]: any },
rawJson: boolean
): Promise<string> => {
): Promise<CompareResult> => {
const { client1, client2 } = clients;
const result2 = await client2.getResult(queryName, params);
const result1 = await client1.getResult(queryName, params);
const [result1, result2] = await Promise.all([
client1.getResult(queryName, params),
client2.getResult(queryName, params)
]);
// Getting the diff of two result objects.
let resultDiff;
const resultDiff = compareObjects(result1, result2, rawJson);
if (rawJson) {
resultDiff = diff(result1, result2);
if (resultDiff) {
// Use util.inspect to extend depth limit in the output.
resultDiff = util.inspect(diff(result1, result2), false, null);
}
} else {
resultDiff = diffString(result1, result2);
}
return resultDiff;
return {
diff: resultDiff,
result1,
result2
};
};
export const getClients = async (config: Config, queryDir?: string):Promise<{
@@ -121,3 +140,40 @@ export const getClients = async (config: Config, queryDir?: string):Promise<{
client2
};
};
export const getBlockIPLDState = async (client: GraphQLClient, contracts: string[], blockHash: string): Promise<{[key: string]: any}> => {
const contractIPLDStates: {[key: string]: any}[] = await Promise.all(contracts.map(async contract => {
const { getState } = await client.query(
gql(IPLD_STATE_QUERY),
{
blockHash,
contractAddress: contract,
kind: 'diff'
}
);
if (getState) {
const data = JSON.parse(getState.data);
return data.state;
}
return {};
}));
return contractIPLDStates.reduce((acc, state) => _.merge(acc, state));
};
export const compareObjects = (obj1: any, obj2: any, rawJson: boolean): string => {
if (rawJson) {
const diffObj = diff(obj1, obj2);
if (diffObj) {
// Use util.inspect to extend depth limit in the output.
return util.inspect(diffObj, false, null);
}
return '';
} else {
return diffString(obj1, obj2);
}
};
+22 -1
View File
@@ -7,11 +7,13 @@ import {
Connection,
ConnectionOptions,
FindOneOptions,
LessThanOrEqual
LessThanOrEqual,
Repository
} from 'typeorm';
import {
BlockHeight,
BlockProgressInterface,
Database as BaseDatabase
} from '@vulcanize/util';
@@ -77,6 +79,19 @@ export class Database {
}
}
async getEntityIdsAtBlockNumber (blockNumber: number, tableName: string): Promise<string[]> {
const repo = this._conn.getRepository(tableName);
const entities = await repo.find({
select: ['id'],
where: {
blockNumber
}
});
return entities.map((entity: any) => entity.id);
}
async getEntityWithRelations<Entity> (entity: (new () => Entity) | string, id: string, relations: { [key: string]: any }, block: BlockHeight = {}): Promise<Entity | undefined> {
const queryRunner = this._conn.createQueryRunner();
let { hash: blockHash, number: blockNumber } = block;
@@ -265,4 +280,10 @@ export class Database {
return acc;
}, {});
}
async getBlocksAtHeight (height: number, isPruned: boolean) {
const repo: Repository<BlockProgressInterface> = this._conn.getRepository('block_progress');
return this._baseDatabase.getBlocksAtHeight(repo, height, isPruned);
}
}
+29 -6
View File
@@ -10,7 +10,6 @@ import {
Contract,
ContractInterface
} from 'ethers';
import JSONbig from 'json-bigint';
import BN from 'bn.js';
import debug from 'debug';
@@ -26,12 +25,11 @@ import {
resolveEntityFieldConflicts,
getEthereumTypes,
jsonFromBytes,
getStorageValueType
getStorageValueType,
jsonBigIntStringReplacer
} from './utils';
import { Database } from './database';
const JSONbigString = JSONbig({ storeAsString: true });
// Endianness of BN used in bigInt store host API.
// Negative bigInt is being stored in wasm in 2's compliment, 'le' representation.
// (for eg. bigInt.fromString(negativeI32Value))
@@ -104,14 +102,39 @@ export const instantiate = async (
// Prepare the diff data.
const diffData: any = { state: {} };
assert(indexer.getRelationsMap);
const result = Array.from(indexer.getRelationsMap().entries())
.find(([key]) => key.name === entityName);
if (result) {
// Update dbData if relations exist.
const [_, relations] = result;
// Update relation fields for diff data to be similar to GQL query entities.
Object.entries(relations).forEach(([relation, { isArray, isDerived }]) => {
if (isDerived || !dbData[relation]) {
// Field is not present in dbData for derived relations
return;
}
if (isArray) {
dbData[relation] = dbData[relation]
.map((id: string) => ({ id }))
.sort((a: any, b: any) => a.id.localeCompare(b.id));
} else {
dbData[relation] = { id: dbData[relation] };
}
});
}
// JSON stringify and parse data for handling unknown types when encoding.
// For example, decimal.js values are converted to string in the diff data.
diffData.state[entityName] = {
// Using JSONbigString to store bigints as string values to be encoded by IPLD dag-cbor.
// Using custom replacer to store bigints as string values to be encoded by IPLD dag-cbor.
// TODO: Parse and store as native bigint by using Type encoders in IPLD dag-cbor encode.
// https://github.com/rvagg/cborg#type-encoders
[dbData.id]: JSONbigString.parse(JSONbigString.stringify(dbData))
[dbData.id]: JSON.parse(JSON.stringify(dbData, jsonBigIntStringReplacer))
};
// Create an auto-diff.
+5
View File
@@ -0,0 +1,5 @@
//
// Copyright 2022 Vulcanize, Inc.
//
declare module 'omit-deep';
@@ -0,0 +1,6 @@
{
"name": "common",
"version": "0.1.0",
"license": "AGPL-3.0",
"typings": "main.d.ts"
}
+8
View File
@@ -798,3 +798,11 @@ const getEthereumType = (storageTypes: StorageLayout['types'], type: string, map
return utils.ParamType.from(label);
};
export const jsonBigIntStringReplacer = (_: string, value: any) => {
if (typeof value === 'bigint') {
return value.toString();
}
return value;
};
+1 -1
View File
@@ -52,7 +52,7 @@
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
"downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */