pixl-server-storage 3.0.23 → 3.1.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/README.md CHANGED
@@ -252,6 +252,12 @@ The `expiration_updates` property activates additional features in the [expirati
252
252
 
253
253
  Note that this feature incurs additional overhead, because the expiration date of every record needs to be stored in a global [Hash](docs/Hashes.md). This slows down both the expiration set operation, and the nightly maintenance sweep to delete expired records. For this reason, the `expiration_dates` property defaults to `false` (disabled).
254
254
 
255
+ ## lower_case_keys
256
+
257
+ The `lower_case_keys` property causes all storage keys to be internally lower-cased, effectively making all storage paths case-insensitive. This is the default (`true`). If you set this property to `false`, then all storage keys retain their natural casing, effectively making them case-sensitive.
258
+
259
+ Please note that if you use the [Local Filesystem](#local-filesystem) engine, then the filesystem itself may be case-insensitive (e.g. legacy macOS HFS).
260
+
255
261
  ## debug (standalone)
256
262
 
257
263
  The `debug` property is only used when using [Standalone Mode](#standalone-mode). Setting this to `true` will cause the engine to emit debugging messages to the console.
@@ -409,27 +415,18 @@ Note that binary records are **not** cached. This system is for JSON records on
409
415
 
410
416
  ## Amazon S3
411
417
 
412
- If you want to use [Amazon S3](http://aws.amazon.com/s3/) as a backing store, here is how to do so. First, you need to manually install the [aws-sdk](https://www.npmjs.com/package/aws-sdk) module into your app:
413
-
414
- ```
415
- npm install --save aws-sdk
416
- ```
417
-
418
- Then configure your storage thusly:
418
+ If you want to use [Amazon S3](http://aws.amazon.com/s3/) as a backing store, configure your storage thusly:
419
419
 
420
420
  ```javascript
421
421
  {
422
422
  "engine": "S3",
423
423
  "AWS": {
424
- "accessKeyId": "YOUR_AMAZON_ACCESS_KEY",
425
- "secretAccessKey": "YOUR_AMAZON_SECRET_KEY",
426
424
  "region": "us-west-1",
427
- "correctClockSkew": true,
428
- "maxRetries": 5,
429
- "httpOptions": {
430
- "connectTimeout": 5000,
431
- "timeout": 5000
432
- }
425
+ "credentials": {
426
+ "accessKeyId": "YOUR_AMAZON_ACCESS_KEY",
427
+ "secretAccessKey": "YOUR_AMAZON_SECRET_KEY",
428
+ },
429
+ "maxAttempts": 5
433
430
  },
434
431
  "S3": {
435
432
  "keyPrefix": "",
@@ -448,10 +445,6 @@ Then configure your storage thusly:
448
445
 
449
446
  Replace `YOUR_AMAZON_ACCESS_KEY` and `YOUR_AMAZON_SECRET_KEY` with your Amazon Access Key and Secret Key, respectively. These can be generated on the Security Credentials page. Replace `MY_S3_BUCKET_ID` with the ID if your own S3 bucket. Make sure you match up the region too.
450
447
 
451
- The `AWS` object is passed directly to the `config.update()` function from the [aws-sdk](https://www.npmjs.com/package/aws-sdk) module, so you can also include any properties supported there. See the [docs](http://docs.aws.amazon.com/AWSJavaScriptSDK/guide/node-configuring.html) for more details.
452
-
453
- The `S3` object is passed directly to the S3 class constructor, so check the [docs](http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#constructor-property) to see what other properties are supported. There are a few special `S3` properties used by pixl-server-storage, which are described below.
454
-
455
448
  If you plan on using Amazon AWS in other parts of your application, you can actually move the `AWS` config object into your outer server configuration. The storage module will look for it there.
456
449
 
457
450
  ### S3 File Extensions
@@ -495,8 +488,6 @@ Besides hash marks, the special macro `[key]` will be substituted with the full
495
488
 
496
489
  It is *highly* recommended that you enable caching for S3, which keeps a copy of the most recently used JSON records in RAM. Not only will this increase overall performance, but it is especially important if you use any of the advanced storage features like [Lists](https://github.com/jhuckaby/pixl-server-storage/blob/master/docs/Lists.md), [Hashes](https://github.com/jhuckaby/pixl-server-storage/blob/master/docs/Hashes.md), [Transactions](https://github.com/jhuckaby/pixl-server-storage/blob/master/docs/Transactions.md) or the [Indexer](https://github.com/jhuckaby/pixl-server-storage/blob/master/docs/Indexer.md).
497
490
 
498
- The reason why caching is so important is because Amazon S3 is only [eventually consistent](https://docs.aws.amazon.com/AmazonS3/latest/dev/Introduction.html#ConsistencyModel) when replacing existing records, so corruption can occur when writing compound objects (i.e. a list or a hash) and then quickly reading them back in. To protect against this, all writes can be cached in RAM, so subsequent reads can often bypass S3 and just read from the RAM cache instead, ensuring that the correct (latest) data is read.
499
-
500
491
  To enable the S3 cache, include a `cache` object in your `S3` configuration with the following properties:
501
492
 
502
493
  ```js
package/engines/S3.js CHANGED
@@ -1,15 +1,13 @@
1
1
  // Amazon AWS S3 Storage Plugin
2
- // Copyright (c) 2015 - 2019 Joseph Huckaby
2
+ // Copyright (c) 2015 - 2022 Joseph Huckaby
3
3
  // Released under the MIT License
4
4
 
5
- // Requires the 'aws-sdk' module from npm
6
- // npm install aws-sdk
7
-
8
5
  var Class = require("pixl-class");
9
6
  var Component = require("pixl-server/component");
10
7
  var Tools = require("pixl-tools");
11
- var AWS = require('aws-sdk');
12
8
  var Cache = require("pixl-cache");
9
+ var S3 = require("@aws-sdk/client-s3");
10
+ var streamToBuffer = require("fast-stream-to-buffer");
13
11
 
14
12
  module.exports = Class.create({
15
13
 
@@ -41,13 +39,6 @@ module.exports = Class.create({
41
39
  this.keyTemplate = (s3_config.keyTemplate || '').replace(/^\//, '').replace(/\/$/, '');
42
40
  this.fileExtensions = !!s3_config.fileExtensions;
43
41
 
44
- if (this.debugLevel(10)) {
45
- // S3 has a logger API but it's extremely verbose -- restrict to level 10 only
46
- s3_config.logger = {
47
- log: function(msg) { self.logDebug(10, "S3 Debug: " + msg); }
48
- };
49
- }
50
-
51
42
  // optional LRU cache
52
43
  this.cache = null;
53
44
  var cache_opts = s3_config.cache;
@@ -63,10 +54,36 @@ module.exports = Class.create({
63
54
  });
64
55
  });
65
56
  }
66
- delete s3_config.cache;
67
57
 
68
- AWS.config.update( aws_config );
69
- this.s3 = new AWS.S3( Tools.copyHashRemoveKeys(s3_config, { keyPrefix:1, keyTemplate:1, fileExtensions:1, cache:1 }) );
58
+ // merge AWS and S3 configs
59
+ var combo_config = Tools.mergeHashes( aws_config, s3_config );
60
+
61
+ // convert v2 config to v3
62
+ if (!combo_config.maxAttempts && combo_config.maxRetries) {
63
+ combo_config.maxAttempts = combo_config.maxRetries;
64
+ delete combo_config.maxRetries;
65
+ }
66
+ if (combo_config.accessKeyId) {
67
+ if (!combo_config.credentials) combo_config.credentials = {};
68
+ combo_config.credentials.accessKeyId = combo_config.accessKeyId;
69
+ delete combo_config.accessKeyId;
70
+ }
71
+ if (combo_config.secretAccessKey) {
72
+ if (!combo_config.credentials) combo_config.credentials = {};
73
+ combo_config.credentials.secretAccessKey = combo_config.secretAccessKey;
74
+ delete combo_config.secretAccessKey;
75
+ }
76
+ delete combo_config.correctClockSkew;
77
+ delete combo_config.httpOptions;
78
+ delete combo_config.keyPrefix;
79
+ delete combo_config.keyTemplate;
80
+ delete combo_config.fileExtensions;
81
+ delete combo_config.cache;
82
+
83
+ this.s3Params = combo_config.params || {};
84
+ delete combo_config.params;
85
+
86
+ this.s3 = new S3.S3Client(combo_config);
70
87
  },
71
88
 
72
89
  prepKey: function(key) {
@@ -107,7 +124,7 @@ module.exports = Class.create({
107
124
  var is_binary = this.storage.isBinaryKey(key);
108
125
  key = this.prepKey(key);
109
126
 
110
- var params = {};
127
+ var params = Tools.copyHash( this.s3Params );
111
128
  params.Key = this.extKey(key, orig_key);
112
129
  params.Body = value;
113
130
 
@@ -121,23 +138,21 @@ module.exports = Class.create({
121
138
  params.ContentType = 'application/json';
122
139
  }
123
140
 
124
- // double-callback protection (bug in aws-sdk)
125
- var done = false;
126
- this.s3.putObject( params, function(err, data) {
127
- if (done) return; else done = true;
128
-
129
- if (err) {
141
+ this.s3.send( new S3.PutObjectCommand(params) )
142
+ .then( function(data) {
143
+ self.logDebug(9, "Store complete: " + key);
144
+
145
+ // possibly cache in LRU
146
+ if (self.cache && !is_binary) {
147
+ self.cache.set( orig_key, params.Body, { date: Tools.timeNow(true) } );
148
+ }
149
+
150
+ if (callback) callback(null, data);
151
+ } )
152
+ .catch( function(err) {
130
153
  self.logError('s3', "Failed to store object: " + key + ": " + (err.message || err), err);
131
- }
132
- else self.logDebug(9, "Store complete: " + key);
133
-
134
- // possibly cache in LRU
135
- if (self.cache && !is_binary) {
136
- self.cache.set( orig_key, params.Body, { date: Tools.timeNow(true) } );
137
- }
138
-
139
- if (callback) callback(err, data);
140
- } );
154
+ if (callback) callback(err);
155
+ } );
141
156
  },
142
157
 
143
158
  putStream: function(key, inp, callback) {
@@ -146,24 +161,21 @@ module.exports = Class.create({
146
161
  var orig_key = key;
147
162
  key = this.prepKey(key);
148
163
 
149
- var params = {};
164
+ var params = Tools.copyHash( this.s3Params );
150
165
  params.Key = this.extKey(key, orig_key);
151
166
  params.Body = inp;
152
167
 
153
168
  this.logDebug(9, "Storing S3 Binary Stream: " + key);
154
169
 
155
- // double-callback protection (bug in aws-sdk)
156
- var done = false;
157
- this.s3.upload(params, function(err, data) {
158
- if (done) return; else done = true;
159
-
160
- if (err) {
170
+ this.s3.send( new S3.PutObjectCommand(params) )
171
+ .then( function(data) {
172
+ self.logDebug(9, "Stream store complete: " + key);
173
+ if (callback) callback(null, data);
174
+ } )
175
+ .catch( function(err) {
161
176
  self.logError('s3', "Failed to store stream: " + key + ": " + (err.message || err), err);
162
- }
163
- else self.logDebug(9, "Stream store complete: " + key);
164
-
165
- if (callback) callback(err, data);
166
- } );
177
+ if (callback) callback(err);
178
+ } );
167
179
  },
168
180
 
169
181
  putStreamCustom: function(key, inp, opts, callback) {
@@ -172,25 +184,22 @@ module.exports = Class.create({
172
184
  var orig_key = key;
173
185
  key = this.prepKey(key);
174
186
 
175
- var params = {};
187
+ var params = Tools.copyHash( this.s3Params );
176
188
  params.Key = this.extKey(key, orig_key);
177
189
  params.Body = inp;
178
190
  if (opts) Tools.mergeHashInto(params, opts);
179
191
 
180
192
  this.logDebug(9, "Storing S3 Binary Stream: " + key);
181
193
 
182
- // double-callback protection (bug in aws-sdk)
183
- var done = false;
184
- this.s3.upload(params, function(err, data) {
185
- if (done) return; else done = true;
186
-
187
- if (err) {
194
+ this.s3.send( new S3.PutObjectCommand(params) )
195
+ .then( function(data) {
196
+ self.logDebug(9, "Stream store complete: " + key);
197
+ if (callback) callback(null, data);
198
+ } )
199
+ .catch( function(err) {
188
200
  self.logError('s3', "Failed to store stream: " + key + ": " + (err.message || err), err);
189
- }
190
- else self.logDebug(9, "Stream store complete: " + key);
191
-
192
- if (callback) callback(err, data);
193
- } );
201
+ if (callback) callback(err);
202
+ } );
194
203
  },
195
204
 
196
205
  head: function(key, callback) {
@@ -214,13 +223,20 @@ module.exports = Class.create({
214
223
  return;
215
224
  } // cache
216
225
 
217
- // double-callback protection (bug in aws-sdk)
218
- var done = false;
219
- this.s3.headObject( { Key: this.extKey(key, orig_key) }, function(err, data) {
220
- if (done) return; else done = true;
221
-
222
- if (err) {
223
- if ((err.code == 'NoSuchKey') || (err.code == 'NotFound')) {
226
+ var params = Tools.copyHash( this.s3Params );
227
+ params.Key = this.extKey(key, orig_key);
228
+
229
+ this.s3.send( new S3.HeadObjectCommand(params) )
230
+ .then( function(data) {
231
+ self.logDebug(9, "Head complete: " + key);
232
+
233
+ callback( null, {
234
+ mod: Math.floor( (new Date(data.LastModified)).getTime() / 1000 ),
235
+ len: data.ContentLength
236
+ } );
237
+ } )
238
+ .catch( function(err) {
239
+ if ((err.name == 'NoSuchKey') || (err.name == 'NotFound') || (err.code == 'NoSuchKey') || (err.code == 'NotFound')) {
224
240
  // key not found, special case, don't log an error
225
241
  // always include "Not found" in error message
226
242
  err = new Error("Failed to head key: " + key + ": Not found");
@@ -230,16 +246,8 @@ module.exports = Class.create({
230
246
  // some other error
231
247
  self.logError('s3', "Failed to head key: " + key + ": " + (err.message || err), err);
232
248
  }
233
- callback( err, null );
234
- return;
235
- }
236
-
237
- self.logDebug(9, "Head complete: " + key);
238
- callback( null, {
239
- mod: Math.floor((new Date(data.LastModified)).getTime() / 1000),
240
- len: data.ContentLength
249
+ callback( err );
241
250
  } );
242
- } );
243
251
  },
244
252
 
245
253
  get: function(key, callback) {
@@ -269,13 +277,46 @@ module.exports = Class.create({
269
277
  return;
270
278
  } // cache
271
279
 
272
- // double-callback protection (bug in aws-sdk)
273
- var done = false;
274
- this.s3.getObject( { Key: this.extKey(key, orig_key) }, function(err, data) {
275
- if (done) return; else done = true;
276
-
277
- if (err) {
278
- if ((err.code == 'NoSuchKey') || (err.code == 'NotFound')) {
280
+ var params = Tools.copyHash( this.s3Params );
281
+ params.Key = this.extKey(key, orig_key);
282
+
283
+ this.s3.send( new S3.GetObjectCommand(params) )
284
+ .then( function(data) {
285
+ // stream to buffer
286
+ streamToBuffer( data.Body, function (err, body) {
287
+ if (err) {
288
+ self.logError('s3', "Failed to fetch key: " + key + ": " + (err.message || err), err);
289
+ return callback(err);
290
+ }
291
+
292
+ if (is_binary) {
293
+ self.logDebug(9, "Binary fetch complete: " + key, '' + body.length + ' bytes');
294
+ }
295
+ else {
296
+ body = body.toString();
297
+
298
+ // possibly cache in LRU
299
+ if (self.cache) {
300
+ self.cache.set( orig_key, body, { date: Tools.timeNow(true) } );
301
+ }
302
+
303
+ try { body = JSON.parse( body ); }
304
+ catch (e) {
305
+ self.logError('s3', "Failed to parse JSON record: " + key + ": " + e);
306
+ callback( e, null );
307
+ return;
308
+ }
309
+ self.logDebug(9, "JSON fetch complete: " + key, self.debugLevel(10) ? body : null);
310
+ }
311
+
312
+ callback( null, body, {
313
+ mod: Math.floor((new Date(data.LastModified)).getTime() / 1000),
314
+ len: data.ContentLength
315
+ } );
316
+ } ); // streamToBuffer
317
+ } )
318
+ .catch( function(err) {
319
+ if ((err.name == 'NoSuchKey') || (err.name == 'NotFound') || (err.code == 'NoSuchKey') || (err.code == 'NotFound')) {
279
320
  // key not found, special case, don't log an error
280
321
  // always include "Not found" in error message
281
322
  err = new Error("Failed to fetch key: " + key + ": Not found");
@@ -285,37 +326,8 @@ module.exports = Class.create({
285
326
  // some other error
286
327
  self.logError('s3', "Failed to fetch key: " + key + ": " + (err.message || err), err);
287
328
  }
288
- callback( err, null );
289
- return;
290
- }
291
-
292
- var body = null;
293
- if (is_binary) {
294
- body = data.Body;
295
- self.logDebug(9, "Binary fetch complete: " + key, '' + body.length + ' bytes');
296
- }
297
- else {
298
- body = data.Body.toString();
299
-
300
- // possibly cache in LRU
301
- if (self.cache) {
302
- self.cache.set( orig_key, body, { date: Tools.timeNow(true) } );
303
- }
304
-
305
- try { body = JSON.parse( body ); }
306
- catch (e) {
307
- self.logError('s3', "Failed to parse JSON record: " + key + ": " + e);
308
- callback( e, null );
309
- return;
310
- }
311
- self.logDebug(9, "JSON fetch complete: " + key, self.debugLevel(10) ? body : null);
312
- }
313
-
314
- callback( null, body, {
315
- mod: Math.floor((new Date(data.LastModified)).getTime() / 1000),
316
- len: data.ContentLength
329
+ callback( err );
317
330
  } );
318
- } );
319
331
  },
320
332
 
321
333
  getStream: function(key, callback) {
@@ -326,27 +338,30 @@ module.exports = Class.create({
326
338
 
327
339
  this.logDebug(9, "Fetching S3 Stream: " + key);
328
340
 
329
- var params = { Key: this.extKey(key, orig_key) };
330
- var download = self.s3.getObject(params).createReadStream();
331
- var proceed = false;
332
-
333
- download.on('error', function(err) {
334
- if (proceed) self.logError('s3', "Failed to download key: " + key + ": " + (err.message || err), err);
335
- });
336
- download.once('end', function() {
337
- self.logDebug(9, "S3 stream download complete: " + key);
338
- } );
339
- download.once('close', function() {
340
- self.logDebug(9, "S3 stream download closed: " + key);
341
- } );
342
-
343
- // double-callback protection (bug in aws-sdk)
344
- var done = false;
345
- this.s3.headObject( params, function(err, data) {
346
- if (done) return; else done = true;
347
-
348
- if (err) {
349
- if ((err.code == 'NoSuchKey') || (err.code == 'NotFound')) {
341
+ var params = Tools.copyHash( this.s3Params );
342
+ params.Key = this.extKey(key, orig_key);
343
+
344
+ this.s3.send( new S3.GetObjectCommand(params) )
345
+ .then( function(data) {
346
+ var download = data.Body;
347
+
348
+ download.on('error', function(err) {
349
+ self.logError('s3', "Failed to download key: " + key + ": " + (err.message || err), err);
350
+ });
351
+ download.once('end', function() {
352
+ self.logDebug(9, "S3 stream download complete: " + key);
353
+ } );
354
+ download.once('close', function() {
355
+ self.logDebug(9, "S3 stream download closed: " + key);
356
+ } );
357
+
358
+ callback( null, download, {
359
+ mod: Math.floor( (new Date(data.LastModified)).getTime() / 1000 ),
360
+ len: data.ContentLength
361
+ } );
362
+ })
363
+ .catch( function(err) {
364
+ if ((err.name == 'NoSuchKey') || (err.name == 'NotFound') || (err.code == 'NoSuchKey') || (err.code == 'NotFound')) {
350
365
  // key not found, special case, don't log an error
351
366
  // always include "Not found" in error message
352
367
  err = new Error("Failed to fetch key: " + key + ": Not found");
@@ -356,18 +371,8 @@ module.exports = Class.create({
356
371
  // some other error
357
372
  self.logError('s3', "Failed to fetch key: " + key + ": " + (err.message || err), err);
358
373
  }
359
-
360
- download.destroy();
361
- callback( err, null );
362
- return;
363
- }
364
-
365
- proceed = true;
366
- callback( null, download, {
367
- mod: Math.floor((new Date(data.LastModified)).getTime() / 1000),
368
- len: data.ContentLength
369
- } );
370
- }); // headObject
374
+ callback( err );
375
+ });
371
376
  },
372
377
 
373
378
  getStreamRange: function(key, start, end, callback) {
@@ -378,7 +383,8 @@ module.exports = Class.create({
378
383
 
379
384
  this.logDebug(9, "Fetching ranged S3 stream: " + key, { start, end });
380
385
 
381
- var params = { Key: this.extKey(key, orig_key) };
386
+ var params = Tools.copyHash( this.s3Params );
387
+ params.Key = this.extKey(key, orig_key);
382
388
 
383
389
  // convert start/end to HTTP range header string
384
390
  var range = "bytes=";
@@ -386,26 +392,29 @@ module.exports = Class.create({
386
392
  range += '-';
387
393
  if (!isNaN(end)) range += end;
388
394
 
389
- var download = self.s3.getObject( Tools.mergeHashes(params, { Range: range }) ).createReadStream();
390
- var proceed = false;
391
-
392
- download.on('error', function(err) {
393
- if (proceed) self.logError('s3', "Failed to download key: " + key + ": " + (err.message || err), err);
394
- });
395
- download.once('end', function() {
396
- self.logDebug(9, "S3 stream download complete: " + key);
397
- } );
398
- download.once('close', function() {
399
- self.logDebug(9, "S3 stream download closed: " + key);
400
- } );
401
-
402
- // double-callback protection (bug in aws-sdk)
403
- var done = false;
404
- this.s3.headObject( params, function(err, data) {
405
- if (done) return; else done = true;
406
-
407
- if (err) {
408
- if ((err.code == 'NoSuchKey') || (err.code == 'NotFound')) {
395
+ params.Range = range;
396
+
397
+ this.s3.send( new S3.GetObjectCommand(params) )
398
+ .then( function(data) {
399
+ var download = data.Body;
400
+
401
+ download.on('error', function(err) {
402
+ self.logError('s3', "Failed to download key: " + key + ": " + (err.message || err), err);
403
+ });
404
+ download.once('end', function() {
405
+ self.logDebug(9, "S3 stream download complete: " + key);
406
+ } );
407
+ download.once('close', function() {
408
+ self.logDebug(9, "S3 stream download closed: " + key);
409
+ } );
410
+
411
+ callback( null, download, {
412
+ mod: Math.floor( (new Date(data.LastModified)).getTime() / 1000 ),
413
+ len: data.ContentLength
414
+ } );
415
+ })
416
+ .catch( function(err) {
417
+ if ((err.name == 'NoSuchKey') || (err.name == 'NotFound') || (err.code == 'NoSuchKey') || (err.code == 'NotFound')) {
409
418
  // key not found, special case, don't log an error
410
419
  // always include "Not found" in error message
411
420
  err = new Error("Failed to fetch key: " + key + ": Not found");
@@ -415,32 +424,8 @@ module.exports = Class.create({
415
424
  // some other error
416
425
  self.logError('s3', "Failed to fetch key: " + key + ": " + (err.message || err), err);
417
426
  }
418
-
419
- download.destroy();
420
- callback( err, null );
421
- return;
422
- }
423
-
424
- // validate byte range, now that we have the head info
425
- if (isNaN(start) && !isNaN(end)) {
426
- start = data.ContentLength - end;
427
- end = data.ContentLength ? data.ContentLength - 1 : 0;
428
- }
429
- else if (!isNaN(start) && isNaN(end)) {
430
- end = data.ContentLength ? data.ContentLength - 1 : 0;
431
- }
432
- if (isNaN(start) || isNaN(end) || (start < 0) || (start >= data.ContentLength) || (end < start) || (end >= data.ContentLength)) {
433
- download.destroy();
434
- callback( new Error("Invalid byte range (" + start + '-' + end + ") for key: " + key + " (len: " + data.ContentLength + ")"), null );
435
- return;
436
- }
437
-
438
- proceed = true;
439
- callback( null, download, {
440
- mod: Math.floor((new Date(data.LastModified)).getTime() / 1000),
441
- len: data.ContentLength
442
- } );
443
- }); // headObject
427
+ callback( err );
428
+ });
444
429
  },
445
430
 
446
431
  delete: function(key, callback) {
@@ -451,23 +436,24 @@ module.exports = Class.create({
451
436
 
452
437
  this.logDebug(9, "Deleting S3 Object: " + key);
453
438
 
454
- // double-callback protection (bug in aws-sdk)
455
- var done = false;
456
- this.s3.deleteObject( { Key: this.extKey(key, orig_key) }, function(err, data) {
457
- if (done) return; else done = true;
458
-
459
- if (err) {
439
+ var params = Tools.copyHash( this.s3Params );
440
+ params.Key = this.extKey(key, orig_key);
441
+
442
+ this.s3.send( new S3.DeleteObjectCommand(params) )
443
+ .then( function(data) {
444
+ self.logDebug(9, "Delete complete: " + key);
445
+
446
+ // possibly delete from LRU cache as well
447
+ if (self.cache && self.cache.has(orig_key)) {
448
+ self.cache.delete(orig_key);
449
+ }
450
+
451
+ if (callback) callback(null, data);
452
+ } )
453
+ .catch( function(err) {
460
454
  self.logError('s3', "Failed to delete object: " + key + ": " + (err.message || err), err);
461
- }
462
- else self.logDebug(9, "Delete complete: " + key);
463
-
464
- // possibly delete from LRU cache as well
465
- if (self.cache && self.cache.has(orig_key)) {
466
- self.cache.delete(orig_key);
467
- }
468
-
469
- if (callback) callback(err, data);
470
- } );
455
+ if (callback) callback(err);
456
+ } );
471
457
  },
472
458
 
473
459
  runMaintenance: function(callback) {
@@ -159,7 +159,9 @@ module.exports = Class.create({
159
159
  }
160
160
 
161
161
  // load index summary for list of all populated dates
162
+ var dspf = state.perf.begin('date_summary');
162
163
  this.get( sum_path, function(err, summary) {
164
+ dspf.end();
163
165
  if (err || !summary) {
164
166
  summary = { id: query.def.id, values: {} };
165
167
  }
@@ -190,6 +192,7 @@ module.exports = Class.create({
190
192
  }
191
193
 
192
194
  // now perform OR search for all applicable words
195
+ var drpf = state.perf.begin('date_range');
193
196
  async.eachLimit( words, self.concurrency,
194
197
  function(word, callback) {
195
198
  // for each word, iterate over record ids
@@ -203,6 +206,8 @@ module.exports = Class.create({
203
206
  },
204
207
  function(err) {
205
208
  // all done, perform final merge
209
+ drpf.end();
210
+ state.perf.count('date_buckets', words.length);
206
211
  if (err) return callback(err);
207
212
  self.mergeIndex( record_ids, temp_results, state.first ? 'or' : state.mode );
208
213
  state.first = false;
@@ -59,7 +59,7 @@ module.exports = Class.create({
59
59
 
60
60
  if (value.match(/^(N?)(\d+)$/i)) {
61
61
  var neg = RegExp.$1.toUpperCase();
62
- var value = parseInt( RegExp.$2 );
62
+ var value = Math.floor( parseInt( RegExp.$2 ) * (def.multiply || 1) / (def.divide || 1) );
63
63
  value = Math.min( NUMBER_INDEX_MAX, value );
64
64
 
65
65
  var tkey = 'T' + neg + Math.floor( Math.floor(value / 1000) * 1000 );
@@ -103,7 +103,7 @@ module.exports = Class.create({
103
103
 
104
104
  // clean number up
105
105
  word = word.replace(/^N/i, '-').replace(/[^\d\-]+/g, '');
106
- word = '' + Math.min( NUMBER_INDEX_MAX, Math.max( NUMBER_INDEX_MIN, parseInt(word) ) );
106
+ word = '' + Math.min( NUMBER_INDEX_MAX, Math.max( NUMBER_INDEX_MIN, Math.floor( parseInt(word) * (query.def.multiply || 1) / (query.def.divide || 1) ) ) );
107
107
  word = word.replace(/\-/, 'N');
108
108
  query.word = word;
109
109
 
@@ -119,7 +119,9 @@ module.exports = Class.create({
119
119
  }
120
120
 
121
121
  // load index summary for list of all populated numbers
122
+ var nspf = state.perf.begin('number_summary');
122
123
  this.get( sum_path, function(err, summary) {
124
+ nspf.end();
123
125
  if (err || !summary) {
124
126
  summary = { id: query.def.id, values: {} };
125
127
  }
@@ -150,6 +152,7 @@ module.exports = Class.create({
150
152
  }
151
153
 
152
154
  // now perform OR search for all applicable words
155
+ var nrpf = state.perf.begin('number_range');
153
156
  async.eachLimit( words, self.concurrency,
154
157
  function(word, callback) {
155
158
  // for each word, iterate over record ids
@@ -163,6 +166,8 @@ module.exports = Class.create({
163
166
  },
164
167
  function(err) {
165
168
  // all done, perform final merge
169
+ nrpf.end();
170
+ state.perf.count('number_buckets', words.length);
166
171
  if (err) return callback(err);
167
172
  self.mergeIndex( record_ids, temp_results, state.first ? 'or' : state.mode );
168
173
  state.first = false;
@@ -185,7 +190,7 @@ module.exports = Class.create({
185
190
 
186
191
  // clean number up
187
192
  word = word.replace(/^N/i, '-').replace(/[^\d\-]+/g, '');
188
- word = '' + Math.min( NUMBER_INDEX_MAX, Math.max( NUMBER_INDEX_MIN, parseInt(word) ) );
193
+ word = '' + Math.min( NUMBER_INDEX_MAX, Math.max( NUMBER_INDEX_MIN, Math.floor( parseInt(word) * (query.def.multiply || 1) / (query.def.divide || 1) ) ) );
189
194
  word = word.replace(/\-/, 'N');
190
195
  query.word = word;
191
196
 
package/indexer.js CHANGED
@@ -5,6 +5,7 @@
5
5
  var async = require('async');
6
6
  var Class = require("pixl-class");
7
7
  var Tools = require("pixl-tools");
8
+ var Perf = require("pixl-perf");
8
9
  var nearley = require("nearley");
9
10
  var pxql_grammar = require("./pxql.js");
10
11
  var stemmer = require('porter-stemmer').stemmer;
@@ -1220,15 +1221,16 @@ module.exports = Class.create({
1220
1221
  var path = config.base_path;
1221
1222
  var pf = this.perf.begin('search');
1222
1223
 
1224
+ var orig_query = query;
1225
+ if (typeof(query) == 'object') query = Tools.copyHash(query, true);
1226
+
1223
1227
  this.shareLock( 'C|'+path, true, function(err, lock) {
1224
1228
  // got shared lock
1225
1229
  self._searchRecords( query, config, function(err, results, state) {
1226
1230
  // search complete
1227
- var elapsed = pf.end();
1228
-
1229
1231
  if (!err) self.logTransaction('search', path, {
1230
- query: query,
1231
- elapsed_ms: elapsed,
1232
+ query: orig_query,
1233
+ perf: state.perf ? state.perf.metrics() : {},
1232
1234
  results: (self.logEventTypes.search || self.logEventTypes.all) ? Tools.numKeys(results) : 0
1233
1235
  });
1234
1236
 
@@ -1251,9 +1253,15 @@ module.exports = Class.create({
1251
1253
  if (query == '*') {
1252
1254
  // fetch all records
1253
1255
  this.logDebug(8, "Fetching all records: " + config.base_path);
1256
+ var apf = new Perf();
1257
+ apf.begin();
1258
+ apf.begin('all');
1259
+
1254
1260
  return this.hashGetAll( config.base_path + '/_id', function(err, results) {
1255
1261
  // ignore error, just return empty hash
1256
- callback( null, results || {}, {} );
1262
+ apf.end('all');
1263
+ apf.end();
1264
+ callback( null, results || {}, { perf: apf } );
1257
1265
  } );
1258
1266
  }
1259
1267
  else if (query.match(/^\([\s\S]+\)$/)) {
@@ -1282,6 +1290,12 @@ module.exports = Class.create({
1282
1290
  state.record_ids = Object.create(null);
1283
1291
  state.first = true;
1284
1292
 
1293
+ // track detailed perf of search operations
1294
+ if (!state.perf) {
1295
+ state.perf = new Perf();
1296
+ state.perf.begin();
1297
+ }
1298
+
1285
1299
  // first, split criteria into subs (sub-queries),
1286
1300
  // stds (standard queries) and negs (negative queries)
1287
1301
  var subs = [], stds = [], negs = [];
@@ -1302,11 +1316,14 @@ module.exports = Class.create({
1302
1316
  }
1303
1317
 
1304
1318
  // stds need to be weighed and sorted by weight ascending
1319
+ var wpf = state.perf.begin('weigh');
1305
1320
  async.eachLimit( (query.mode == 'and') ? stds : [], this.concurrency,
1306
1321
  function(crit, callback) {
1307
1322
  self.weighCriterion(crit, config, callback);
1308
1323
  },
1309
1324
  function(err) {
1325
+ wpf.end();
1326
+
1310
1327
  // sort stds by weight ascending (only needed in AND mode)
1311
1328
  if (query.mode == 'and') {
1312
1329
  stds = stds.sort( function(a, b) { return a.weight - b.weight; } );
@@ -1317,9 +1334,12 @@ module.exports = Class.create({
1317
1334
  var tasks = [].concat( subs, stds, negs );
1318
1335
  async.eachSeries( tasks,
1319
1336
  function(task, callback) {
1337
+ task.perf = state.perf;
1338
+
1320
1339
  if (task.criteria) {
1321
- // sub-query
1340
+ // sub-query
1322
1341
  self._searchRecords( task, config, function(err, records) {
1342
+ state.perf.count('subs', 1);
1323
1343
  self.mergeIndex( state.record_ids, records, state.first ? 'or' : state.mode );
1324
1344
  state.first = false;
1325
1345
  callback();
@@ -1333,13 +1353,31 @@ module.exports = Class.create({
1333
1353
  // custom index type, e.g. date, time, number
1334
1354
  var func = 'searchIndex_' + task.def.type;
1335
1355
  if (!self[func]) return callback( new Error("Unknown index type: " + task.def.type) );
1336
- self[func]( task, state, callback );
1356
+
1357
+ var cpf = state.perf.begin('search_' + task.def.id + '_' + task.def.type);
1358
+ self[func]( task, state, function(err) {
1359
+ cpf.end();
1360
+ state.perf.count(task.def.type + 's', 1);
1361
+ callback(err);
1362
+ } );
1337
1363
  }
1338
1364
  else if (task.literal) {
1339
- self.searchWordIndexLiteral(task, state, callback);
1365
+ // literal multi-word phrase
1366
+ var spf = state.perf.begin('search_' + task.def.id + '_literal');
1367
+ self.searchWordIndexLiteral(task, state, function(err) {
1368
+ spf.end();
1369
+ state.perf.count('literals', 1);
1370
+ callback(err);
1371
+ });
1340
1372
  }
1341
1373
  else {
1342
- self.searchWordIndex(task, state, callback);
1374
+ // single word search
1375
+ var spf = state.perf.begin('search_' + task.def.id + '_word');
1376
+ self.searchWordIndex(task, state, function(err) {
1377
+ spf.end();
1378
+ state.perf.count('words', 1);
1379
+ callback(err);
1380
+ });
1343
1381
  }
1344
1382
  },
1345
1383
  function(err) {
@@ -1347,6 +1385,7 @@ module.exports = Class.create({
1347
1385
  if (err) {
1348
1386
  self.logError('index', "Index search failed: " + err);
1349
1387
  state.record_ids = {};
1388
+ state.err = err;
1350
1389
  }
1351
1390
  self.logDebug(10, "Search complete", state.record_ids);
1352
1391
  callback(null, state.record_ids, Tools.copyHashRemoveKeys(state, { config:1, record_ids:1, first:1 }));
@@ -1357,7 +1396,7 @@ module.exports = Class.create({
1357
1396
  },
1358
1397
 
1359
1398
  searchWordIndex: function(query, state, callback) {
1360
- // run one search query (list of words against one index)
1399
+ // run one word query (single word against one index)
1361
1400
  var self = this;
1362
1401
  var config = state.config;
1363
1402
  var def = query.def;
@@ -1385,6 +1424,7 @@ module.exports = Class.create({
1385
1424
  // then it would probably be faster to apply the logic using _data getMulti (a.k.a row scan).
1386
1425
  // Otherwise, perform a normal hash merge (which has to read every hash page).
1387
1426
  var hash_page_size = config.hash_page_size || 1000;
1427
+ var wpf = state.perf.begin('word_' + query.word);
1388
1428
 
1389
1429
  if ((mode == 'and') && query.weight && (query.weight / hash_page_size > num_cur_items)) {
1390
1430
  this.logDebug(10, "Performing row scan on " + num_cur_items + " items", query);
@@ -1394,7 +1434,9 @@ module.exports = Class.create({
1394
1434
  return config.base_path + '/_data/' + record_id;
1395
1435
  } );
1396
1436
 
1437
+ var rspf = state.perf.begin('row_scan');
1397
1438
  this.getMulti( data_paths, function(err, datas) {
1439
+ rspf.end();
1398
1440
  if (err) return callback(err);
1399
1441
 
1400
1442
  datas.forEach( function(data, idx) {
@@ -1404,11 +1446,15 @@ module.exports = Class.create({
1404
1446
  }
1405
1447
  } );
1406
1448
 
1449
+ state.perf.count('rows_scanned', datas.length);
1450
+ wpf.end();
1407
1451
  callback();
1408
1452
  } ); // getMulti
1409
1453
  } // row scan
1410
1454
  else {
1411
1455
  this.logDebug(10, "Performing '" + mode + "' hash merge on " + num_cur_items + " items", query);
1456
+
1457
+ var hmpf = state.perf.begin('hash_merge');
1412
1458
  this.hashEachPage( path,
1413
1459
  function(items, callback) {
1414
1460
  switch (mode) {
@@ -1430,9 +1476,12 @@ module.exports = Class.create({
1430
1476
  }
1431
1477
  break;
1432
1478
  }
1479
+ state.perf.count('hash_pages', 1);
1433
1480
  callback();
1434
1481
  },
1435
1482
  function(err) {
1483
+ hmpf.end();
1484
+ wpf.end();
1436
1485
  if (mode == 'and') state.record_ids = new_items;
1437
1486
  callback(err);
1438
1487
  }
@@ -1460,6 +1509,7 @@ module.exports = Class.create({
1460
1509
  function(word, callback) {
1461
1510
  // for each word, iterate over record ids
1462
1511
  var keepers = Object.create(null);
1512
+ var wpf = state.perf.begin('literal_' + word);
1463
1513
 
1464
1514
  self.hashEachSync( path_prefix + word,
1465
1515
  function(record_id, raw_value) {
@@ -1495,6 +1545,7 @@ module.exports = Class.create({
1495
1545
  else keepers[record_id] = 1;
1496
1546
  },
1497
1547
  function(err) {
1548
+ wpf.end();
1498
1549
  // If in a subsequent word pass, make sure all temp_results
1499
1550
  // ids are still matched in the latest word
1500
1551
  if (temp_idx > 0) self.mergeIndex( temp_results, keepers, 'and' );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pixl-server-storage",
3
- "version": "3.0.23",
3
+ "version": "3.1.0",
4
4
  "description": "A key/value/list storage component for the pixl-server framework.",
5
5
  "author": "Joseph Huckaby <jhuckaby@gmail.com>",
6
6
  "homepage": "https://github.com/jhuckaby/pixl-server-storage",
@@ -21,17 +21,19 @@
21
21
  "redis"
22
22
  ],
23
23
  "dependencies": {
24
- "pixl-server": "^1.0.0",
25
- "pixl-class": "^1.0.3",
26
- "pixl-tools": "^1.0.24",
27
- "pixl-perf": "^1.0.0",
28
- "pixl-cache": "^1.0.1",
24
+ "@aws-sdk/client-s3": "3.186.0",
29
25
  "async": "2.6.4",
30
- "nearley": "2.20.1",
26
+ "fast-stream-to-buffer": "1.0.0",
27
+ "he": "1.1.1",
31
28
  "moo": "0.4.3",
29
+ "nearley": "2.20.1",
30
+ "pixl-cache": "^1.0.1",
31
+ "pixl-class": "^1.0.3",
32
+ "pixl-perf": "^1.0.0",
33
+ "pixl-server": "^1.0.0",
34
+ "pixl-tools": "^1.0.24",
32
35
  "porter-stemmer": "0.9.1",
33
- "unidecode": "0.1.8",
34
- "he": "1.1.1"
36
+ "unidecode": "0.1.8"
35
37
  },
36
38
  "devDependencies": {
37
39
  "pixl-unit": "^1.0.0"
package/test/test-main.js CHANGED
@@ -446,10 +446,11 @@ module.exports = {
446
446
 
447
447
  self.storage.getStreamRange( key, 0, 5, function(err, storageStream, streamInfo) {
448
448
  test.ok( !err, "No error fetching stream: " + err );
449
+ test.debug( "streamInfo: ", streamInfo );
449
450
  test.ok( !!storageStream, "Got storage stream as 2nd arg");
450
451
  test.ok( !!storageStream.pipe, "Storage stream has a pipe");
451
452
  test.ok( !!streamInfo, "Info was provided as the 3rd arg");
452
- test.ok( streamInfo.len == 43, "Info has correct data length");
453
+ test.ok( streamInfo.len == 6, "Info has correct data length");
453
454
  test.ok( streamInfo.mod > 0, "Info has a non-zero mod date");
454
455
 
455
456
  outStream.on('finish', function() {
@@ -496,7 +497,7 @@ module.exports = {
496
497
  test.ok( !!storageStream, "Got storage stream as 2nd arg");
497
498
  test.ok( !!storageStream.pipe, "Storage stream has a pipe");
498
499
  test.ok( !!streamInfo, "Info was provided as the 3rd arg");
499
- test.ok( streamInfo.len == 43, "Info has correct data length");
500
+ test.ok( streamInfo.len == 23, "Info has correct data length");
500
501
  test.ok( streamInfo.mod > 0, "Info has a non-zero mod date");
501
502
 
502
503
  outStream.on('finish', function() {
@@ -543,7 +544,7 @@ module.exports = {
543
544
  test.ok( !!storageStream, "Got storage stream as 2nd arg");
544
545
  test.ok( !!storageStream.pipe, "Storage stream has a pipe");
545
546
  test.ok( !!streamInfo, "Info was provided as the 3rd arg");
546
- test.ok( streamInfo.len == 43, "Info has correct data length");
547
+ test.ok( streamInfo.len == 10, "Info has correct data length");
547
548
  test.ok( streamInfo.mod > 0, "Info has a non-zero mod date");
548
549
 
549
550
  outStream.on('finish', function() {