pelias-openstreetmap 9.1.0 → 9.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/README.md CHANGED
@@ -94,6 +94,27 @@ The OSM importer will look for a file with a name matching this value in the con
94
94
 
95
95
  If downloading from a remote URL, the filename must match the value in `sourceURL`.
96
96
 
97
+ A file ending in `.ndjson`, `.ldjson` or `.jsonl`, optionally `.gz` compressed,
98
+ is read as newline delimited JSON instead of being parsed as a pbf. It is
99
+ assumed to be the stored output of a previous `pbf2json` run over the same
100
+ extract, and importing it produces exactly the same documents.
101
+
102
+ ```javascript
103
+ {
104
+ "imports": {
105
+ "openstreetmap": {
106
+ "datapath": "/data/openstreetmap",
107
+ "import": [{
108
+ "filename": "pbf2json.pelias.jsonl.gz"
109
+ }]
110
+ }
111
+ }
112
+ }
113
+ ```
114
+
115
+ The two forms can be mixed in one `import` array, and both work with
116
+ `parallelism`. Compressed files are decompressed as they are read.
117
+
97
118
  #### `imports.openstreetmap.layers`
98
119
 
99
120
  This is an object you can use to define your own layers based on OSM tags. For example, if you wanted a layer `coffee` for just coffee shops you could set:
@@ -136,6 +157,16 @@ discarded. In practice, this affects records with tags such as
136
157
 
137
158
  By default, or if set to any other value besides `true`, these records will be imported.
138
159
 
160
+ #### `imports.openstreetmap.parallelism`
161
+
162
+ The number of import pipelines to run in parallel. Defaults to `1`, which runs
163
+ the importer as a single process.
164
+
165
+ Reading a PBF file cannot be parallelized, but everything after the parser can
166
+ be. When set above `1`, a single `pbf2json` reader is started and its output is
167
+ distributed across that many worker processes, each running the full import
168
+ pipeline.
169
+
139
170
  ### Administrative Hierarchy Lookup
140
171
 
141
172
  OSM records often do not contain information about which city, state (or
package/index.js CHANGED
@@ -9,4 +9,12 @@ if (_.has(peliasConfig, 'imports.openstreetmap.adminLookup')) {
9
9
 
10
10
  const importPipeline = require('./stream/importPipeline');
11
11
 
12
- importPipeline.import();
12
+ // a parallelism greater than one runs a single pbf2json reader which fans its
13
+ // output out to that many worker processes, each running the full pipeline
14
+ const parallelism = _.get(peliasConfig, 'imports.openstreetmap.parallelism', 1);
15
+
16
+ if (parallelism > 1) {
17
+ require('./parallel/dispatcher').run(parallelism);
18
+ } else {
19
+ importPipeline.import();
20
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pelias-openstreetmap",
3
- "version": "9.1.0",
3
+ "version": "9.2.0",
4
4
  "engines": {
5
5
  "node": ">=22.0.0"
6
6
  },
@@ -0,0 +1,284 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ Parallel import dispatcher.
5
+
6
+ Reading a pbf file cannot be parallelized, but everything downstream of the
7
+ parser is stateless per record, so a single pbf2json reader can feed any
8
+ number of import pipelines. This module runs that reader and forwards its
9
+ output, split on line boundaries, to N worker processes.
10
+
11
+ Blocks are handed to whichever worker is currently accepting writes, so a
12
+ worker blocked on Elasticsearch does not stall the reader. When every worker
13
+ is busy the reader is paused, which propagates backpressure to pbf2json.
14
+
15
+ Only used when imports.openstreetmap.parallelism is greater than 1.
16
+ **/
17
+
18
+ const child = require('child_process');
19
+ const path = require('path');
20
+ const os = require('os');
21
+ const fs = require('fs');
22
+ const logger = require('pelias-logger').get('openstreetmap');
23
+ const settings = require('pelias-config').generate(require('../schema'));
24
+ const pbf = require('../stream/pbf');
25
+ const source = require('../stream/source');
26
+ const generateParams = require('pbf2json/lib/generateParams');
27
+
28
+ const NEWLINE = 0x0a;
29
+ const WORKER_PATH = path.join(__dirname, 'worker.js');
30
+ const PBF2JSON_BIN = path.join(
31
+ path.dirname(require.resolve('pbf2json')),
32
+ 'build',
33
+ `pbf2json.${os.platform()}-${os.arch()}`
34
+ );
35
+
36
+ function run(parallelism) {
37
+ const configs = sourceConfigs();
38
+ const state = { shuttingDown: false, exitCode: 0, workers: [], reader: null };
39
+
40
+ state.workers = startWorkers(parallelism, state);
41
+ const dispatcher = createDispatcher(state.workers);
42
+
43
+ ['SIGINT', 'SIGTERM'].forEach((signal) => {
44
+ process.on(signal, () => {
45
+ state.shuttingDown = true;
46
+ killChildren(state);
47
+ process.exit(1);
48
+ });
49
+ });
50
+
51
+ readSequentially(configs, dispatcher, state, () => {
52
+ logger.info('pbf parsing complete, waiting for workers to flush');
53
+ state.shuttingDown = true;
54
+ dispatcher.end();
55
+ });
56
+ }
57
+
58
+ // one reader config per configured import file. stored pbf2json output needs
59
+ // neither the pbf2json options nor a leveldb cache
60
+ function sourceConfigs() {
61
+ const osm = settings.imports.openstreetmap;
62
+
63
+ return osm.import.map((entry) => {
64
+ const file = path.join(osm.datapath, entry.filename);
65
+
66
+ const conf = source.isJsonSource(file) ?
67
+ { file: file, importVenues: entry.importVenues } :
68
+ pbf.config({
69
+ file: file,
70
+ leveldb: osm.leveldbpath,
71
+ importVenues: entry.importVenues
72
+ });
73
+
74
+ [conf.file, conf.leveldb].filter(Boolean).forEach((target) => {
75
+ try {
76
+ fs.statSync(target);
77
+ } catch (e) {
78
+ throw new Error('failed to stat path: ' + target);
79
+ }
80
+ });
81
+
82
+ return conf;
83
+ });
84
+ }
85
+
86
+ function startWorkers(parallelism, state, workerPath) {
87
+ const workers = [];
88
+
89
+ for (let id = 0; id < parallelism; id++) {
90
+ workers.push(spawnWorker(id, workers, state, workerPath || WORKER_PATH));
91
+ }
92
+
93
+ logger.info(`started ${parallelism} import workers`);
94
+ return workers;
95
+ }
96
+
97
+ function spawnWorker(id, workers, state, workerPath) {
98
+ const proc = child.spawn(process.execPath, process.execArgv.concat(workerPath), {
99
+ stdio: ['pipe', 'inherit', 'inherit'],
100
+ env: Object.assign({}, process.env, { PELIAS_OSM_WORKER_ID: String(id) })
101
+ });
102
+
103
+ const worker = { id: id, proc: proc, ready: true, alive: true };
104
+
105
+ // a closed stdin surfaces as the exit below, no need to also throw here
106
+ proc.stdin.on('error', () => {});
107
+
108
+ proc.on('exit', (code, signal) => {
109
+ worker.alive = false;
110
+ worker.ready = false;
111
+
112
+ if (!state.shuttingDown) {
113
+ fatal(state, `worker ${id} exited early (code ${code}, signal ${signal})`);
114
+ return;
115
+ }
116
+
117
+ if (code !== 0) {
118
+ logger.error(`worker ${id} exited with code ${code}, signal ${signal}`);
119
+ state.exitCode = 1;
120
+ }
121
+
122
+ if (workers.every((w) => !w.alive)) {
123
+ logger.info('import complete');
124
+ process.exit(state.exitCode);
125
+ }
126
+ });
127
+
128
+ return worker;
129
+ }
130
+
131
+ function createDispatcher(workers) {
132
+ let cursor = 0;
133
+ let leftover = null;
134
+ let source = null;
135
+ let paused = false;
136
+
137
+ // round robin over the workers currently accepting writes
138
+ function claim() {
139
+ let fallback = null;
140
+
141
+ for (let i = 0; i < workers.length; i++) {
142
+ const worker = workers[(cursor + i) % workers.length];
143
+ if (!worker.alive) { continue; }
144
+ if (worker.ready) {
145
+ cursor = (cursor + i + 1) % workers.length;
146
+ return worker;
147
+ }
148
+ fallback = fallback || worker;
149
+ }
150
+
151
+ // every worker is busy, buffer into one of them rather than dropping the
152
+ // block: the reader is paused immediately after this write
153
+ return fallback;
154
+ }
155
+
156
+ function resume() {
157
+ if (paused && source) {
158
+ paused = false;
159
+ source.resume();
160
+ }
161
+ }
162
+
163
+ function send(block) {
164
+ const worker = claim();
165
+ if (!worker) { return; }
166
+
167
+ const wasReady = worker.ready;
168
+
169
+ if (worker.proc.stdin.write(block)) {
170
+ worker.ready = true;
171
+ } else if (wasReady) {
172
+ worker.ready = false;
173
+ worker.proc.stdin.once('drain', () => {
174
+ worker.ready = true;
175
+ resume();
176
+ });
177
+ }
178
+ }
179
+
180
+ function onData(chunk) {
181
+ const buf = leftover ? Buffer.concat([leftover, chunk]) : chunk;
182
+ const idx = buf.lastIndexOf(NEWLINE);
183
+
184
+ // no complete line yet, keep accumulating
185
+ if (idx === -1) {
186
+ leftover = buf;
187
+ return;
188
+ }
189
+
190
+ leftover = idx + 1 < buf.length ? Buffer.from(buf.subarray(idx + 1)) : null;
191
+ send(buf.subarray(0, idx + 1));
192
+
193
+ if (source && !workers.some((worker) => worker.ready)) {
194
+ paused = true;
195
+ source.pause();
196
+ }
197
+ }
198
+
199
+ return {
200
+ attach: (stdout) => {
201
+ source = stdout;
202
+ paused = false;
203
+ stdout.on('data', onData);
204
+ },
205
+ end: () => {
206
+ if (leftover && leftover.length) {
207
+ send(Buffer.concat([leftover, Buffer.from('\n')]));
208
+ leftover = null;
209
+ }
210
+ workers.forEach((worker) => {
211
+ if (worker.alive) { worker.proc.stdin.end(); }
212
+ });
213
+ }
214
+ };
215
+ }
216
+
217
+ function readSequentially(configs, dispatcher, state, done) {
218
+ let index = 0;
219
+
220
+ (function next() {
221
+ if (index >= configs.length) { return done(); }
222
+
223
+ const conf = configs[index++];
224
+ logger.info('Creating read stream for: ' + conf.file);
225
+
226
+ if (source.isJsonSource(conf.file)) {
227
+ const stream = source.createByteStream(conf.file);
228
+
229
+ stream.on('error', (err) => fatal(state, 'failed to read ' + conf.file + ': ' + err.message));
230
+ stream.on('end', next);
231
+
232
+ state.reader = stream;
233
+ dispatcher.attach(stream);
234
+ return;
235
+ }
236
+
237
+ const proc = child.spawn(PBF2JSON_BIN, generateParams(conf), {
238
+ stdio: ['ignore', 'pipe', 'pipe']
239
+ });
240
+
241
+ proc.stderr.on('data', (data) => {
242
+ data.toString('utf8').trim().split('\n').forEach((line) => {
243
+ if (line.indexOf('[info]') === -1 && line.indexOf('[warn]') === -1) {
244
+ logger.error('[pbf2json]: ' + line);
245
+ }
246
+ });
247
+ });
248
+
249
+ proc.on('close', (code) => {
250
+ if (code !== 0) {
251
+ return fatal(state, 'pbf2json exited with code ' + code);
252
+ }
253
+ next();
254
+ });
255
+
256
+ state.reader = proc;
257
+ dispatcher.attach(proc.stdout);
258
+ })();
259
+ }
260
+
261
+ function killChildren(state) {
262
+ if (state.reader) {
263
+ if (typeof state.reader.kill === 'function') { state.reader.kill(); }
264
+ else { state.reader.destroy(); }
265
+ }
266
+ state.workers.forEach((worker) => {
267
+ if (worker.alive) { worker.proc.kill(); }
268
+ });
269
+ }
270
+
271
+ function fatal(state, message) {
272
+ logger.error(message);
273
+ state.shuttingDown = true;
274
+ killChildren(state);
275
+ process.exit(1);
276
+ }
277
+
278
+ module.exports.run = run;
279
+
280
+ // exported for testing
281
+ module.exports.createDispatcher = createDispatcher;
282
+ module.exports.startWorkers = startWorkers;
283
+ module.exports.readSequentially = readSequentially;
284
+ module.exports.sourceConfigs = sourceConfigs;
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ Decodes a byte stream of newline delimited JSON into an object stream.
5
+ Used by the parallel import workers to read the pbf2json output forwarded
6
+ to them by the dispatcher.
7
+
8
+ A line which fails to parse is fatal: it means the stream was truncated or
9
+ the dispatcher mis-aligned a block boundary, and silently dropping records
10
+ would produce an incomplete index.
11
+ **/
12
+
13
+ const { Transform } = require('stream');
14
+ const NEWLINE = 0x0a;
15
+
16
+ class NDJSONDecoder extends Transform {
17
+ constructor() {
18
+ super({ writableObjectMode: false, readableObjectMode: true });
19
+ this.leftover = null;
20
+ }
21
+
22
+ _transform(chunk, enc, next) {
23
+ const buf = this.leftover ? Buffer.concat([this.leftover, chunk]) : chunk;
24
+ let start = 0;
25
+ let idx = buf.indexOf(NEWLINE, start);
26
+
27
+ while (idx !== -1) {
28
+ const err = this.decode(buf.subarray(start, idx));
29
+ if (err) { return next(err); }
30
+ start = idx + 1;
31
+ idx = buf.indexOf(NEWLINE, start);
32
+ }
33
+
34
+ this.leftover = start < buf.length ? buf.subarray(start) : null;
35
+ next();
36
+ }
37
+
38
+ _flush(next) {
39
+ const leftover = this.leftover;
40
+ this.leftover = null;
41
+ next(leftover ? this.decode(leftover) : null);
42
+ }
43
+
44
+ // returns an Error when the line is not valid JSON, else undefined
45
+ decode(line) {
46
+ if (!line.length) { return; }
47
+ try {
48
+ const obj = JSON.parse(line);
49
+ if (obj) { this.push(obj); }
50
+ } catch (e) {
51
+ return new Error('failed to decode json: ' + line.toString('utf8').slice(0, 200));
52
+ }
53
+ }
54
+ }
55
+
56
+ module.exports.create = function () {
57
+ return new NDJSONDecoder();
58
+ };
@@ -0,0 +1,23 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ A single import pipeline reading newline delimited JSON from stdin instead of
5
+ spawning its own pbf2json reader. Spawned by the dispatcher, never run directly.
6
+ **/
7
+
8
+ const logger = require('pelias-logger').get('openstreetmap');
9
+ const importPipeline = require('../stream/importPipeline');
10
+ const ndjson = require('./ndjson');
11
+
12
+ const id = process.env.PELIAS_OSM_WORKER_ID || '0';
13
+ const source = ndjson.create();
14
+
15
+ logger.info(`import worker ${id} ready`);
16
+
17
+ source.on('error', (err) => {
18
+ logger.error(`worker ${id} failed to decode input: ${err.message}`);
19
+ process.exit(1);
20
+ });
21
+
22
+ process.stdin.pipe(source);
23
+ importPipeline.import(source, `openstreetmap-${id}`);
package/schema.js CHANGED
@@ -9,6 +9,7 @@ const Joi = require('joi');
9
9
  // importVenues: boolean (optional)
10
10
  // download: array of objects containing sourceURL (optional)
11
11
  // deduplicate: boolean (optional)
12
+ // parallelism: integer >= 1 (optional, defaults to 1)
12
13
  module.exports = Joi.object().keys({
13
14
  imports: Joi.object().keys({
14
15
  openstreetmap: Joi.object().keys({
@@ -22,6 +23,7 @@ module.exports = Joi.object().keys({
22
23
  sourceURL: Joi.string()
23
24
  }).requiredKeys('sourceURL').unknown(true)),
24
25
  deduplicate: Joi.boolean(),
26
+ parallelism: Joi.number().integer().min(1).default(1),
25
27
  addressTags: Joi.array().items(Joi.string()),
26
28
  layers: Joi.object().pattern(
27
29
  Joi.string(),
@@ -21,8 +21,11 @@ streams.dbMapper = require('pelias-model').createDocumentMapperStream;
21
21
  streams.elasticsearch = require('pelias-dbclient');
22
22
 
23
23
  // default import pipeline
24
- streams.import = function(){
25
- streams.pbfParser()
24
+ // source and name are only supplied by the parallel dispatcher's workers, which
25
+ // read pbf2json output from stdin rather than parsing a pbf themselves, and name
26
+ // themselves individually so dbclient stats and output files stay separable
27
+ streams.import = function(source, name){
28
+ ( source || streams.pbfParser() )
26
29
  .pipe( streams.docConstructor() )
27
30
  .pipe( streams.addressesWithoutStreet() )
28
31
  .pipe( streams.tagMapper() )
@@ -34,7 +37,7 @@ streams.import = function(){
34
37
  .pipe( streams.popularityMapper() )
35
38
  .pipe( streams.adminLookup() )
36
39
  .pipe( streams.dbMapper() )
37
- .pipe( streams.elasticsearch({name: 'openstreetmap'}) );
40
+ .pipe( streams.elasticsearch({name: name || 'openstreetmap'}) );
38
41
  };
39
42
 
40
43
  module.exports = streams;
@@ -1,5 +1,5 @@
1
1
  var combinedStream = require('combined-stream');
2
- var pbf = require('./pbf');
2
+ var source = require('./source');
3
3
  var path = require('path');
4
4
  var logger = require('pelias-logger').get('openstreetmap');
5
5
 
@@ -15,7 +15,7 @@ function createCombinedStream(){
15
15
  };
16
16
  fullStream.append(function(next){
17
17
  logger.info('Creating read stream for: ' + conf.file);
18
- next(pbf.parser(conf));
18
+ next(source.createRecordStream(conf));
19
19
  });
20
20
  });
21
21
 
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ Chooses how to read a configured OSM source file.
5
+
6
+ A .pbf is parsed by pbf2json, which first builds a leveldb cache of node
7
+ positions. That cache is the slowest part of an OSM import - hours for a
8
+ planet file - and its output is deterministic, so it is often produced once
9
+ and stored.
10
+
11
+ A .ndjson, .ldjson or .jsonl file is assumed to be exactly that: the newline
12
+ delimited output of a previous pbf2json run. It is read directly, skipping
13
+ both the parse and the cache build. Since that output is usually kept
14
+ compressed, a .gz suffix is decompressed on the way through.
15
+ **/
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const zlib = require('zlib');
20
+ const { pipeline } = require('stream');
21
+ const pbf = require('./pbf');
22
+ const ndjson = require('../parallel/ndjson');
23
+ const logger = require('pelias-logger').get('openstreetmap');
24
+
25
+ const JSON_EXTENSIONS = ['.ndjson', '.ldjson', '.jsonl'];
26
+ const GZIP_EXTENSION = '.gz';
27
+
28
+ function isCompressed(file) {
29
+ return path.extname(file || '').toLowerCase() === GZIP_EXTENSION;
30
+ }
31
+
32
+ // the name without its compression suffix, so 'planet.jsonl.gz' is recognised
33
+ // by the same rule as 'planet.jsonl'
34
+ function uncompressedName(file) {
35
+ return isCompressed(file) ? (file || '').slice(0, -GZIP_EXTENSION.length) : (file || '');
36
+ }
37
+
38
+ /**
39
+ * True when this file holds stored pbf2json output rather than a pbf.
40
+ */
41
+ function isJsonSource(file) {
42
+ return JSON_EXTENSIONS.includes(path.extname(uncompressedName(file)).toLowerCase());
43
+ }
44
+
45
+ // options which pbf2json applies while parsing, and which a stored file has
46
+ // already been produced with
47
+ function warnIgnoredOptions(conf) {
48
+ if (conf.importVenues === false) {
49
+ logger.warn(
50
+ `importVenues is ignored for ${path.basename(conf.file)}: which tags were ` +
51
+ 'extracted was decided when the file was generated'
52
+ );
53
+ }
54
+ }
55
+
56
+ /**
57
+ * A stream of OSM record objects, from either source type.
58
+ */
59
+ function createRecordStream(conf) {
60
+ if (!isJsonSource(conf.file)) {
61
+ return pbf.parser(conf);
62
+ }
63
+
64
+ warnIgnoredOptions(conf);
65
+
66
+ // pipeline rather than pipe: a read or decompression failure has to surface
67
+ // on the stream the pipeline is consuming, not be lost upstream
68
+ return pipeline(createByteStream(conf.file), ndjson.create(), () => {});
69
+ }
70
+
71
+ /**
72
+ * The raw newline delimited bytes of a stored file, for the parallel
73
+ * dispatcher: it splits on newlines itself and never parses the json.
74
+ *
75
+ * Decompression, when needed, happens in this process. For a planet sized file
76
+ * that is a few percent of one core spread over the whole import, far less than
77
+ * the pbf parse it replaces.
78
+ */
79
+ function createByteStream(file) {
80
+ const raw = fs.createReadStream(file);
81
+ if (!isCompressed(file)) { return raw; }
82
+
83
+ // pipeline so the file handle is closed if decompression fails, or if the
84
+ // reader is destroyed when the import is interrupted
85
+ return pipeline(raw, zlib.createGunzip(), () => {});
86
+ }
87
+
88
+ module.exports = {
89
+ isJsonSource: isJsonSource,
90
+ isCompressed: isCompressed,
91
+ createRecordStream: createRecordStream,
92
+ createByteStream: createByteStream
93
+ };