@cloudant/couchbackup 2.9.17 → 2.10.0-206
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 +10 -0
- package/app.js +253 -268
- package/bin/couchbackup.bin.js +1 -3
- package/bin/couchrestore.bin.js +2 -4
- package/includes/allDocsGenerator.js +53 -0
- package/includes/backup.js +103 -247
- package/includes/backupMappings.js +260 -0
- package/includes/config.js +10 -9
- package/includes/error.js +42 -44
- package/includes/liner.js +134 -23
- package/includes/logfilegetbatches.js +25 -60
- package/includes/logfilesummary.js +41 -71
- package/includes/parser.js +3 -3
- package/includes/request.js +95 -106
- package/includes/restore.js +45 -14
- package/includes/restoreMappings.js +141 -0
- package/includes/spoolchanges.js +57 -79
- package/includes/transforms.js +378 -0
- package/package.json +5 -8
- package/includes/change.js +0 -41
- package/includes/shallowbackup.js +0 -80
- package/includes/writer.js +0 -164
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Copyright © 2017, 2023 IBM Corp. All rights reserved.
|
|
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
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const { BackupError } = require('./error.js');
|
|
17
|
+
const debug = require('debug');
|
|
18
|
+
|
|
19
|
+
const mappingDebug = debug('couchbackup:mappings');
|
|
20
|
+
const marker = '@cloudant/couchbackup:resume';
|
|
21
|
+
const RESUME_COMMENT = `${JSON.stringify({ marker })}`; // Special marker for resumes
|
|
22
|
+
|
|
23
|
+
class Restore {
|
|
24
|
+
// For compatibility with old versions ignore all broken JSON by default.
|
|
25
|
+
// (Old versions did not have a distinguishable resume marker).
|
|
26
|
+
// If we are restoring a backup file from a newer version we'll read the metadata
|
|
27
|
+
// and change the flag.
|
|
28
|
+
suppressAllBrokenJSONErrors = true;
|
|
29
|
+
backupMode;
|
|
30
|
+
|
|
31
|
+
constructor(dbClient) {
|
|
32
|
+
this.dbClient = dbClient;
|
|
33
|
+
this.batchCounter = 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Mapper for converting a backup file line to an array of documents pending restoration.
|
|
38
|
+
*
|
|
39
|
+
* @param {object} backupLine object representation of a backup file line {lineNumber: #, line: '...'}
|
|
40
|
+
* @returns {array} array of documents parsed from the line or an empty array for invalid lines
|
|
41
|
+
*/
|
|
42
|
+
backupLineToDocsArray = (backupLine) => {
|
|
43
|
+
if (backupLine && backupLine.line !== '' && backupLine.line !== RESUME_COMMENT) {
|
|
44
|
+
// see if it parses as JSON
|
|
45
|
+
let lineAsJson;
|
|
46
|
+
try {
|
|
47
|
+
lineAsJson = JSON.parse(backupLine.line);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
mappingDebug(`Invalid JSON on line ${backupLine.lineNumber} of backup file.`);
|
|
50
|
+
if (this.suppressAllBrokenJSONErrors) {
|
|
51
|
+
// The backup file comes from an older version of couchbackup that predates RESUME_COMMENT.
|
|
52
|
+
// For compatibility ignore the broken JSON line assuming it was part of a resume.
|
|
53
|
+
mappingDebug(`Ignoring invalid JSON on line ${backupLine.lineNumber} of backup file as it was written by couchbackup version < 2.10.0 and could be a valid resume point.`);
|
|
54
|
+
return [];
|
|
55
|
+
} else if (this.backupMode === 'full' && backupLine.line.slice(-RESUME_COMMENT.length) === RESUME_COMMENT) {
|
|
56
|
+
mappingDebug(`Ignoring invalid JSON on line ${backupLine.lineNumber} of full mode backup file as it was resumed.`);
|
|
57
|
+
return [];
|
|
58
|
+
} else {
|
|
59
|
+
// If the backup wasn't resumed and we aren't ignoring errors then it is invalid and we should error
|
|
60
|
+
throw new BackupError('BackupFileJsonError', `Error on line ${backupLine.lineNumber} of backup file - cannot parse as JSON`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// if it's an array
|
|
64
|
+
if (lineAsJson && Array.isArray(lineAsJson)) {
|
|
65
|
+
return lineAsJson;
|
|
66
|
+
} else if (backupLine.lineNumber === 1 && lineAsJson.name && lineAsJson.version && lineAsJson.mode) {
|
|
67
|
+
// First line is metadata.
|
|
68
|
+
mappingDebug(`Parsed backup file metadata ${lineAsJson.name} ${lineAsJson.version} ${lineAsJson.mode}.`);
|
|
69
|
+
// This identifies a version of 2.10.0 or newer that wrote the backup file.
|
|
70
|
+
// Set the mode that was used for the backup file.
|
|
71
|
+
this.backupMode = lineAsJson.mode;
|
|
72
|
+
// For newer versions we don't need to ignore all broken JSON, only ones that
|
|
73
|
+
// were associated wiht a resume, so unset the ignore flag.
|
|
74
|
+
this.suppressAllBrokenJSONErrors = false;
|
|
75
|
+
// Later we may add other version/feature specific toggles here.
|
|
76
|
+
} else if (lineAsJson.marker && lineAsJson.marker === marker) {
|
|
77
|
+
mappingDebug(`Resume marker on line ${backupLine.lineNumber} of backup file.`);
|
|
78
|
+
} else {
|
|
79
|
+
throw new BackupError('BackupFileJsonError', `Error on line ${backupLine.lineNumber} of backup file - not an array or expected metadata`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Return an empty array if there was a blank line (including a line of only the resume marker)
|
|
83
|
+
return [];
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Mapper to wrap an array of docs in batch metadata
|
|
88
|
+
* @param {array} docs an array of documents to be restored
|
|
89
|
+
* @returns {object} a pending restore batch {batch: #, docs: [...]}
|
|
90
|
+
*/
|
|
91
|
+
docsToRestoreBatch = (docs) => {
|
|
92
|
+
return { batch: this.batchCounter++, docs };
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Mapper for converting a pending restore batch to a _bulk_docs request
|
|
97
|
+
* and awaiting the response and finally returing a "restored" object
|
|
98
|
+
* with the batch number and number of restored docs.
|
|
99
|
+
*
|
|
100
|
+
* @param {object} restoreBatch a pending restore batch {batch: #, docs: [{_id: id, ...}, ...]}
|
|
101
|
+
* @returns {object} a restored batch object { batch: #, documents: #}
|
|
102
|
+
*/
|
|
103
|
+
pendingToRestored = async(restoreBatch) => {
|
|
104
|
+
// Save the batch number
|
|
105
|
+
const batch = restoreBatch.batch;
|
|
106
|
+
mappingDebug(`Preparing to restore ${batch}`);
|
|
107
|
+
// Remove it from the restoreBatch since we'll use that as our payload
|
|
108
|
+
delete restoreBatch.batch;
|
|
109
|
+
if (!restoreBatch.docs || restoreBatch.docs.length === 0) {
|
|
110
|
+
mappingDebug(`Nothing to restore in batch ${batch}.`);
|
|
111
|
+
return { batch, documents: 0 };
|
|
112
|
+
}
|
|
113
|
+
mappingDebug(`Restoring batch ${batch} with ${restoreBatch.docs.length} docs.`);
|
|
114
|
+
// if we are restoring known revisions, we need to supply new_edits=false
|
|
115
|
+
if (restoreBatch.docs[0] && restoreBatch.docs[0]._rev) {
|
|
116
|
+
restoreBatch.new_edits = false;
|
|
117
|
+
mappingDebug('Using new_edits false mode.');
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
const response = await this.dbClient.service.postBulkDocs({
|
|
121
|
+
db: this.dbClient.dbName,
|
|
122
|
+
bulkDocs: restoreBatch
|
|
123
|
+
});
|
|
124
|
+
if (!response.result || (restoreBatch.new_edits === false && response.result.length > 0)) {
|
|
125
|
+
mappingDebug(`Some errors restoring batch ${batch}.`);
|
|
126
|
+
throw new Error(`Error writing batch ${batch} with new_edits:${restoreBatch.new_edits !== false}` +
|
|
127
|
+
` and ${response.result ? response.result.length : 'unavailable'} items`);
|
|
128
|
+
}
|
|
129
|
+
mappingDebug(`Successfully restored batch ${batch}.`);
|
|
130
|
+
return { batch, documents: restoreBatch.docs.length };
|
|
131
|
+
} catch (err) {
|
|
132
|
+
mappingDebug(`Error writing docs when restoring batch ${batch}`);
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
module.exports = {
|
|
139
|
+
Restore,
|
|
140
|
+
RESUME_COMMENT
|
|
141
|
+
};
|
package/includes/spoolchanges.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Copyright © 2017,
|
|
1
|
+
// Copyright © 2017, 2024 IBM Corp. All rights reserved.
|
|
2
2
|
//
|
|
3
3
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
4
|
// you may not use this file except in compliance with the License.
|
|
@@ -13,99 +13,77 @@
|
|
|
13
13
|
// limitations under the License.
|
|
14
14
|
'use strict';
|
|
15
15
|
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
const
|
|
16
|
+
const { createWriteStream } = require('node:fs');
|
|
17
|
+
const { pipeline } = require('node:stream/promises');
|
|
18
|
+
const { BackupError } = require('./error.js');
|
|
19
|
+
const { BatchingStream, DelegateWritable, MappingStream } = require('./transforms.js');
|
|
20
20
|
const debug = require('debug')('couchbackup:spoolchanges');
|
|
21
|
+
const { ChangesFollower } = require('@ibm-cloud/cloudant');
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Write log file for all changes from a database, ready for downloading
|
|
24
25
|
* in batches.
|
|
25
26
|
*
|
|
26
|
-
* @param {
|
|
27
|
+
* @param {object} dbClient - object for connection to source database containing name, service and url
|
|
27
28
|
* @param {string} log - path to log file to use
|
|
29
|
+
* @param {function} eeFn - event emitter function to call after each write
|
|
28
30
|
* @param {number} bufferSize - the number of changes per batch/log line
|
|
29
|
-
* @param {
|
|
31
|
+
* @param {number} tolerance - changes follower error tolerance
|
|
30
32
|
*/
|
|
31
|
-
module.exports = function(
|
|
32
|
-
|
|
33
|
-
const buffer = [];
|
|
33
|
+
module.exports = function(dbClient, log, eeFn, bufferSize = 500, tolerance = 600000) {
|
|
34
|
+
let lastSeq;
|
|
34
35
|
let batch = 0;
|
|
35
|
-
let
|
|
36
|
-
const logStream = fs.createWriteStream(log);
|
|
37
|
-
let pending = 0;
|
|
38
|
-
// The number of changes to fetch per request
|
|
39
|
-
const limit = 100000;
|
|
36
|
+
let totalBuffer = 0;
|
|
40
37
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
38
|
+
class LogWriter extends DelegateWritable {
|
|
39
|
+
constructor(log) {
|
|
40
|
+
super('logFileChangesWriter', // name for debug
|
|
41
|
+
createWriteStream(log, { flags: 'a' }), // log file write stream (append mode)
|
|
42
|
+
() => {
|
|
43
|
+
debug('finished streaming database changes');
|
|
44
|
+
return ':changes_complete ' + lastSeq + '\n';
|
|
45
|
+
}, // Function to write complete last chunk
|
|
46
|
+
mapBackupBatchToPendingLogLine, // map the changes batch to a log line
|
|
47
|
+
eeFn // postWrite function to emit the 'batch' event
|
|
48
|
+
);
|
|
49
49
|
}
|
|
50
|
-
}
|
|
50
|
+
}
|
|
51
51
|
|
|
52
|
-
//
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
if (
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
} else
|
|
62
|
-
|
|
63
|
-
pending = c.pending;
|
|
52
|
+
// Map a batch of changes to document IDs, checking for errors
|
|
53
|
+
const mapChangesToIds = function(changesBatch) {
|
|
54
|
+
return changesBatch.map((changeResultItem) => {
|
|
55
|
+
if (changeResultItem.changes && changeResultItem.changes.length > 0) {
|
|
56
|
+
if (changeResultItem.seq) {
|
|
57
|
+
lastSeq = changeResultItem.seq;
|
|
58
|
+
}
|
|
59
|
+
// Extract the document ID from the change
|
|
60
|
+
return { id: changeResultItem.id };
|
|
61
|
+
} else {
|
|
62
|
+
throw new BackupError('SpoolChangesError', `Received invalid change: ${JSON.stringify(changeResultItem)}`);
|
|
64
63
|
}
|
|
65
|
-
}
|
|
64
|
+
});
|
|
66
65
|
};
|
|
67
66
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
.on('finish', function() {
|
|
83
|
-
processBuffer(true);
|
|
84
|
-
if (!lastSeq) {
|
|
85
|
-
logStream.end();
|
|
86
|
-
debug('changes request terminated before last_seq was sent');
|
|
87
|
-
callback(new error.BackupError('SpoolChangesError', 'Changes request terminated before last_seq was sent'));
|
|
88
|
-
} else {
|
|
89
|
-
debug(`changes request completed with last_seq: ${lastSeq} and ${pending} changes pending.`);
|
|
90
|
-
if (pending > 0) {
|
|
91
|
-
// Return the next promise
|
|
92
|
-
return getChanges(lastSeq);
|
|
93
|
-
} else {
|
|
94
|
-
debug('finished streaming database changes');
|
|
95
|
-
logStream.end(':changes_complete ' + lastSeq + '\n', 'utf8', callback);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
});
|
|
99
|
-
})
|
|
100
|
-
.catch(err => {
|
|
101
|
-
logStream.end();
|
|
102
|
-
if (err.status && err.status >= 400) {
|
|
103
|
-
callback(error.convertResponseError(err));
|
|
104
|
-
} else if (err.name !== 'SpoolChangesError') {
|
|
105
|
-
callback(new error.BackupError('SpoolChangesError', `Failed changes request - ${err.message}`));
|
|
106
|
-
}
|
|
107
|
-
});
|
|
108
|
-
}
|
|
67
|
+
const mapChangesBatchToBackupBatch = function(changesBatch) {
|
|
68
|
+
return { command: 't', batch: batch++, docs: mapChangesToIds(changesBatch) };
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const mapBackupBatchToPendingLogLine = function(backupBatch) {
|
|
72
|
+
totalBuffer += backupBatch.docs.length;
|
|
73
|
+
debug('writing', backupBatch.docs.length, 'changes to the backup log file with total of', totalBuffer);
|
|
74
|
+
return `:t batch${backupBatch.batch} ${JSON.stringify(backupBatch.docs)}\n`;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const changesParams = {
|
|
78
|
+
db: dbClient.dbName,
|
|
79
|
+
seqInterval: bufferSize
|
|
80
|
+
};
|
|
109
81
|
|
|
110
|
-
|
|
82
|
+
const changesFollower = new ChangesFollower(dbClient.service, changesParams, tolerance);
|
|
83
|
+
return pipeline(
|
|
84
|
+
changesFollower.startOneOff(), // stream of changes from the DB
|
|
85
|
+
new BatchingStream(bufferSize), // group changes into bufferSize batches for mapping
|
|
86
|
+
new MappingStream(mapChangesBatchToBackupBatch), // map a batch of ChangesResultItem to doc IDs
|
|
87
|
+
new LogWriter(log)
|
|
88
|
+
);
|
|
111
89
|
};
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// Copyright © 2023, 2024 IBM Corp. All rights reserved.
|
|
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
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const { Duplex, PassThrough, Writable, getDefaultHighWaterMark, setDefaultHighWaterMark } = require('node:stream');
|
|
17
|
+
const debug = require('debug');
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Input: stream of elements
|
|
21
|
+
* Output: stream of arrays of batchSize elements each
|
|
22
|
+
*/
|
|
23
|
+
class BatchingStream extends Duplex {
|
|
24
|
+
// The buffer of elements to batch
|
|
25
|
+
elementsToBatch = [];
|
|
26
|
+
// The current batch ID
|
|
27
|
+
batchId = 0;
|
|
28
|
+
// Flag whether the Readable side is currently draining
|
|
29
|
+
isReadableDraining = true;
|
|
30
|
+
// Flag whether the writable side is complete
|
|
31
|
+
isWritableComplete = false;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Make a new BatchingStream with the given output batch
|
|
35
|
+
* size and whether it is accepting arrays for rebatching
|
|
36
|
+
* or single elements and buffering the set number of batches.
|
|
37
|
+
*
|
|
38
|
+
* @param {number} batchSize output batch (array) size
|
|
39
|
+
* @param {boolean} rebatch true to accept arrays and resize them (defaults to false to accept single items)
|
|
40
|
+
* @param {number} batchHighWaterMark the number of batches to buffer before applying upstream back-pressure
|
|
41
|
+
*/
|
|
42
|
+
constructor(batchSize, rebatch = false, batchHighWaterMark = 1) {
|
|
43
|
+
// This Duplex stream is always objectMode and doesn't use the stream
|
|
44
|
+
// buffers. It does use an internal buffer of elements for batching, which
|
|
45
|
+
// holds up to 1 batch in memory.
|
|
46
|
+
// The Writable side of this Duplex is written to by the element supplier.
|
|
47
|
+
// The Readable side of this Duplex is read by the batch consumer.
|
|
48
|
+
super({ objectMode: true, readableHighWaterMark: 0, writableHighWaterMark: 0 });
|
|
49
|
+
// Logging config
|
|
50
|
+
this.log = debug((`couchbackup:transform:${rebatch ? 're' : ''}batch`));
|
|
51
|
+
this.log(`Batching to size ${batchSize}`);
|
|
52
|
+
this.batchSize = batchSize;
|
|
53
|
+
this.rebatch = rebatch;
|
|
54
|
+
this.elementHighWaterMark = batchHighWaterMark * this.batchSize;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Check the available elementsToBatch and if the downstream consumer is
|
|
59
|
+
* accepting make and push as many batches as possible.
|
|
60
|
+
*
|
|
61
|
+
* This will not push if the Readable is not draining (downstream back-pressure).
|
|
62
|
+
* Batches will be pushed if:
|
|
63
|
+
* 1. The Readable is draining and there are at least batch size elements
|
|
64
|
+
* 2. The Readable is draining and there will be no new elements (the Writable is complete)
|
|
65
|
+
* and there are any elements available.
|
|
66
|
+
* Condition 2 allows for a smaller sized partial final batch.
|
|
67
|
+
*/
|
|
68
|
+
tryPushingBatches() {
|
|
69
|
+
this.log('Try to push batches.',
|
|
70
|
+
`Available elements:${this.elementsToBatch.length}`,
|
|
71
|
+
`Readable draining:${this.isReadableDraining}`,
|
|
72
|
+
`Writable complete:${this.isWritableComplete}`);
|
|
73
|
+
while (this.isReadableDraining &&
|
|
74
|
+
(this.elementsToBatch.length >= this.batchSize ||
|
|
75
|
+
(this.isWritableComplete && this.elementsToBatch.length > 0))) {
|
|
76
|
+
// Splice up to batchSize elements from the available elements
|
|
77
|
+
const batch = this.elementsToBatch.splice(0, this.batchSize);
|
|
78
|
+
this.log(`Writing batch ${this.batchId} with ${batch.length} elements.`);
|
|
79
|
+
// Increment the batch ID ready for the next batch
|
|
80
|
+
this.batchId++;
|
|
81
|
+
// push the batch downstream
|
|
82
|
+
if (!this.push(batch)) {
|
|
83
|
+
// There was back-pressure from downstream.
|
|
84
|
+
// Unset the draining flag and break the loop.
|
|
85
|
+
this.isReadableDraining = false;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (this.elementsToBatch.length < this.batchSize) {
|
|
90
|
+
// We've drained the buffer, release upstream.
|
|
91
|
+
if (this.pendingCallback) {
|
|
92
|
+
this.log('Unblocking after downstream reads.');
|
|
93
|
+
this.pendingCallback();
|
|
94
|
+
this.pendingCallback = null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (this.elementsToBatch.length === 0 && this.isWritableComplete) {
|
|
98
|
+
this.log('No further elements, signalling EOF.');
|
|
99
|
+
this.push(null);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Implementation of _read.
|
|
105
|
+
* The Duplex.read is called when downstream can accept more data.
|
|
106
|
+
* That in turn calls this _read implementation.
|
|
107
|
+
*
|
|
108
|
+
* @param {number} size ignored for objectMode
|
|
109
|
+
*/
|
|
110
|
+
_read(size) {
|
|
111
|
+
// Downstream asked for data set the draining flag.
|
|
112
|
+
this.isReadableDraining = true;
|
|
113
|
+
// Push any available batches.
|
|
114
|
+
this.tryPushingBatches();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Implementation of _write that accepts elements and
|
|
119
|
+
* adds them to an array of elementsToBatch.
|
|
120
|
+
* If the size of elementsToBatch exceeds the configured
|
|
121
|
+
* high water mark then the element supplier receives back-pressure
|
|
122
|
+
* via a delayed callback.
|
|
123
|
+
*
|
|
124
|
+
* @param {*} element the element to add to a batch
|
|
125
|
+
* @param {string} encoding ignored (objects are passed as-is)
|
|
126
|
+
* @param {function} callback called back when elementsToBatch is not too full
|
|
127
|
+
*/
|
|
128
|
+
_write(element, encoding, callback) {
|
|
129
|
+
if (!this.rebatch) {
|
|
130
|
+
// If we're not rebatching we're dealing with a single element
|
|
131
|
+
// but the push is cleaner if we can spread, so wrap in an array.
|
|
132
|
+
element = [element];
|
|
133
|
+
}
|
|
134
|
+
if (this.elementsToBatch.push(...element) >= this.elementHighWaterMark) {
|
|
135
|
+
// Delay callback as we have more than 1 batch buffered
|
|
136
|
+
// Downstream cannot accept more batches, delay the
|
|
137
|
+
// callback to back-pressure our element supplier until
|
|
138
|
+
// after the next downstream read.
|
|
139
|
+
this.log(`Back pressure after batch ${this.batchId}`);
|
|
140
|
+
this.pendingCallback = callback;
|
|
141
|
+
// If there are enough elements we must try to push batches
|
|
142
|
+
// to satisfy the Readable contract which will not call read
|
|
143
|
+
// again until after a push in the event that no data was available.
|
|
144
|
+
this.tryPushingBatches();
|
|
145
|
+
} else {
|
|
146
|
+
// Callback immediately if there are fewer elements
|
|
147
|
+
callback();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Implementation of _final to ensure that any partial batches
|
|
153
|
+
* remaining when the supplying stream ends are pushed downstream.
|
|
154
|
+
*
|
|
155
|
+
* @param {function} callback
|
|
156
|
+
*/
|
|
157
|
+
_final(callback) {
|
|
158
|
+
this.log('Flushing batch transform.');
|
|
159
|
+
// Set the writable complete flag
|
|
160
|
+
this.isWritableComplete = true;
|
|
161
|
+
// Try to push batches
|
|
162
|
+
this.tryPushingBatches();
|
|
163
|
+
callback();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
class DelegateWritable extends Writable {
|
|
168
|
+
/**
|
|
169
|
+
* A Writable that delegates to another writable wrapping it in some
|
|
170
|
+
* helpful operations and handling "ending" of special streams like
|
|
171
|
+
* process.stdout.
|
|
172
|
+
*
|
|
173
|
+
* @param {string} name - the name of this DelegateWritable for logging
|
|
174
|
+
* @param {Writable} targetWritable - the Writable stream to write to
|
|
175
|
+
* @param {function} lastChunkFn - a no-args function to call to get a final chunk to write
|
|
176
|
+
* @param {function} chunkMapFn - a function(chunk) that can transform/map a chunk before writing
|
|
177
|
+
* @param {function} postWriteFn - a function(chunk) that can perform an action after a write completes
|
|
178
|
+
*/
|
|
179
|
+
constructor(name, targetWritable, lastChunkFn, chunkMapFn, postWriteFn) {
|
|
180
|
+
super({ objectMode: true });
|
|
181
|
+
this.name = name;
|
|
182
|
+
this.targetWritable = targetWritable;
|
|
183
|
+
this.lastChunkFn = lastChunkFn;
|
|
184
|
+
this.chunkMapFn = chunkMapFn;
|
|
185
|
+
this.postWriteFn = postWriteFn;
|
|
186
|
+
this.log = debug((`couchbackup:transform:delegate:${name}`));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
_write(chunk, encoding, callback) {
|
|
190
|
+
const toWrite = (this.chunkMapFn) ? this.chunkMapFn(chunk) : chunk;
|
|
191
|
+
this.targetWritable.write(toWrite, encoding, (err) => {
|
|
192
|
+
if (!err) {
|
|
193
|
+
this.log('completed target chunk write');
|
|
194
|
+
if (this.postWriteFn) {
|
|
195
|
+
this.postWriteFn(chunk);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
callback(err);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
_final(callback) {
|
|
203
|
+
this.log('Finalizing');
|
|
204
|
+
const lastChunk = (this.lastChunkFn && this.lastChunkFn()) || null;
|
|
205
|
+
// We can't 'end' stdout, so use a final write instead for that case
|
|
206
|
+
if (this.targetWritable === process.stdout) {
|
|
207
|
+
// we can't 'write' null, so don't do anything if there is no last chunk
|
|
208
|
+
if (lastChunk) {
|
|
209
|
+
this.targetWritable.write(lastChunk, 'utf-8', (err) => {
|
|
210
|
+
if (!err) {
|
|
211
|
+
this.log('wrote last chunk to stdout');
|
|
212
|
+
} else {
|
|
213
|
+
this.log('error writing last chunk to stdout');
|
|
214
|
+
}
|
|
215
|
+
callback(err);
|
|
216
|
+
});
|
|
217
|
+
} else {
|
|
218
|
+
this.log('no last chunk to write to stdout');
|
|
219
|
+
callback();
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
this.targetWritable.end(lastChunk, 'utf-8', (err) => {
|
|
223
|
+
if (!err) {
|
|
224
|
+
this.log('wrote last chunk and ended target writable');
|
|
225
|
+
} else {
|
|
226
|
+
this.log('error ending target writable');
|
|
227
|
+
}
|
|
228
|
+
callback(err);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* A helper PassThrough class that is used in our custom
|
|
236
|
+
* Duplex streams.
|
|
237
|
+
*/
|
|
238
|
+
class DuplexPassThrough extends PassThrough {
|
|
239
|
+
constructor(opts) {
|
|
240
|
+
super({ objectMode: true, readableHighWaterMark: 0, writableHighWaterMark: 0, ...opts });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// The destroy call on this PassThrough stream
|
|
244
|
+
// gets ahead of real errors reaching the
|
|
245
|
+
// callback/promise at the end of the pipeline.
|
|
246
|
+
// This allows an AbortError to get propagated
|
|
247
|
+
// from the micro-task queue instead because the
|
|
248
|
+
// real error is blocked behind a callback somewhere.
|
|
249
|
+
// That in turn masks the real cause of the failure,
|
|
250
|
+
// so we defer the _destroy in a setImmediate.
|
|
251
|
+
_destroy(err, cb) {
|
|
252
|
+
setImmediate(() => {
|
|
253
|
+
cb(err);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
class DuplexMapper extends Duplex {
|
|
259
|
+
constructor(fn, style, concurrency = 1) {
|
|
260
|
+
const operatorOpts = { concurrency, highWaterMark: concurrency };
|
|
261
|
+
const inputStream = new DuplexPassThrough();
|
|
262
|
+
let outputStream;
|
|
263
|
+
switch (style) {
|
|
264
|
+
case 'map':
|
|
265
|
+
outputStream = inputStream.map(fn, operatorOpts);
|
|
266
|
+
break;
|
|
267
|
+
case 'filter':
|
|
268
|
+
outputStream = inputStream.filter(fn, operatorOpts);
|
|
269
|
+
break;
|
|
270
|
+
default:
|
|
271
|
+
throw new Error('Invalid style.');
|
|
272
|
+
}
|
|
273
|
+
// Workaround the fact that Duplex.from doesn't allow customizing the HWM
|
|
274
|
+
// Set a new objectMode default value while we create the stream, then reset it.
|
|
275
|
+
const originalHWM = getDefaultHighWaterMark(true);
|
|
276
|
+
// Use concurrency as the highWaterMark to allow one item on deck for each "thread"
|
|
277
|
+
setDefaultHighWaterMark(true, concurrency);
|
|
278
|
+
try {
|
|
279
|
+
return Duplex.from({ readable: outputStream, writable: inputStream });
|
|
280
|
+
} finally {
|
|
281
|
+
setDefaultHighWaterMark(true, originalHWM);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Input: stream of x
|
|
288
|
+
* Output: stream of x with elements not passing the filter removed
|
|
289
|
+
*/
|
|
290
|
+
class FilterStream extends DuplexMapper {
|
|
291
|
+
constructor(filterFunction) {
|
|
292
|
+
super(filterFunction, 'filter');
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Input: stream of x
|
|
298
|
+
* Output: stream of mappingFunction(x)
|
|
299
|
+
*/
|
|
300
|
+
class MappingStream extends DuplexMapper {
|
|
301
|
+
constructor(mappingFunction, concurrency = 1) {
|
|
302
|
+
super(mappingFunction, 'map', concurrency);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* PassThrough stream that calls another function
|
|
308
|
+
* to perform a side effect.
|
|
309
|
+
*/
|
|
310
|
+
class SideEffect extends DuplexPassThrough {
|
|
311
|
+
constructor(fn, options) {
|
|
312
|
+
super(options);
|
|
313
|
+
this.fn = fn;
|
|
314
|
+
this.log = debug(('couchbackup:transform:sideeffect'));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async doSideEffect(chunk, encoding) {
|
|
318
|
+
return await this.fn(chunk, encoding);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
_transform(chunk, encoding, callback) {
|
|
322
|
+
this.log('Performing side effect');
|
|
323
|
+
this.doSideEffect(chunk, encoding)
|
|
324
|
+
.then(() => {
|
|
325
|
+
this.log('Passing through');
|
|
326
|
+
super._transform(chunk, encoding, callback);
|
|
327
|
+
}).catch((err) => {
|
|
328
|
+
this.log(`Caught error ${err}`);
|
|
329
|
+
callback(err);
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
class WritableWithPassThrough extends SideEffect {
|
|
335
|
+
/**
|
|
336
|
+
* A Writable that passes through the original chunk.
|
|
337
|
+
* The chunk is also passed to a SideEffect which behaves as DelegateWritable does.
|
|
338
|
+
*
|
|
339
|
+
* @param {string} name - the name of the DelegateWritable for logging
|
|
340
|
+
* @param {Writable} targetWritable - the Writable stream to write to
|
|
341
|
+
* @param {function} lastChunkFn - a no-args function to call to get a final chunk to write
|
|
342
|
+
* @param {function} chunkMapFn - a function(chunk) that can transform/map a chunk before writing
|
|
343
|
+
* @param {function} postWriteFn - a function(chunk) that can perform an action after a write completes
|
|
344
|
+
*/
|
|
345
|
+
constructor(name, targetWritable, lastChunkFn, chunkMapFn, postWriteFn) {
|
|
346
|
+
// Initialize super without a side effect fn because we need to set some
|
|
347
|
+
// properties before we can define it.
|
|
348
|
+
super(null, { objectMode: true });
|
|
349
|
+
this.log = debug(`couchbackup:transform:writablepassthrough:${name}`);
|
|
350
|
+
this.delegateWritable = new DelegateWritable(name, targetWritable, lastChunkFn, chunkMapFn, postWriteFn);
|
|
351
|
+
// Now set the side effect fn we omitted earlier
|
|
352
|
+
this.fn = (chunk, encoding) => {
|
|
353
|
+
return new Promise((resolve, reject) => {
|
|
354
|
+
this.delegateWritable.write(chunk, encoding, (err) => {
|
|
355
|
+
if (err) {
|
|
356
|
+
reject(err);
|
|
357
|
+
} else {
|
|
358
|
+
resolve();
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
_flush(callback) {
|
|
366
|
+
this.log('Flushing writable passthrough');
|
|
367
|
+
this.delegateWritable.end(callback);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
module.exports = {
|
|
372
|
+
BatchingStream,
|
|
373
|
+
DelegateWritable,
|
|
374
|
+
FilterStream,
|
|
375
|
+
MappingStream,
|
|
376
|
+
SideEffect,
|
|
377
|
+
WritableWithPassThrough
|
|
378
|
+
};
|