github.com/kaleido-io/firefly@v0.0.0-20210622132723-8b4b6aacb971/kat/src/test/ethereum/lib/batch-processor-test.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 assert from 'assert';
    16  import sinon, { SinonStub } from 'sinon';
    17  import { promisify } from 'util';
    18  import * as database from '../../../clients/database';
    19  import { BatchProcessor } from '../../../lib/batch-processor';
    20  import { IDBBatch, BatchRecordType } from '../../../lib/interfaces';
    21  
    22  const delay = promisify(setTimeout);
    23  
    24  export const testBatchProcessor = async () => {
    25  
    26  describe('BatchProcessor', () => {
    27  
    28    beforeEach(() => {
    29      sinon.stub(database, 'retrieveBatches');
    30      sinon.stub(database, 'upsertBatch');
    31    });
    32  
    33    afterEach(() => {
    34      (database.retrieveBatches as SinonStub).restore();
    35      (database.upsertBatch as SinonStub).restore();
    36    })
    37  
    38    it('fills a batch full in parallel and dispatches it, then cleans up', async () => {
    39  
    40      const processBatchCallback = sinon.stub();
    41      const processorCompleteCallback = sinon.stub();
    42      const p = new BatchProcessor(
    43        'author1',
    44        'type1',
    45        processBatchCallback,
    46        processorCompleteCallback,
    47      );
    48  
    49      const scheduleRandomDelayedAdd = async (i: number) => {
    50        // Introduce some randomness, but with very short delays to keep the test fast
    51        await delay(Math.ceil(Math.random() * 5));
    52        // Half and half records vs. properties
    53        if (i % 2 === 0) {
    54          await p.add({
    55            recordType: BatchRecordType.assetInstance,
    56            id: `test_${i}`,
    57          });  
    58        } else {
    59          await p.add({
    60            recordType: BatchRecordType.assetProperty,
    61            key: `test_${i}`,
    62            value: `value_${i}`,
    63          });  
    64        }
    65      }
    66  
    67      const promises: Promise<void>[] = [];
    68      for (let i = 0; i < p.config.batchMaxRecords; i++) {
    69        promises.push(scheduleRandomDelayedAdd(i));
    70      }
    71      await Promise.all(promises);
    72  
    73      assert.strictEqual(processBatchCallback.callCount, 1);
    74      assert.strictEqual(processorCompleteCallback.callCount, 1);
    75      const batch: IDBBatch = processBatchCallback.getCall(0).args[0];
    76      for (let i = 0; i < p.config.batchMaxRecords; i++) {
    77        // Half and half records vs. properties
    78        if (i % 2 === 0) {
    79          assert(batch.records.find(r => r.id === `test_${i}`));
    80        } else {
    81          assert(batch.records.find(r => r.key === `test_${i}`));
    82          assert(batch.records.find(r => r.value === `value_${i}`));
    83        }
    84      }
    85  
    86    });
    87  
    88    it('takes a batch array on input simulating recovery, and dispatches immediately', async () => {
    89  
    90      const processBatchCallback = sinon.stub();
    91      const processorCompleteCallback = sinon.stub();
    92      const p = new BatchProcessor(
    93        'author1',
    94        'type1',
    95        processBatchCallback,
    96        processorCompleteCallback,
    97      );
    98  
    99      let batch: IDBBatch = {
   100        author: 'author1',
   101        type: 'type1',
   102        batchID: 'batch1',
   103        completed: null,
   104        created: Date.now(),
   105        records: [],
   106      };
   107      for (let i = 0; i < p.config.batchMaxRecords-1; i++) {
   108        assert(batch.records.push({id: `test_${i}`, recordType: BatchRecordType.assetInstance }));
   109      }
   110      await p.init([batch]);
   111  
   112      assert.strictEqual(processBatchCallback.callCount, 1);
   113      assert.strictEqual(processorCompleteCallback.callCount, 1);
   114      batch = processBatchCallback.getCall(0).args[0];
   115      for (let i = 0; i < p.config.batchMaxRecords-1; i++) {
   116        assert(batch.records.find(r => r.id === `test_${i}`));
   117      }
   118  
   119    });
   120  
   121    it('times out a batch with arrival, then cleans up once it dispatches', async () => {
   122  
   123      const processBatchCallback = sinon.stub();
   124      const processorCompleteCallback = sinon.stub();
   125      const p = new BatchProcessor(
   126        'author1',
   127        'type1',
   128        processBatchCallback,
   129        processorCompleteCallback,
   130        {
   131          batchTimeoutArrivallMS: 10,
   132        }
   133      );
   134  
   135      const scheduleRandomDelayedAdd = async (i: number) => {
   136        // Introduce some randomness, but with very short delays to keep the test fast
   137        await delay(Math.ceil(Math.random() * 5));
   138        await p.add({
   139          recordType: BatchRecordType.assetInstance,
   140          id: `test_${i}`,
   141        });
   142      }
   143  
   144      const before = Date.now();
   145      const promises: Promise<void>[] = [];
   146      for (let i = 0; i < (p.config.batchMaxRecords - 1); i++) {
   147        promises.push(scheduleRandomDelayedAdd(i));
   148      }
   149      await Promise.all(promises);
   150  
   151      // Should not be set yet - wait for timeout
   152      assert.strictEqual(processBatchCallback.callCount, 0);
   153      assert.strictEqual(processorCompleteCallback.callCount, 0);
   154  
   155      for (let i = 0; i < 100; i++) {
   156        if (processBatchCallback.callCount === 0) await delay(1);
   157      }
   158      const after = Date.now();
   159  
   160      assert(after - before >= 10 /* we must have waited this long */)
   161      assert.strictEqual(processBatchCallback.callCount, 1);
   162      assert.strictEqual(processorCompleteCallback.callCount, 1);
   163  
   164      const batch: IDBBatch = processBatchCallback.getCall(0).args[0];
   165      for (let i = 0; i < (p.config.batchMaxRecords - 1); i++) {
   166        assert(batch.records.find(r => r.id === `test_${i}`));
   167      }
   168  
   169    });
   170  
   171    it('times out a batch with an overall timeout, then continues to add to the next batch', async () => {
   172  
   173      let totalReceived = 0;
   174      let batchCount = 0;
   175      const processorCompleteCallback = sinon.stub();
   176      const p = new BatchProcessor(
   177        'author1',
   178        'type1',
   179        async b => {totalReceived += b.records.length; batchCount++},
   180        processorCompleteCallback,
   181        {
   182          batchTimeoutArrivallMS: 10,
   183          batchTimeoutOverallMS: 20,
   184        }
   185      );
   186  
   187      for (let i = 0; i < 50; i++) {
   188        await delay(1);
   189        await p.add({
   190          recordType: BatchRecordType.assetInstance,
   191          id: `test_${i}`,
   192        });
   193      }
   194  
   195      while (totalReceived < 50) await delay(5);
   196  
   197      assert(batchCount > 1);
   198      assert(processorCompleteCallback.callCount >= 1);
   199  
   200    });
   201  
   202    it('fills a batch with a slow persistence to the DB', async () => {
   203  
   204      const processBatchCallback = sinon.stub();
   205      const processorCompleteCallback = sinon.stub();
   206      const p = new BatchProcessor(
   207        'author1',
   208        'type1',
   209        processBatchCallback,
   210        processorCompleteCallback,
   211        {
   212          batchMaxRecords: 10,
   213        }
   214      );
   215  
   216      // Make the persistence slow
   217      const dbUpdateStub = (database.upsertBatch as SinonStub);
   218      dbUpdateStub.callsFake(() => delay(10))
   219  
   220      // Make the adding fast
   221      const addImmediate = async (i: number) => {
   222        await p.add({
   223          recordType: BatchRecordType.assetInstance,
   224          id: `test_${i}`,
   225        });
   226      }
   227      const promises: Promise<void>[] = [];
   228      for (let i = 0; i < p.config.batchMaxRecords; i++) {
   229        promises.push(addImmediate(i));
   230      }
   231      await Promise.all(promises);
   232  
   233      for (let i = 0; i < 100; i++) {
   234        if (processorCompleteCallback.callCount === 0) await delay(1);
   235      }
   236  
   237      // We should have exactly three calls
   238      // - once for the first as the batch started
   239      // - once with everything else in the batch
   240      // - once when we completed the batch
   241      assert.strictEqual(dbUpdateStub.callCount, 3);
   242  
   243      assert.strictEqual(processBatchCallback.callCount, 1);
   244      assert.strictEqual(processorCompleteCallback.callCount, 1);
   245      const batch: IDBBatch = processBatchCallback.getCall(0).args[0];
   246      for (let i = 0; i < p.config.batchMaxRecords; i++) {
   247        assert(batch.records.find(r => r.id === `test_${i}`));
   248      }
   249  
   250    });
   251  
   252    it('handles a failure to persist the batch to the DB', async () => {
   253  
   254      const processBatchCallback = sinon.stub();
   255      const processorCompleteCallback = sinon.stub();
   256      const p = new BatchProcessor(
   257        'author1',
   258        'type1',
   259        processBatchCallback,
   260        processorCompleteCallback,
   261      );
   262  
   263      // Make the persistence fail
   264      (database.upsertBatch as SinonStub).rejects(new Error('pop'));
   265  
   266      let failed;
   267      try {
   268        await p.add({
   269          recordType: BatchRecordType.assetInstance,
   270          id: `test`
   271        });
   272      }
   273      catch(err) {
   274        failed = true;
   275        assert.strictEqual(err.message, 'pop');
   276      }
   277      assert(failed);
   278  
   279    });
   280  
   281    it('times out a requests that are queued too long, when there is a batch in flight, and a batch queued', async () => {
   282  
   283      const processBatchCallback = sinon.stub().callsFake(() => delay(10));
   284      const processorCompleteCallback = sinon.stub();
   285      const p = new BatchProcessor(
   286        'author1',
   287        'type1',
   288        processBatchCallback,
   289        processorCompleteCallback,
   290        {
   291          batchMaxRecords: 1, // to trigger a batch immeidately
   292          addTimeoutMS: 5,
   293        }
   294      );
   295  
   296      await p.add({ id: `test-batch1-dispatched`, recordType: BatchRecordType.assetInstance  });
   297  
   298      // Make the persistence fail
   299      (database.upsertBatch as SinonStub).onSecondCall().callsFake(() => delay(10));
   300  
   301      let failed;
   302      try {
   303        await Promise.all([
   304          p.add({ id: `test-batch2-blocked`, recordType: BatchRecordType.assetInstance }),
   305          p.add({ id: `test-batch3-timeout`, recordType: BatchRecordType.assetInstance  }),
   306        ]);
   307      }
   308      catch(err) {
   309        failed = true;
   310        assert(err.message.includes('Timed out add of record after'));
   311      }
   312      assert(failed);
   313  
   314      // Clear everything out
   315      for (let i = 0; i < 100; i++) {
   316        if (processorCompleteCallback.callCount === 0) await delay(1);
   317      }
   318      // Confirm two batches went through
   319      assert.strictEqual(processBatchCallback.callCount, 2);
   320  
   321    });
   322  
   323    describe('with test wrapper', () => {
   324  
   325      class TestBatchProcessorWrapper extends BatchProcessor {
   326        public dispatchBatch() {
   327          return super.dispatchBatch();
   328        }
   329        public processBatch(batch: IDBBatch) {
   330          return super.processBatch(batch);
   331        }
   332        public newBatch(): IDBBatch {
   333          return super.newBatch();
   334        }
   335      }
   336  
   337      it('protects dispatchBatch against duplicate calls', async () => {
   338        const p = new TestBatchProcessorWrapper(
   339          'author1',
   340          'type1',
   341          sinon.stub(),
   342          sinon.stub(),
   343        );
   344        // p.assemblyBatch is not set, so this is a no-op and can be called many times
   345        await p.dispatchBatch();
   346        await p.dispatchBatch();
   347      });
   348  
   349      it('retries in processBatch, with backoff', async () => {
   350        const p = new TestBatchProcessorWrapper(
   351          'author1',
   352          'type1',
   353          sinon.stub()
   354            .onFirstCall().rejects(new Error('try me again'))
   355            .onSecondCall().rejects(new Error('and one more time with feeling')),
   356          sinon.stub(),
   357          {
   358            retryInitialDelayMS: 1,
   359          }
   360        );
   361        // p.assemblyBatch is not set, so this is a no-op and can be called many times
   362        await p.processBatch(p.newBatch());
   363      });
   364  
   365    });
   366  
   367  });
   368  };