github.com/kaleido-io/firefly@v0.0.0-20210622132723-8b4b6aacb971/kat/src/lib/event-stream.ts (about)

     1  // Copyright © 2021 Kaleido, Inc.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  import * as QueryString from 'querystring';
    16  import { URL } from 'url';
    17  import axios, { AxiosInstance } from 'axios';
    18  import { promisify } from 'util';
    19  import * as timers from 'timers';
    20  import * as database from '../clients/database';
    21  const sleep = promisify(timers.setTimeout);
    22  
    23  import * as utils from './utils';
    24  import { config } from './config'
    25  import { IConfig, IEventStream, IEventStreamSubscription } from './interfaces';
    26  const logger = utils.getLogger('lib/event-stream.ts');
    27  
    28  /* istanbul ignore next */
    29  const requestLogger = (config: any) => {
    30    const qs = config.params ? `?${QueryString.stringify(config.params)}` : '';
    31    logger.info(`--> ${config.method} ${config.baseURL}${config.url}${qs}`);
    32    logger.debug(config.data);
    33    return config;
    34  };
    35  
    36  /* istanbul ignore next */
    37  const responseLogger = (response: any) => {
    38    const { config, status, data } = response;
    39    logger.info(`<-- ${config.method} ${config.url} [${status}]`);
    40    logger.debug(data);
    41    return response;
    42  };
    43  
    44  /* istanbul ignore next */
    45  const errorLogger = (err: any) => {
    46    const { config = {}, response = {} } = err;
    47    const { status, data } = response;
    48    logger.info(`<-- ${config.method} ${config.url} [${status || err}]: ${JSON.stringify(data)}`);
    49    throw err;
    50  };
    51  
    52  const subscriptionsInfoEthereum = [
    53    ['Asset instance created', 'AssetInstanceCreated'],
    54    ['Asset instance batch created', 'AssetInstanceBatchCreated'],
    55    ['Payment instance created', 'PaymentInstanceCreated'],
    56    ['Payment definition created', 'PaymentDefinitionCreated'],
    57    ['Asset definition created', 'AssetDefinitionCreated'],
    58    ['Asset instance property set', 'AssetInstancePropertySet'],
    59    ['Described payment instance created', 'DescribedPaymentInstanceCreated'],
    60    ['Described asset instance created', 'DescribedAssetInstanceCreated'],
    61    ['Described payment definition created', 'DescribedPaymentDefinitionCreated'],
    62    ['Member registered', 'MemberRegistered']
    63  ];
    64  
    65  const subscriptionInfoCorda = [
    66    ['Asset instance created', 'io.kaleido.kat.states.AssetInstanceCreated'],
    67    ['Described asset instance created', 'io.kaleido.kat.states.DescribedAssetInstanceCreated'],
    68    ['Asset instance batch created', 'io.kaleido.kat.states.AssetInstanceBatchCreated'],
    69    ['Asset instance property set', 'io.kaleido.kat.states.AssetInstancePropertySet']
    70  ]
    71  
    72  export const ensureEventStreamAndSubscriptions = async () => {
    73    if(!config.eventStreams.skipSetup) {
    74      const esMgr = new EventStreamManager(config);
    75      await esMgr.ensureEventStreamsWithRetry();
    76    }
    77  };
    78  
    79  class EventStreamManager {
    80    private gatewayPath: string;
    81    private api: AxiosInstance;
    82    private streamDetails: any;
    83    private retryCount: number;
    84    private retryDelay: number;
    85    private protocol: string;
    86  
    87    constructor(config: IConfig) {
    88      const apiURL = new URL(config.apiGateway.apiEndpoint);
    89      this.gatewayPath = apiURL.pathname.replace(/^\//, '');
    90      apiURL.pathname = '';
    91      const creds = `${config.apiGateway.auth?.user??config.appCredentials.user}:${config.apiGateway.auth?.password??config.appCredentials.password}`;
    92      this.api = axios.create({
    93        baseURL: apiURL.href,
    94        headers: {
    95          Authorization: `Basic ${Buffer.from(creds).toString('base64')}`
    96        }
    97      });
    98      this.api.interceptors.request.use(requestLogger);
    99      this.api.interceptors.response.use(responseLogger, errorLogger);
   100      this.retryCount = 20;
   101      this.retryDelay = 5000;
   102      this.protocol = config.protocol;
   103      this.streamDetails = {
   104        name: config.eventStreams.topic,
   105        errorHandling: config.eventStreams.config?.errorHandling??'block',
   106        batchSize: config.eventStreams.config?.batchSize??50,
   107        batchTimeoutMS: config.eventStreams.config?.batchTimeoutMS??500,
   108        type: "websocket",
   109        websocket: {
   110          topic: config.eventStreams.topic
   111        }
   112      }
   113      if(this.protocol === 'ethereum') {
   114        // Due to spell error in ethconnect, we do this for now until ethconnect is updated
   115        this.streamDetails.blockedReryDelaySec = config.eventStreams.config?.blockedRetryDelaySec??30;
   116      } else {
   117        this.streamDetails.blockedRetryDelaySec = config.eventStreams.config?.blockedRetryDelaySec??30;
   118      }
   119    }
   120  
   121    async ensureEventStreamsWithRetry() {
   122      for (let i = 1; i <= this.retryCount; i++) {
   123        try {
   124          if (i > 1) await sleep(this.retryDelay);
   125          const stream: IEventStream = await this.ensureEventStream();
   126          await this.ensureSubscriptions(stream);
   127          return;
   128        } catch (err) {
   129          logger.error(`Attempt ${i} to initialize event streams failed`, err);
   130        }
   131      }
   132      throw new Error("Failed to initialize event streams after retries");
   133    }
   134  
   135    async ensureEventStream(): Promise<IEventStream> {
   136      const { data: existingStreams } = await this.api.get('eventstreams');
   137      let stream = existingStreams.find((s: any) => s.name === this.streamDetails.name);
   138      if (stream) {
   139        const { data: patchedStream } = await this.api.patch(`eventstreams/${stream.id}`, this.streamDetails);
   140        return patchedStream;
   141      }
   142      const { data: newStream } = await this.api.post('eventstreams', this.streamDetails);
   143      return newStream;
   144    }
   145  
   146    subscriptionInfo() {
   147      switch(this.protocol) {
   148        case 'ethereum':
   149          return subscriptionsInfoEthereum;
   150        case 'corda':
   151          return subscriptionInfoCorda;
   152        default:
   153          throw new Error("Unsupported protocol.");
   154      }
   155    }
   156  
   157    async createSubscription(eventType: string, streamId: string, description: string): Promise<{id: string, name: string}> {
   158      switch(this.protocol) {
   159        case 'ethereum':
   160          return this.api.post(`${this.gatewayPath}/${eventType}/Subscribe`, {
   161            description,
   162            name: eventType,
   163            stream: streamId,
   164            fromBlock: "0", // Subscribe from the start of the chain
   165          }).then(r => { logger.info(`Created subscription ${eventType}: ${r.data.id}`); return r.data });
   166        case 'corda': 
   167          return this.api.post('subscriptions', {
   168            name: eventType,
   169            stream: streamId,
   170            fromTime: null, // BEGINNING is specified as `null` in Corda event streams
   171            filter: {
   172              stateType: eventType,
   173              stateStatus: "unconsumed",
   174              relevancyStatus: "all"
   175            }
   176          }).then(r => { logger.info(`Created subscription ${eventType}: ${r.data.id}`); return r.data });
   177        default:
   178          throw new Error("Unsupported protocol.");
   179      }
   180    }
   181  
   182  
   183  
   184    async ensureSubscriptions(stream: IEventStream) {
   185      const dbSubscriptions = (await database.retrieveSubscriptions()) || {};
   186      const { data: existing } = await this.api.get('subscriptions');
   187      for (const [description, eventName] of this.subscriptionInfo()) {
   188        const dbEventName = eventName.replace(/\./g, '_');
   189        let sub = existing.find((s: IEventStreamSubscription) => s.name === eventName && s.stream === stream.id);
   190        let storedSubId = dbSubscriptions[dbEventName];
   191        if (!sub || sub.id !== storedSubId) {
   192          if (sub) {
   193            logger.info(`Deleting stale subscription that does not match persisted id ${storedSubId}`, sub);
   194            await this.api.delete(`subscriptions/${sub.id}`);
   195          }
   196          const newSub = await this.createSubscription(eventName, stream.id, description);
   197          dbSubscriptions[dbEventName] = newSub.id;
   198        } else {
   199          logger.info(`Subscription ${eventName}: ${sub.id}`);
   200        }
   201      }
   202      await database.upsertSubscriptions(dbSubscriptions);
   203    }
   204  
   205  }