pixl-server-storage 4.1.0 → 4.2.0

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) {
@@ -204,13 +212,22 @@ module.exports = Class.create({
204
212
  if (this.cache && this.cache.has(key)) {
205
213
  var item = this.cache.getMeta(key);
206
214
 
207
- process.nextTick( function() {
208
- self.logDebug(9, "Cached head complete: " + key);
209
- callback( null, {
210
- mod: item.date,
211
- len: item.value.length
215
+ if (item.value.length == 0) {
216
+ process.nextTick( function() {
217
+ var err = new Error("Failed to head key: " + key + ": Not found");
218
+ err.code = "NoSuchKey";
219
+ callback( err );
212
220
  } );
213
- } );
221
+ }
222
+ else {
223
+ process.nextTick( function() {
224
+ self.logDebug(9, "Cached head complete: " + key);
225
+ callback( null, {
226
+ mod: item.date,
227
+ len: item.value.length
228
+ } );
229
+ } );
230
+ }
214
231
  return;
215
232
  } // cache
216
233
 
@@ -227,6 +244,12 @@ module.exports = Class.create({
227
244
  if (!row) {
228
245
  var err = new Error("Failed to head key: " + key + ": Not found");
229
246
  err.code = "NoSuchKey";
247
+
248
+ if (self.cache && !self.storage.isBinaryKey(key)) {
249
+ // store 'empty' stub in cache
250
+ self.cache.set( key, Buffer.alloc(0), { date: 0 } );
251
+ }
252
+
230
253
  return callback(err, null);
231
254
  }
232
255
  callback(null, { mod: parseInt(row.modified, 10), len: row.len });
@@ -242,19 +265,29 @@ module.exports = Class.create({
242
265
 
243
266
  // check cache first
244
267
  if (this.cache && !is_binary && this.cache.has(key)) {
245
- var data = this.cache.get(key);
268
+ var item = this.cache.getMeta(key);
246
269
 
247
- process.nextTick( function() {
248
- try { data = JSON.parse( data.toString() ); }
249
- catch (e) {
250
- self.logError('pg', "Failed to parse JSON record: " + key + ": " + e);
251
- callback( e, null );
252
- return;
253
- }
254
- self.logDebug(9, "Cached JSON fetch complete: " + key, self.debugLevel(10) ? data : null);
255
-
256
- callback( null, data );
257
- } );
270
+ if (item.value.length == 0) {
271
+ process.nextTick( function() {
272
+ var err = new Error("Failed to fetch key: " + key + ": Not found");
273
+ err.code = "NoSuchKey";
274
+ callback( err );
275
+ } );
276
+ }
277
+ else {
278
+ process.nextTick( function() {
279
+ var data = null;
280
+ try { data = JSON.parse( item.value.toString() ); }
281
+ catch (e) {
282
+ self.logError('pg', "Failed to parse JSON record: " + key + ": " + e);
283
+ callback( e, null );
284
+ return;
285
+ }
286
+ self.logDebug(9, "Cached JSON fetch complete: " + key, self.debugLevel(10) ? data : null);
287
+
288
+ callback( null, data );
289
+ } );
290
+ }
258
291
  return;
259
292
  } // cache
260
293
 
@@ -273,6 +306,12 @@ module.exports = Class.create({
273
306
  if (!row) {
274
307
  var err = new Error("Failed to fetch key: " + key + ": Not found");
275
308
  err.code = "NoSuchKey";
309
+
310
+ if (self.cache && !is_binary) {
311
+ // store 'empty' stub in cache
312
+ self.cache.set( key, Buffer.alloc(0), { date: 0 } );
313
+ }
314
+
276
315
  return callback(err, null);
277
316
  }
278
317
  if (is_binary) {
@@ -412,21 +451,250 @@ module.exports = Class.create({
412
451
  if (!res.rowCount) {
413
452
  var err = new Error("Failed to delete object: " + key + ": Not found");
414
453
  err.code = "NoSuchKey";
454
+
455
+ if (self.cache && !self.storage.isBinaryKey(key)) {
456
+ // store 'empty' stub in cache
457
+ self.cache.set( key, Buffer.alloc(0), { date: 0 } );
458
+ }
459
+
415
460
  return callback(err);
416
461
  }
417
462
  self.logDebug(9, "Delete complete: " + key);
418
- if (self.cache && self.cache.has(key)) {
419
- self.cache.delete(key);
463
+
464
+ // possibly "delete" from LRU cache as well
465
+ if (self.cache && !self.storage.isBinaryKey(key)) {
466
+ // store 'empty' stub in cache
467
+ self.cache.set( key, Buffer.alloc(0), { date: 0 } );
420
468
  }
421
469
  callback();
422
470
  } ); // query
423
471
  },
424
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
+
425
651
  runMaintenance: function(callback) {
426
652
  // run daily maintenance
427
653
  callback();
428
654
  },
429
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
+
430
698
  shutdown: function(callback) {
431
699
  // shutdown storage
432
700
  var self = this;