pixl-server-storage 4.1.1 → 4.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/engines/Hybrid.js CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  var Class = require("pixl-class");
6
6
  var Component = require("pixl-server/component");
7
- var Tools = require("pixl-tools");
7
+ var Perf = require("pixl-perf");
8
8
  var async = require('async');
9
9
 
10
10
  module.exports = Class.create({
@@ -20,7 +20,8 @@ module.exports = Class.create({
20
20
  startup: function(callback) {
21
21
  // setup multiple sub-engines
22
22
  this.logDebug(2, "Setting up hybrid engine", this.config.get() );
23
-
23
+ var self = this;
24
+
24
25
  var binaryClass = require( './' + this.config.get('binaryEngine') + '.js' );
25
26
  this.binaryEngine = new binaryClass();
26
27
  this.binaryEngine.storage = this.storage;
@@ -31,6 +32,17 @@ module.exports = Class.create({
31
32
  this.docEngine.storage = this.storage;
32
33
  this.docEngine.init( this.server, this.storage.config.getSub( this.config.get('docEngine') ) );
33
34
 
35
+ // Native transactions only apply to document records. Binary keys are
36
+ // already passed straight through by the outer transaction wrapper, so
37
+ // only expose this hook if the doc engine can atomically commit JSON.
38
+ delete this.commitTransaction;
39
+ if (this.docEngine.commitTransaction) {
40
+ this.logDebug(3, "Hybrid engine will use native doc engine transactions");
41
+ this.commitTransaction = function(trans, callback) {
42
+ self.docEngine.commitTransaction( trans, callback );
43
+ };
44
+ }
45
+
34
46
  async.series([
35
47
  this.binaryEngine.startup.bind(this.binaryEngine),
36
48
  this.docEngine.startup.bind(this.docEngine)
@@ -115,6 +127,96 @@ module.exports = Class.create({
115
127
  ], callback );
116
128
  },
117
129
 
130
+ optimize: function(callback) {
131
+ // run optimization on child engines that support it
132
+ var self = this;
133
+ var perf = new Perf();
134
+ var results = [];
135
+
136
+ this.logDebug(3, "Running hybrid optimization");
137
+ perf.setScale(1);
138
+ perf.begin();
139
+
140
+ var optimizeEngine = function(label, engine, callback) {
141
+ var tracker = perf.begin(label);
142
+
143
+ if (!engine || !engine.optimize) {
144
+ var child_perf = new Perf();
145
+ child_perf.setScale(1);
146
+ child_perf.begin();
147
+ child_perf.begin('noop');
148
+ child_perf.end('noop');
149
+ child_perf.end();
150
+ tracker.end();
151
+
152
+ results.push({
153
+ role: label,
154
+ engine: engine ? engine.__name : '',
155
+ optimized: false,
156
+ perf: child_perf.metrics(),
157
+ operations: [
158
+ {
159
+ name: 'noop',
160
+ ok: true,
161
+ message: "No optimization is available for this engine."
162
+ }
163
+ ]
164
+ });
165
+ return process.nextTick(callback);
166
+ }
167
+
168
+ engine.optimize( function(err, report) {
169
+ tracker.end();
170
+ if (err) return callback(err);
171
+ if (!report) report = {};
172
+ report.role = label;
173
+ if (!report.engine) report.engine = engine.__name;
174
+ results.push(report);
175
+ callback();
176
+ } );
177
+ };
178
+
179
+ async.series([
180
+ function(callback) {
181
+ optimizeEngine( 'binary', self.binaryEngine, callback );
182
+ },
183
+ function(callback) {
184
+ optimizeEngine( 'doc', self.docEngine, callback );
185
+ }
186
+ ], function(err) {
187
+ if (err) return callback(err);
188
+ perf.end();
189
+
190
+ var report = {
191
+ engine: self.__name,
192
+ optimized: results.some( function(item) { return !!item.optimized; } ),
193
+ perf: perf.metrics(),
194
+ engines: results
195
+ };
196
+
197
+ report.operations = results.reduce( function(list, item) {
198
+ return list.concat( item.operations || [] );
199
+ }, [] );
200
+
201
+ callback(null, report);
202
+ } );
203
+ },
204
+
205
+ unitTestCleanup: function(callback) {
206
+ // cleanup all child engines that provide a unit test cleanup hook
207
+ var tasks = [];
208
+
209
+ if (this.binaryEngine && this.binaryEngine.unitTestCleanup) {
210
+ tasks.push( this.binaryEngine.unitTestCleanup.bind(this.binaryEngine) );
211
+ }
212
+ if (this.docEngine && this.docEngine.unitTestCleanup) {
213
+ tasks.push( this.docEngine.unitTestCleanup.bind(this.docEngine) );
214
+ }
215
+
216
+ if (!tasks.length) return process.nextTick(callback);
217
+ async.series( tasks, callback );
218
+ },
219
+
118
220
  shutdown: function(callback) {
119
221
  // shutdown storage
120
222
  this.logDebug(2, "Shutting down hybrid system");
@@ -53,7 +53,9 @@ module.exports = Class.create({
53
53
  get: `SELECT modified, value FROM ${pg_config.table} WHERE key = $key LIMIT 1`,
54
54
  head: `SELECT modified, octet_length(value) AS len FROM ${pg_config.table} WHERE key = $key LIMIT 1`,
55
55
  put: `INSERT INTO ${pg_config.table} (key, value, modified) VALUES ($key, $value, $now) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, modified = EXCLUDED.modified`,
56
- delete: `DELETE FROM ${pg_config.table} WHERE key = $key`
56
+ delete: `DELETE FROM ${pg_config.table} WHERE key = $key`,
57
+ transPut: `INSERT INTO ${pg_config.table} (key, value, modified) SELECT * FROM unnest($keys::text[], $values::bytea[], $modified::bigint[]) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, modified = EXCLUDED.modified`,
58
+ transDelete: `DELETE FROM ${pg_config.table} WHERE key = ANY($keys::text[])`
57
59
  };
58
60
 
59
61
  // optional LRU cache
@@ -98,9 +100,15 @@ module.exports = Class.create({
98
100
  },
99
101
 
100
102
  query(sql, args, callback) {
103
+ // run query against the pool
104
+ this.queryWithClient( this.db, sql, args, callback );
105
+ },
106
+
107
+ queryWithClient: function(client, sql, args, callback) {
101
108
  // run and log db query, and error if applicable
102
109
  // allow for named variables in sql, expand to numbered $ variables for pg
103
110
  var self = this;
111
+ if (!args) args = {};
104
112
 
105
113
  // perform $ placeholder substitution to populate sargs array
106
114
  var sargs = [];
@@ -111,7 +119,7 @@ module.exports = Class.create({
111
119
  return value;
112
120
  } );
113
121
 
114
- this.db.query( sql, sargs, function(err, res) {
122
+ client.query( sql, sargs, function(err, res) {
115
123
  if (err) {
116
124
  self.logError('db', "Failed to execute SQL: " + err, { sql, err });
117
125
  return callback(err);
@@ -123,7 +131,7 @@ module.exports = Class.create({
123
131
  });
124
132
 
125
133
  callback(null, res);
126
- } ); // db.query
134
+ } ); // client.query
127
135
  },
128
136
 
129
137
  prepKey: function(key) {
@@ -462,11 +470,231 @@ module.exports = Class.create({
462
470
  } ); // query
463
471
  },
464
472
 
473
+ commitTransaction: function(trans, callback) {
474
+ // commit all transaction actions using a native Postgres transaction
475
+ // IMPORTANT: all queries must run on this one checked-out client
476
+ var self = this;
477
+ var did_begin = false;
478
+ var write_keys = [];
479
+ var write_values = [];
480
+ var write_modified = [];
481
+ var delete_keys = [];
482
+ var bad_state = null;
483
+ var query_step = '';
484
+
485
+ this.logDebug(5, "Beginning transaction commit: " + trans.id, {
486
+ path: trans.path,
487
+ actions: Tools.numKeys(trans.keys)
488
+ });
489
+
490
+ Object.keys(trans.keys).forEach( function(key) {
491
+ var record_state = trans.keys[key];
492
+ var norm_key = self.storage.normalizeKey(key);
493
+
494
+ if (record_state == 'W') {
495
+ // Collect writes into parallel arrays for one bulk UPSERT below.
496
+ var item = trans.values[key];
497
+ write_keys.push( self.prepKey(norm_key) );
498
+ write_values.push( Buffer.from( JSON.stringify( item.data ) ) );
499
+ write_modified.push( item.mod );
500
+ }
501
+ else if (record_state == 'D') {
502
+ // Collect deletes into one bulk DELETE below.
503
+ delete_keys.push( self.prepKey(norm_key) );
504
+ }
505
+ else {
506
+ bad_state = new Error("Unknown transaction record state: " + record_state + ": " + key);
507
+ }
508
+ });
509
+
510
+ if (bad_state) return callback(bad_state);
511
+
512
+ this.db.connect( function(err, client, release) {
513
+ if (err) {
514
+ self.logError('pg', "Failed to acquire Postgres client for transaction commit: " + err);
515
+ return callback(err);
516
+ }
517
+
518
+ var releaseClient = function(err) {
519
+ // release the pg client exactly once
520
+ if (!release) return;
521
+ var rel = release;
522
+ release = null;
523
+ rel(err);
524
+ };
525
+
526
+ async.series(
527
+ [
528
+ function(callback) {
529
+ // start native Postgres transaction
530
+ query_step = 'BEGIN';
531
+ self.queryWithClient( client, "BEGIN", {}, function(err) {
532
+ if (!err) did_begin = true;
533
+ callback(err);
534
+ } );
535
+ },
536
+ function(callback) {
537
+ // delete all final-deleted records in one set-based SQL statement
538
+ if (!delete_keys.length) return callback();
539
+
540
+ query_step = 'DELETE';
541
+ self.queryWithClient( client, self.commands.transDelete, {
542
+ keys: delete_keys
543
+ }, function(err, res) {
544
+ if (err) return callback(err);
545
+
546
+ if (res.rowCount < delete_keys.length) {
547
+ self.logDebug(5, "Some records were already deleted", {
548
+ requested: delete_keys.length,
549
+ deleted: res.rowCount
550
+ });
551
+ }
552
+
553
+ callback();
554
+ } );
555
+ },
556
+ function(callback) {
557
+ // upsert all final-written records in one set-based SQL statement
558
+ if (!write_keys.length) return callback();
559
+
560
+ query_step = 'UPSERT';
561
+ self.queryWithClient( client, self.commands.transPut, {
562
+ keys: write_keys,
563
+ values: write_values,
564
+ modified: write_modified
565
+ }, callback );
566
+ },
567
+ function(callback) {
568
+ // atomically reveal all transaction actions
569
+ query_step = 'COMMIT';
570
+ self.queryWithClient( client, "COMMIT", {}, callback );
571
+ }
572
+ ],
573
+ function(err) {
574
+ if (!err) {
575
+ // commit succeeded, so now it is safe to update the engine LRU cache
576
+ if (self.cache) {
577
+ write_keys.forEach( function(key, idx) {
578
+ if (!self.storage.isBinaryKey(key)) {
579
+ self.cache.set( key, write_values[idx], { date: write_modified[idx] } );
580
+ }
581
+ });
582
+
583
+ delete_keys.forEach( function(key) {
584
+ if (!self.storage.isBinaryKey(key)) {
585
+ // store 'empty' stub in cache
586
+ self.cache.set( key, Buffer.alloc(0), { date: 0 } );
587
+ }
588
+ });
589
+ }
590
+
591
+ self.logDebug(5, "Transaction commit complete: " + trans.id, {
592
+ path: trans.path
593
+ });
594
+
595
+ releaseClient();
596
+ return callback();
597
+ }
598
+
599
+ self.logError('pg', "Transaction commit failed: " + err);
600
+
601
+ // If COMMIT itself fails, the outcome may be unknowable from the app side:
602
+ // Postgres may have committed, but the client may have lost the response.
603
+ // Surface this to the outer transaction layer as a fatal consistency error.
604
+ if (query_step == 'COMMIT') {
605
+ err.fatal = true;
606
+ }
607
+
608
+ if (!did_begin) {
609
+ releaseClient(err);
610
+ return callback(err);
611
+ }
612
+
613
+ // rollback best-effort. If this fails, discard the client from the pool.
614
+ self.queryWithClient( client, "ROLLBACK", {}, function(rollback_err) {
615
+ if (rollback_err) {
616
+ self.logError('pg', "Transaction rollback failed: " + rollback_err);
617
+ err.fatal = true;
618
+ err.rollbackError = rollback_err;
619
+ releaseClient(rollback_err);
620
+ }
621
+ else {
622
+ self.logDebug(5, "Transaction rollback complete: " + trans.id);
623
+ releaseClient();
624
+ }
625
+
626
+ callback(err);
627
+ } );
628
+ }
629
+ ); // series
630
+ } ); // db.connect
631
+ },
632
+
633
+ unitTestCleanup: function(callback) {
634
+ // cleanup all unit test data, leaving the table itself intact
635
+ var self = this;
636
+ var table = this.config.get('table');
637
+
638
+ this.logDebug(3, "Cleaning up Postgres unit test table: " + table);
639
+
640
+ this.query( `TRUNCATE TABLE ${table}`, {}, function(err) {
641
+ if (err) {
642
+ self.logError('pg', "Failed to cleanup Postgres unit test table: " + err);
643
+ return callback(err);
644
+ }
645
+
646
+ if (self.cache) self.cache.clear();
647
+ callback();
648
+ } );
649
+ },
650
+
465
651
  runMaintenance: function(callback) {
466
652
  // run daily maintenance
467
653
  callback();
468
654
  },
469
655
 
656
+ optimize: function(callback) {
657
+ // run Postgres VACUUM on the storage table
658
+ var self = this;
659
+ var table = this.config.get('table');
660
+ var perf = new Perf();
661
+ var vacuum = null;
662
+
663
+ this.logDebug(3, "Running Postgres optimization", { table: table });
664
+ perf.setScale(1);
665
+ perf.begin();
666
+ vacuum = perf.begin('vacuum');
667
+
668
+ this.query( `VACUUM ${table}`, {}, function(err, res) {
669
+ vacuum.end();
670
+ perf.end();
671
+
672
+ if (err) {
673
+ err.message = "Postgres optimization failed: " + err.message;
674
+ self.logError('pg', '' + err);
675
+ return callback(err);
676
+ }
677
+
678
+ var report = {
679
+ engine: self.__name,
680
+ optimized: true,
681
+ table: table,
682
+ perf: perf.metrics(),
683
+ operations: [
684
+ {
685
+ name: 'vacuum',
686
+ ok: true,
687
+ command: res.command || 'VACUUM',
688
+ rowCount: res.rowCount || 0
689
+ }
690
+ ]
691
+ };
692
+
693
+ self.logDebug(3, "Postgres optimization complete", report);
694
+ callback(null, report);
695
+ } );
696
+ },
697
+
470
698
  shutdown: function(callback) {
471
699
  // shutdown storage
472
700
  var self = this;
package/engines/Redis.js CHANGED
@@ -9,6 +9,7 @@ const Class = require("pixl-class");
9
9
  const Component = require("pixl-server/component");
10
10
  const Redis = require('ioredis');
11
11
  const Tools = require("pixl-tools");
12
+ const Perf = require("pixl-perf");
12
13
  const Cache = require("pixl-cache");
13
14
 
14
15
  module.exports = Class.create({
@@ -421,11 +422,143 @@ module.exports = Class.create({
421
422
  } );
422
423
  },
423
424
 
425
+ commitTransaction: function(trans, callback) {
426
+ // commit all transaction actions using a native Redis MULTI/EXEC transaction
427
+ var self = this;
428
+ var write_keys = [];
429
+ var write_values = [];
430
+ var write_modified = [];
431
+ var delete_keys = [];
432
+ var bad_state = null;
433
+ var multi = this.redis.multi();
434
+
435
+ this.logDebug(5, "Beginning transaction commit: " + trans.id, {
436
+ path: trans.path,
437
+ actions: Tools.numKeys(trans.keys)
438
+ });
439
+
440
+ Object.keys(trans.keys).forEach( function(key) {
441
+ var record_state = trans.keys[key];
442
+ var norm_key = self.storage.normalizeKey(key);
443
+
444
+ if (record_state == 'W') {
445
+ // Collect writes into parallel arrays so we can update the cache later
446
+ // without stringifying and Buffer-wrapping JSON a second time.
447
+ var item = trans.values[key];
448
+ var redis_key = self.prepKey(norm_key);
449
+ var value = Buffer.from( JSON.stringify( item.data ) );
450
+
451
+ write_keys.push( redis_key );
452
+ write_values.push( value );
453
+ write_modified.push( item.mod );
454
+
455
+ multi.set( redis_key, value );
456
+ }
457
+ else if (record_state == 'D') {
458
+ // Queue deletes into the same Redis transaction.
459
+ var redis_key = self.prepKey(norm_key);
460
+ delete_keys.push( redis_key );
461
+
462
+ multi.del( redis_key );
463
+ }
464
+ else {
465
+ bad_state = new Error("Unknown transaction record state: " + record_state + ": " + key);
466
+ }
467
+ });
468
+
469
+ if (bad_state) return callback(bad_state);
470
+
471
+ multi.exec( function(err, results) {
472
+ if (err) {
473
+ // With MULTI/EXEC over the network, a top-level exec error may leave
474
+ // the actual Redis outcome unknowable, so treat it as fatal.
475
+ err.fatal = true;
476
+ self.logError('redis', "Transaction commit failed: " + err);
477
+ return callback(err);
478
+ }
479
+
480
+ // Redis can return per-command errors from EXEC while still applying
481
+ // other commands. That is a partial commit, so it must be fatal.
482
+ var cmd_err = null;
483
+ if (results) {
484
+ results.forEach( function(item) {
485
+ if (item && item[0] && !cmd_err) cmd_err = item[0];
486
+ });
487
+ }
488
+ if (cmd_err) {
489
+ cmd_err.fatal = true;
490
+ self.logError('redis', "Transaction command failed: " + cmd_err);
491
+ return callback(cmd_err);
492
+ }
493
+
494
+ // commit succeeded, so now it is safe to update the engine LRU cache
495
+ if (self.cache) {
496
+ write_keys.forEach( function(key, idx) {
497
+ if (!self.storage.isBinaryKey(key)) {
498
+ self.cache.set( key, write_values[idx], { date: write_modified[idx] } );
499
+ }
500
+ });
501
+
502
+ delete_keys.forEach( function(key) {
503
+ if (!self.storage.isBinaryKey(key)) {
504
+ // store 'empty' stub in cache
505
+ self.cache.set( key, Buffer.alloc(0), { date: 0 } );
506
+ }
507
+ });
508
+ }
509
+
510
+ self.logDebug(5, "Transaction commit complete: " + trans.id, {
511
+ path: trans.path
512
+ });
513
+
514
+ callback();
515
+ } );
516
+ },
517
+
518
+ unitTestCleanup: function(callback) {
519
+ // cleanup all unit test data
520
+ var self = this;
521
+ this.logDebug(3, "Cleaning up Redis unit test database with FLUSHDB");
522
+
523
+ return this.redis.flushdb( function(err) {
524
+ if (err) {
525
+ self.logError('redis', "Failed to cleanup Redis unit test database: " + err);
526
+ return callback(err);
527
+ }
528
+
529
+ if (self.cache) self.cache.clear();
530
+ callback();
531
+ } );
532
+ },
533
+
424
534
  runMaintenance: function(callback) {
425
535
  // run daily maintenance
426
536
  callback();
427
537
  },
428
538
 
539
+ optimize: function(callback) {
540
+ // Redis does its own memory management, so there is nothing to run here.
541
+ var perf = new Perf();
542
+ perf.setScale(1);
543
+ perf.begin();
544
+ perf.begin('noop');
545
+ perf.end('noop');
546
+ perf.end();
547
+
548
+ process.nextTick( callback, null, {
549
+ engine: this.__name,
550
+ optimized: false,
551
+ perf: perf.metrics(),
552
+ operations: [
553
+ {
554
+ name: 'noop',
555
+ ok: true,
556
+ message: "Redis does not require storage optimization."
557
+ }
558
+ ]
559
+ } );
560
+ },
561
+
429
562
  shutdown: function(callback) {
430
563
  // shutdown storage
431
564
  this.logDebug(2, "Shutting down Redis");
@@ -9,6 +9,7 @@ const Class = require("pixl-class");
9
9
  const Component = require("pixl-server/component");
10
10
  const Redis = require('ioredis');
11
11
  const Tools = require("pixl-tools");
12
+ const Perf = require("pixl-perf");
12
13
  const Cache = require("pixl-cache");
13
14
 
14
15
  module.exports = Class.create({
@@ -439,6 +440,29 @@ module.exports = Class.create({
439
440
  callback();
440
441
  },
441
442
 
443
+ optimize: function(callback) {
444
+ // Redis Cluster does its own memory management, so there is nothing to run here.
445
+ var perf = new Perf();
446
+ perf.setScale(1);
447
+ perf.begin();
448
+ perf.begin('noop');
449
+ perf.end('noop');
450
+ perf.end();
451
+
452
+ process.nextTick( callback, null, {
453
+ engine: this.__name,
454
+ optimized: false,
455
+ perf: perf.metrics(),
456
+ operations: [
457
+ {
458
+ name: 'noop',
459
+ ok: true,
460
+ message: "RedisCluster does not require storage optimization."
461
+ }
462
+ ]
463
+ } );
464
+ },
465
+
442
466
  shutdown: function(callback) {
443
467
  // shutdown storage
444
468
  this.logDebug(2, "Shutting down Redis");