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 };