diff --git a/.gitignore b/.gitignore index a824681..6bc3025 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ build/ /.idea/ /data-boundaries/ /.env +/tmp/ diff --git a/.run/PaikkaApplication (Import Schleswig-Holstein).run.xml b/.run/PaikkaApplication (Import Schleswig-Holstein).run.xml new file mode 100644 index 0000000..6695bbd --- /dev/null +++ b/.run/PaikkaApplication (Import Schleswig-Holstein).run.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/src/main/java/com/dedicatedcode/paikka/service/importer/BoundaryImportStatistics.java b/src/main/java/com/dedicatedcode/paikka/service/importer/BoundaryImportStatistics.java index 88c3d5b..537c1e1 100644 --- a/src/main/java/com/dedicatedcode/paikka/service/importer/BoundaryImportStatistics.java +++ b/src/main/java/com/dedicatedcode/paikka/service/importer/BoundaryImportStatistics.java @@ -79,9 +79,16 @@ public String toString() { private volatile long phaseStartTime = System.currentTimeMillis(); private long totalTime; - private final int TOTAL_STEPS = 2; + private final int TOTAL_STEPS = 3; private int currentStep = 0; + private volatile long phase1Duration; + private volatile long phase2Duration; + private volatile long h3OsmSizeBytes; + private volatile long regionMetaSizeBytes; + private volatile long regionGeomSizeBytes; + private volatile long osmNamesSizeBytes; + public long getNodesCached() { return nodesCached.get(); } @@ -127,8 +134,14 @@ public String getCurrentPhase() { } public void setCurrentPhase(int step, String phase) { + long now = System.currentTimeMillis(); + if (this.currentStep == 1 && step != 1) { + this.phase1Duration = now - this.phaseStartTime; + } else if (this.currentStep == 2 && step != 2) { + this.phase2Duration = now - this.phaseStartTime; + } this.currentPhase = phase; - this.phaseStartTime = System.currentTimeMillis(); + this.phaseStartTime = now; this.currentStep = step; } @@ -186,9 +199,16 @@ public long getErrorsTotal() { public String getMemoryStats() { Runtime r = Runtime.getRuntime(); - long used = (r.totalMemory() - r.freeMemory()) / 1024 / 1024 / 1024; - long max = r.maxMemory() / 1024 / 1024 / 1024; - return String.format("%dGB/%dGB", used, max); + long usedBytes = r.totalMemory() - r.freeMemory(); + long maxBytes = r.maxMemory(); + return String.format("%.1fG/%.1fG", usedBytes / (1024.0 * 1024.0 * 1024.0), maxBytes / (1024.0 * 1024.0 * 1024.0)); + } + + public void setOutputSizes(long h3OsmBytes, long regionMetaBytes, long regionGeomBytes, long osmNamesBytes) { + this.h3OsmSizeBytes = h3OsmBytes; + this.regionMetaSizeBytes = regionMetaBytes; + this.regionGeomSizeBytes = regionGeomBytes; + this.osmNamesSizeBytes = osmNamesBytes; } public void startProgressReporter() { @@ -210,13 +230,13 @@ public void startProgressReporter() { sb.append(String.format("\033[1;90m[%d/%d]\033[0m ", currentStep, TOTAL_STEPS)); - if (phase.contains("1.1")) { + if (phase.contains("Caching")) { long nodesPerSec = phaseSeconds > 0 ? (long) (getNodesCached() / phaseSeconds) : 0; sb.append(String.format("\033[1;36m[%s]\033[0m \033[1mCaching Nodes & Ways\033[0m", formatTime(elapsed))); sb.append(String.format(" │ \033[32mNodes:\033[0m %s \033[33m(%s/s)\033[0m", formatCompactNumber(getNodesCached()), formatCompactRate(nodesPerSec))); sb.append(String.format(" │ \033[34mWays:\033[0m %s", formatCompactNumber(getWaysCached()))); - } else if (phase.contains("2.1")) { + } else if (phase.contains("Processing")) { long relsPerSec = phaseSeconds > 0 ? (long) (getRelationsProcessed() / phaseSeconds) : 0; double percentage = getRelationsFound() > 0 ? (double) getRelationsProcessed() / getRelationsFound() * 100.0 : 0.0; sb.append(String.format("\033[1;36m[%s]\033[0m \033[1mProcessing Relations & H3\033[0m", formatTime(elapsed))); @@ -224,6 +244,9 @@ public void startProgressReporter() { formatCompactNumber(getRelationsProcessed()), formatCompactNumber(getRelationsFound()), formatCompactRate(relsPerSec))); sb.append(String.format(" │ \033[35mProgress:\033[0m %.2f%%", percentage)); sb.append(String.format(" │ \033[36mH3 Cells:\033[0m %s", formatCompactNumber(getH3CellsGenerated()))); + if (getErrorsTotal() > 0) { + sb.append(String.format(" │ \033[31mErrors:\033[0m %d", getErrorsTotal())); + } } else { sb.append(String.format("\033[1;36m[%s]\033[0m %s", formatTime(elapsed), phase)); } @@ -248,33 +271,43 @@ public void startProgressReporter() { } public void printFinalStatistics() { - System.out.println("\n\033[1;36m" + "═".repeat(80) + "\n" + centerText("🎯 BOUNDARY IMPORT STATISTICS") + "\n" + "═".repeat(80) + "\033[0m"); + System.out.println("\n\033[1;36m" + "═".repeat(80) + "\n" + centerText("BOUNDARY IMPORT STATISTICS") + "\n" + "═".repeat(80) + "\033[0m"); long totalTime = Math.max(1, getTotalTime()); double totalSeconds = totalTime / 1000.0; + double phase1Seconds = Math.max(0.001, phase1Duration / 1000.0); + double phase2Seconds = Math.max(0.001, phase2Duration / 1000.0); - System.out.printf("\n\033[1;37m⏱️ Total Import Time:\033[0m \033[1;33m%s\033[0m%n%n", formatTime(getTotalTime())); + System.out.printf("\n\033[1;37mTotal Import Time:\033[0m \033[1;33m%s\033[0m%n%n", formatTime(getTotalTime())); - System.out.println("\033[1;37m📊 Processing Summary:\033[0m"); - System.out.println("┌────────────────────┬─────────────────┬─────────────────┐"); - System.out.println("│ \033[1mEntity Type\033[0m │ \033[1mTotal Count\033[0m │ \033[1mAvg Speed\033[0m │"); - System.out.println("├────────────────────┼─────────────────┼─────────────────┤"); - System.out.printf("│ \033[32mNodes Cached\033[0m │ %15s │ %13s/s │%n", + System.out.println("\033[1;37mProcessing Summary:\033[0m"); + System.out.println("┌──────────────────────┬─────────────────┬─────────────────┐"); + System.out.println("│ \033[1mEntity Type\033[0m │ \033[1mTotal Count\033[0m │ \033[1mAvg Speed\033[0m │"); + System.out.println("├──────────────────────┼─────────────────┼─────────────────┤"); + System.out.printf("│ \033[32mNodes Cached\033[0m │ %15s │ %13s/s │%n", formatCompactNumber(getNodesCached()), - formatCompactNumber((long) (getNodesCached() / totalSeconds))); - System.out.printf("│ \033[34mWays Cached\033[0m │ %15s │ %13s/s │%n", + formatCompactNumber((long) (getNodesCached() / phase1Seconds))); + System.out.printf("│ \033[34mWays Cached\033[0m │ %15s │ %13s/s │%n", formatCompactNumber(getWaysCached()), - formatCompactNumber((long) (getWaysCached() / totalSeconds))); - System.out.printf("│ \033[35mRelations Found\033[0m │ %15s │ %13s/s │%n", + formatCompactNumber((long) (getWaysCached() / phase1Seconds))); + System.out.printf("│ \033[35mRelations Found\033[0m │ %15s │ %13s/s │%n", formatCompactNumber(getRelationsFound()), formatCompactNumber((long) (getRelationsFound() / totalSeconds))); - System.out.printf("│ \033[36mRelations Processed\033[0m│ %15s │ %13s/s │%n", + System.out.printf("│ \033[36mRelations Processed\033[0m │ %15s │ %13s/s │%n", formatCompactNumber(getRelationsProcessed()), - formatCompactNumber((long) (getRelationsProcessed() / totalSeconds))); - System.out.printf("│ \033[33mH3 Cells Generated\033[0m │ %15s │ %13s/s │%n", + formatCompactNumber((long) (getRelationsProcessed() / phase2Seconds))); + System.out.printf("│ \033[33mH3 Cells Generated\033[0m │ %15s │ %13s/s │%n", formatCompactNumber(getH3CellsGenerated()), - formatCompactNumber((long) (getH3CellsGenerated() / totalSeconds))); - System.out.println("└────────────────────┴─────────────────┴─────────────────┘"); + formatCompactNumber((long) (getH3CellsGenerated() / phase2Seconds))); + System.out.println("└──────────────────────┴─────────────────┴─────────────────┘"); + + if (h3OsmSizeBytes > 0) { + System.out.println("\n\033[1;37mOutput Database Sizes:\033[0m"); + System.out.printf(" \033[36mh3_to_osm:\033[0m %s%n", formatSize(h3OsmSizeBytes)); + System.out.printf(" \033[36mregion_metadata:\033[0m %s%n", formatSize(regionMetaSizeBytes)); + System.out.printf(" \033[36mregion_geometry:\033[0m %s%n", formatSize(regionGeomSizeBytes)); + System.out.printf(" \033[36mosm_names.tsv:\033[0m %s%n", formatSize(osmNamesSizeBytes)); + } System.out.println(); } @@ -331,6 +364,13 @@ private String formatCompactRate(long n) { return String.format("%.1fM", n / 1_000_000.0); } + private String formatSize(long bytes) { + if (bytes < 1024) return bytes + " B"; + if (bytes < 1024 * 1024) return String.format("%.1f KB", bytes / 1024.0); + if (bytes < 1024L * 1024 * 1024) return String.format("%.1f MB", bytes / (1024.0 * 1024.0)); + return String.format("%.2f GB", bytes / (1024.0 * 1024.0 * 1024.0)); + } + private String centerText(String text) { int pad = (80 - text.length()) / 2; return " ".repeat(Math.max(0, pad)) + text; diff --git a/src/main/java/com/dedicatedcode/paikka/service/importer/StandaloneBoundaryImporter.java b/src/main/java/com/dedicatedcode/paikka/service/importer/StandaloneBoundaryImporter.java index 2c2c04f..e684ed9 100644 --- a/src/main/java/com/dedicatedcode/paikka/service/importer/StandaloneBoundaryImporter.java +++ b/src/main/java/com/dedicatedcode/paikka/service/importer/StandaloneBoundaryImporter.java @@ -45,7 +45,7 @@ * Reads a pre-filtered boundaries_only.pbf (Nodes -> Ways -> Relations ordered) * and produces three RocksDB databases for offline mobile lookup: * - h3_to_osm: H3_CELL_ID (uint64) -> List[OSM_ID] (raw byte array) - * - region_metadata: OSM_ID -> total cell count (int) + * - region_metadata: OSM_ID -> [total cell count (long), h3 resolution (int)] (12 bytes) * - region_geometry: OSM_ID -> simplified WKB (bytes) */ @Service @@ -54,6 +54,7 @@ public class StandaloneBoundaryImporter { private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory(); private static final double BUFFER_DISTANCE = 0.0001; // ~11m at equator, ensures border cells + private static final long H3_THREAD_WRITE_BUFFER = 64L * 1024 * 1024; // per-thread RocksDB write buffer private final GeometrySimplificationService geometrySimplificationService; private final PaikkaConfiguration paikkaConfiguration; @@ -83,7 +84,6 @@ public void importBoundaries(List pbfPaths, String outputDir) throws Exc Path regionGeomPath = out.resolve("region_geometry"); Path nameSql = out.resolve("osm_names.tsv"); - Path tmpH3ToOsmPath = tmp.resolve("tmp_h3_to_osm"); Path tmpRegionMetaPath = tmp.resolve("tmp_region_metadata"); Path tmpRegionGeomPath = tmp.resolve("tmp_region_geometry"); @@ -92,14 +92,13 @@ public void importBoundaries(List pbfPaths, String outputDir) throws Exc cleanup(h3ToOsmPath); cleanup(regionMetaPath); cleanup(regionGeomPath); - cleanup(tmpH3ToOsmPath); cleanup(tmpRegionMetaPath); cleanup(tmpRegionGeomPath); - // Shared Rocksoptions (inline with ImportService style) BlockBasedTableConfig tableCfg = new BlockBasedTableConfig() .setBlockSize(64 * 1024) .setFilterPolicy(new BloomFilter(10, false)); + Options cacheOpts = new Options() .setCreateIfMissing(true) .setTableFormatConfig(tableCfg) @@ -107,6 +106,12 @@ public void importBoundaries(List pbfPaths, String outputDir) throws Exc .setWriteBufferSize(512 * 1024 * 1024) .setMaxWriteBufferNumber(3) .setLevel0FileNumCompactionTrigger(4); + + // Per-thread H3 DBs use smaller write buffers (64MB each) to control memory + Options threadH3Opts = new Options(cacheOpts) + .setWriteBufferSize(H3_THREAD_WRITE_BUFFER) + .setMaxWriteBufferNumber(2); + Options finalOpts = new Options() .setCreateIfMissing(true) .setTableFormatConfig(tableCfg) @@ -125,217 +130,210 @@ public void importBoundaries(List pbfPaths, String outputDir) throws Exc stats.startProgressReporter(); - try ( - RocksDB nodeCache = RocksDB.open(cacheOpts, nodeCachePath.toString()); - RocksDB wayCache = RocksDB.open(cacheOpts, wayCachePath.toString()); - RocksDB h3ToOsm = RocksDB.open(finalOpts, h3ToOsmPath.toString()); - RocksDB regionMeta = RocksDB.open(finalOpts, regionMetaPath.toString()); - RocksDB regionGeom = RocksDB.open(finalOpts, regionGeomPath.toString()); - RocksDB tmpH3ToOsm = RocksDB.open(cacheOpts, tmpH3ToOsmPath.toString()); - RocksDB tmpRegionMeta = RocksDB.open(cacheOpts, tmpRegionMetaPath.toString()); - RocksDB tmpRegionGeom = RocksDB.open(cacheOpts, tmpRegionGeomPath.toString()); - OsmNameStreamer nameStreamer = new OsmNameStreamer(nameSql.toString()) - ) { - for (String pbfPath : pbfPaths) { - stats.setCurrentPhase(1, "1.1: Caching Nodes & Ways"); - try (WriteOptions wo = new WriteOptions().setDisableWAL(true)) { - - // ---------- SINGLE PASS ---------- - PbfIterator iterator = new PbfIterator(Files.newInputStream(Paths.get(pbfPath)), false); - - // Phase 1 & 2: Stream nodes and ways (cached) - WriteBatch nodeBatch = new WriteBatch(); - WriteBatch wayBatch = new WriteBatch(); - AtomicLong phaseCounter = new AtomicLong(); - - while (iterator.hasNext()) { - EntityContainer c = iterator.next(); - if (c.getType() == EntityType.Node) { - // PHASE 1: Cache node coordinates (lat, lon) as 16-byte double pair - OsmNode n = (OsmNode) c.getEntity(); - ByteBuffer bb = ByteBuffer.allocate(16) - .putDouble(n.getLatitude()) - .putDouble(n.getLongitude()); - nodeBatch.put(longToBytes(n.getId()), bb.array()); - stats.incrementNodesCached(); - if (phaseCounter.incrementAndGet() % 100_000 == 0) { - nodeCache.write(wo, nodeBatch); - nodeBatch.clear(); - } - } else if (c.getType() == EntityType.Way) { - // PHASE 2: Cache way node-id sequences (long[] as raw bytes) - OsmWay w = (OsmWay) c.getEntity(); - long[] ids = new long[w.getNumberOfNodes()]; - for (int i = 0; i < w.getNumberOfNodes(); i++) ids[i] = w.getNodeId(i); - wayBatch.put(longToBytes(w.getId()), longArrayToBytes(ids)); - stats.incrementWaysCached(); - if (phaseCounter.incrementAndGet() % 50_000 == 0) { - wayCache.write(wo, wayBatch); - wayBatch.clear(); - } - } else if (c.getType() == EntityType.Relation) { - // PHASE 3: Count administrative boundaries for accurate progress tracking - OsmRelation r = (OsmRelation) c.getEntity(); - if (isAdministrativeBoundary(r)) { - stats.incrementRelationsFound(); + // Shared block cache reduces redundant I/O across all RocksDB instances + try (Cache sharedCache = new LRUCache(2L * 1024 * 1024 * 1024)) { + tableCfg.setBlockCache(sharedCache); + + try ( + RocksDB nodeCache = RocksDB.open(cacheOpts, nodeCachePath.toString()); + RocksDB wayCache = RocksDB.open(cacheOpts, wayCachePath.toString()); + RocksDB h3ToOsm = RocksDB.open(finalOpts, h3ToOsmPath.toString()); + RocksDB regionMeta = RocksDB.open(finalOpts, regionMetaPath.toString()); + RocksDB regionGeom = RocksDB.open(finalOpts, regionGeomPath.toString()); + RocksDB tmpRegionMeta = RocksDB.open(cacheOpts, tmpRegionMetaPath.toString()); + RocksDB tmpRegionGeom = RocksDB.open(cacheOpts, tmpRegionGeomPath.toString()); + OsmNameStreamer nameStreamer = new OsmNameStreamer(nameSql.toString()) + ) { + int threads = paikkaConfiguration.getImportConfiguration().getThreads(); + + // Pre-create per-thread H3 db paths and clean any left-overs + Path[] threadH3Paths = new Path[threads]; + for (int t = 0; t < threads; t++) { + threadH3Paths[t] = tmp.resolve("h3_osm_" + t); + cleanup(threadH3Paths[t]); + } + + for (String pbfPath : pbfPaths) { + stats.setCurrentPhase(1, "Phase 1: Caching Nodes & Ways"); + + // ------ Single pass: cache nodes/ways and collect relation stubs ------ + List stubs = new ArrayList<>(500_000); + + try (InputStream is = Files.newInputStream(Paths.get(pbfPath)); + WriteOptions wo = new WriteOptions().setDisableWAL(true)) { + + PbfIterator iterator = new PbfIterator(is, false); + WriteBatch nodeBatch = new WriteBatch(); + WriteBatch wayBatch = new WriteBatch(); + AtomicLong phaseCounter = new AtomicLong(); + + while (iterator.hasNext()) { + EntityContainer c = iterator.next(); + if (c.getType() == EntityType.Node) { + OsmNode n = (OsmNode) c.getEntity(); + ByteBuffer bb = ByteBuffer.allocate(16) + .putDouble(n.getLatitude()) + .putDouble(n.getLongitude()); + nodeBatch.put(longToBytes(n.getId()), bb.array()); + stats.incrementNodesCached(); + if (phaseCounter.incrementAndGet() % 100_000 == 0) { + nodeCache.write(wo, nodeBatch); + nodeBatch.clear(); + } + } else if (c.getType() == EntityType.Way) { + OsmWay w = (OsmWay) c.getEntity(); + long[] ids = new long[w.getNumberOfNodes()]; + for (int i = 0; i < w.getNumberOfNodes(); i++) ids[i] = w.getNodeId(i); + wayBatch.put(longToBytes(w.getId()), longArrayToBytes(ids)); + stats.incrementWaysCached(); + if (phaseCounter.incrementAndGet() % 50_000 == 0) { + wayCache.write(wo, wayBatch); + wayBatch.clear(); + } + } else if (c.getType() == EntityType.Relation) { + OsmRelation r = (OsmRelation) c.getEntity(); + try { + nameStreamer.processEntity(r, "R"); + } catch (IOException e) { + logger.warn("Failed to stream name for relation ID: {}", r.getId(), e); + } + if (isAdministrativeBoundary(r)) { + stats.incrementRelationsFound(); + stubs.add(buildRelationStub(r)); + } } } + nodeCache.write(wo, nodeBatch); + wayCache.write(wo, wayBatch); + nodeBatch.close(); + wayBatch.close(); } - nodeCache.write(wo, nodeBatch); - wayCache.write(wo, wayBatch); - nodeBatch.close(); - wayBatch.close(); - } - stats.setCurrentPhase(2, "2.1: Processing Relations & H3"); - // Re-open iterator for Phase 3 (or use two iterators; here we reuse file) + // ------ Process Relations via partitioned thread pool ------ + stats.setCurrentPhase(2, "Phase 2: Processing Relations & H3"); - // Phase 3: Process Relations (separate iterator pass is fine since PBF is local) - try (InputStream is = Files.newInputStream(Paths.get(pbfPath))) { - PbfIterator relIter = new PbfIterator(is, false); + if (!stubs.isEmpty()) { + ExecutorService executor = Executors.newFixedThreadPool(threads); + List> futures = new ArrayList<>(); + int partitionSize = (stubs.size() + threads - 1) / threads; - int threads = paikkaConfiguration.getImportConfiguration().getThreads(); - ExecutorService executor = Executors.newFixedThreadPool(threads); - BlockingQueue> queue = new LinkedBlockingQueue<>(100); - List POISON_PILL = List.of(); + for (int t = 0; t < threads; t++) { + int from = t * partitionSize; + int to = Math.min(from + partitionSize, stubs.size()); + if (from >= to) break; - // Producer thread - Thread producer = new Thread(() -> { - try { - List batch = new ArrayList<>(100); - while (relIter.hasNext()) { - EntityContainer c = relIter.next(); - if (c.getType() == EntityType.Relation) { - OsmRelation r = (OsmRelation) c.getEntity(); - try { - nameStreamer.processEntity(r, "R"); - } catch (IOException e) { - logger.warn("Failed to stream name for relation ID: {}", r.getId(), e); - } - if (isAdministrativeBoundary(r)) { - batch.add(buildRelationStub(r)); - if (batch.size() >= 100) { - queue.put(batch); - batch = new ArrayList<>(100); - } - } - } - } - if (!batch.isEmpty()) { - queue.put(batch); - } - } catch (Exception e) { - stats.recordError(BoundaryImportStatistics.Stage.PROCESSING_RELATIONS, BoundaryImportStatistics.Kind.READ, null, "producer-thread", e); - } finally { - for (int i = 0; i < threads; i++) { + List partition = stubs.subList(from, to); + final int threadIndex = t; + + futures.add(executor.submit(() -> { try { - queue.put(POISON_PILL); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - } - }); - - producer.start(); - - // Consumer threads - List> futures = new ArrayList<>(); - for (int i = 0; i < threads; i++) { - futures.add(executor.submit(() -> { - try (WriteOptions wo = new WriteOptions().setDisableWAL(true)) { - while (true) { - List batch = queue.take(); - if (batch == POISON_PILL) break; - - for (RelationStub stub : batch) { - if (stub.adminLevel() <= 3) { - logger.debug("Processing relation OSM ID: {} [Admin Level: {}]", stub.osmId(), stub.adminLevel()); - } - try { - Geometry geom = buildMultiPolygon(stub, nodeCache, wayCache); - if (geom == null || geom.isEmpty()) { - continue; + try (RocksDB threadH3 = RocksDB.open(threadH3Opts, threadH3Paths[threadIndex].toString()); + WriteOptions wo = new WriteOptions().setDisableWAL(true)) { + for (RelationStub stub : partition) { + if (stub.adminLevel() <= 3) { + logger.debug("Processing relation OSM ID: {} [Admin Level: {}]", stub.osmId(), stub.adminLevel()); } - - // Repair invalid geometries using buffer(0) - if (!geom.isValid()) { - logger.debug("Relation OSM ID: {} [Admin Level: {}] Geometry is invalid, attempting repair", stub.osmId(), stub.adminLevel()); - geom = geom.buffer(0); - if (geom == null || geom.isEmpty() || !geom.isValid()) { - logger.error("Relation OSM ID: {} [Admin Level: {}] Geometry repair failed", stub.osmId(), stub.adminLevel()); + try { + Geometry geom = buildMultiPolygon(stub, nodeCache, wayCache); + if (geom == null || geom.isEmpty()) { continue; } - } - // Simplify first to reduce H3 cell count, then buffer - Geometry simplified = geometrySimplificationService.simplifyByAdminLevel(geom, stub.adminLevel()); - if (simplified == null || simplified.isEmpty()) { - simplified = geom; - } - // Buffer to include border-touching cells - Geometry buffered = simplified.buffer(BUFFER_DISTANCE); - if (stub.adminLevel() <= 3) { - logger.debug("Simplified Geometry: {} points for OSM ID: {}", simplified.getNumPoints(), stub.osmId()); - } - - int resolution = getResolutionForAdminLevel(stub.adminLevel()); - - // ---- H3 Polyfill ---- - AtomicLong cellCount = new AtomicLong(0); - long startTime = System.currentTimeMillis(); - processCellsH3Stream(buffered, stub.osmId(), wo, tmpH3ToOsm, cellCount, resolution); - if (stub.adminLevel() <= 3) { - logger.debug("H3 Polyfill (Res {}) took {}ms for OSM ID: {}", resolution, System.currentTimeMillis() - startTime, stub.osmId()); - } - if (cellCount.get() == 0) continue; - stats.incrementRelationsProcessed(); - stats.addH3CellsGenerated((int) cellCount.get()); + // Repair invalid geometries using buffer(0) + if (!geom.isValid()) { + logger.debug("Relation OSM ID: {} [Admin Level: {}] Geometry is invalid, attempting repair", stub.osmId(), stub.adminLevel()); + geom = geom.buffer(0); + if (geom == null || geom.isEmpty() || !geom.isValid()) { + logger.error("Relation OSM ID: {} [Admin Level: {}] Geometry repair failed", stub.osmId(), stub.adminLevel()); + continue; + } + } + // Simplify first to reduce H3 cell count, then buffer + Geometry simplified = geometrySimplificationService.simplifyByAdminLevel(geom, stub.adminLevel()); + if (simplified == null || simplified.isEmpty()) { + simplified = geom; + } + // Buffer to include border-touching cells + Geometry buffered = simplified.buffer(BUFFER_DISTANCE); + if (stub.adminLevel() <= 3) { + logger.debug("Simplified Geometry: {} points for OSM ID: {}", simplified.getNumPoints(), stub.osmId()); + } - startTime = System.currentTimeMillis(); - tmpRegionMeta.put(wo, longToBytes(stub.osmId()), intToBytes((int) cellCount.get())); - if (stub.adminLevel() <= 3) { - logger.debug("H3 Cells written to tmpRegionMeta in {}ms for OSM ID: {}", System.currentTimeMillis() - startTime, stub.osmId()); - } - startTime = System.currentTimeMillis(); - byte[] wkb = new WKBWriter().write(simplified); - tmpRegionGeom.put(wo, longToBytes(stub.osmId()), wkb); - if (stub.adminLevel() <= 3) { - logger.debug("WKB written to tmpRegionGeom in {}ms for OSM ID: {}", System.currentTimeMillis() - startTime, stub.osmId()); + int resolution = getResolutionForAdminLevel(stub.adminLevel()); + + // ---- H3 Polyfill ---- + AtomicLong cellCount = new AtomicLong(0); + long startTime = System.currentTimeMillis(); + processCellsH3Stream(buffered, stub.osmId(), wo, threadH3, cellCount, resolution); + if (stub.adminLevel() <= 3) { + logger.debug("H3 Polyfill (Res {}) took {}ms for OSM ID: {}", resolution, System.currentTimeMillis() - startTime, stub.osmId()); + } + long totalCells = cellCount.get(); + if (totalCells == 0) continue; + + stats.incrementRelationsProcessed(); + stats.addH3CellsGenerated(totalCells); + + startTime = System.currentTimeMillis(); + tmpRegionMeta.put(wo, longToBytes(stub.osmId()), cellMetaToBytes(totalCells, resolution)); + if (stub.adminLevel() <= 3) { + logger.debug("H3 Cells written to tmpRegionMeta in {}ms for OSM ID: {}", System.currentTimeMillis() - startTime, stub.osmId()); + } + startTime = System.currentTimeMillis(); + byte[] wkb = new WKBWriter().write(simplified); + tmpRegionGeom.put(wo, longToBytes(stub.osmId()), wkb); + if (stub.adminLevel() <= 3) { + logger.debug("WKB written to tmpRegionGeom in {}ms for OSM ID: {}", System.currentTimeMillis() - startTime, stub.osmId()); + } + } catch (Exception e) { + stats.recordError(BoundaryImportStatistics.Stage.PROCESSING_RELATIONS, BoundaryImportStatistics.Kind.GEOMETRY, stub.osmId(), "process-relation", e); } - } catch (Exception e) { - stats.recordError(BoundaryImportStatistics.Stage.PROCESSING_RELATIONS, BoundaryImportStatistics.Kind.GEOMETRY, stub.osmId(), "process-relation", e); } } + } catch (RocksDBException e) { + throw new RuntimeException("Failed to open per-thread H3 DB", e); } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - })); - } + })); + } - // Wait for consumers to finish - for (Future f : futures) { - f.get(); + for (Future f : futures) { + f.get(); + } + executor.shutdown(); + executor.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS); + + // Merge per-thread H3 DBs into the final h3_to_osm + stats.setCurrentPhase(3, "3.1: Merging H3 thread DBs"); + for (int t = 0; t < threads; t++) { + if (Files.exists(threadH3Paths[t])) { + try (RocksDB threadDb = RocksDB.open(cacheOpts, threadH3Paths[t].toString())) { + copyH3Db(threadDb, h3ToOsm); + } + cleanup(threadH3Paths[t]); + } + } } - executor.shutdown(); - executor.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS); - producer.join(); } - } - stats.setCurrentPhase(3, "3.1: Compacting Final Databases"); - // Final Step: Copy from temporary DBs to final DBs in sorted order - copyDb(tmpRegionMeta, regionMeta); - copyDb(tmpRegionGeom, regionGeom); - copyH3Db(tmpH3ToOsm, h3ToOsm); + stats.setCurrentPhase(3, "3.2: Compacting Final Databases"); + // Copy from temporary DBs to final DBs in sorted order + copyDb(tmpRegionMeta, regionMeta); + copyDb(tmpRegionGeom, regionGeom); - // Compact finals - h3ToOsm.compactRange(); - regionMeta.compactRange(); - regionGeom.compactRange(); + // Compact finals + h3ToOsm.compactRange(); + regionMeta.compactRange(); + regionGeom.compactRange(); + } } + stats.setOutputSizes( + dirSize(h3ToOsmPath), + dirSize(regionMetaPath), + dirSize(regionGeomPath), + fileSize(nameSql) + ); + stats.stop(); stats.setTotalTime(System.currentTimeMillis() - stats.getStartTime()); stats.printFinalStatistics(); @@ -501,9 +499,11 @@ private int getResolutionForAdminLevel(int adminLevel) { /** * Converts a JTS Geometry to H3 cells at the specified resolution. - * Uses h3.polygonToCellsStream with LatLng vertices. Multipolygons are expanded. + * Uses h3.polygonToCells with LatLng vertices. Multipolygons are expanded. + * Each thread has its own RocksDB instance (passed via threadH3ToOsm) so no + * synchronization is needed on writes. */ - private void processCellsH3Stream(Geometry geom, long osmId, WriteOptions wo, RocksDB tmpH3ToOsm, AtomicLong cellCount, int resolution) { + private void processCellsH3Stream(Geometry geom, long osmId, WriteOptions wo, RocksDB threadH3ToOsm, AtomicLong cellCount, int resolution) { int num = geom.getNumGeometries(); for (int i = 0; i < num; i++) { Geometry part = geom.getGeometryN(i); @@ -513,49 +513,45 @@ private void processCellsH3Stream(Geometry geom, long osmId, WriteOptions wo, Ro for (int h = 0; h < poly.getNumInteriorRing(); h++) { holes.add(toLatLng(poly.getInteriorRingN(h).getCoordinates())); } - try { - List batch = new ArrayList<>(5_000); // Reduced batch size to prevent OOM - h3.polygonToCells(outer, holes, resolution).forEach(cell -> { - batch.add(cell); - if (batch.size() >= 5_000) { - try { - processH3Batch(batch, osmId, wo, tmpH3ToOsm, cellCount); - } catch (RocksDBException e) { - throw new RuntimeException(e); - } - batch.clear(); - } - }); - if (!batch.isEmpty()) { - processH3Batch(batch, osmId, wo, tmpH3ToOsm, cellCount); + List batch = new ArrayList<>(5_000); + h3.polygonToCells(outer, holes, resolution).forEach(cell -> { + batch.add(cell); + if (batch.size() >= 5_000) { + processH3Batch(batch, osmId, wo, threadH3ToOsm, cellCount); + batch.clear(); } - } catch (Exception e) { - stats.recordError(BoundaryImportStatistics.Stage.PROCESSING_RELATIONS, BoundaryImportStatistics.Kind.GEOMETRY, osmId, "processCellsH3Stream", e); + }); + if (!batch.isEmpty()) { + processH3Batch(batch, osmId, wo, threadH3ToOsm, cellCount); } } } - private void processH3Batch(List cells, long osmId, WriteOptions wo, RocksDB tmpH3ToOsm, AtomicLong cellCount) throws RocksDBException { + /** + * Writes H3 cell -> OSM ID associations to a thread-local RocksDB. + * No synchronization needed since each thread owns its RocksDB instance. + */ + private void processH3Batch(List cells, long osmId, WriteOptions wo, RocksDB h3Db, AtomicLong cellCount) { List keys = new ArrayList<>(cells.size()); for (long cell : cells) { keys.add(longToBytes(cell)); } - - // Synchronize to prevent race conditions when multiple threads update the same H3 cell - synchronized (tmpH3ToOsm) { - List existingValues = tmpH3ToOsm.multiGetAsList(keys); + try { + List existingValues = h3Db.multiGetAsList(keys); try (WriteBatch writeBatch = new WriteBatch()) { for (int i = 0; i < cells.size(); i++) { cellCount.incrementAndGet(); - byte[] key = keys.get(i); byte[] existing = existingValues.get(i); byte[] updated = appendOsmIdToArray(existing, osmId); - writeBatch.put(key, updated); + writeBatch.put(keys.get(i), updated); } - tmpH3ToOsm.write(wo, writeBatch); + h3Db.write(wo, writeBatch); } + } catch (RocksDBException e) { + throw new RuntimeException(e); } } + private List toLatLng(Coordinate[] coords) { List list = new ArrayList<>(coords.length); for (Coordinate c : coords) { @@ -583,8 +579,11 @@ private long[] bytesToLongArray(byte[] b) { return arr; } - private byte[] intToBytes(int v) { - return ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(v).array(); + private byte[] cellMetaToBytes(long cellCount, int resolution) { + return ByteBuffer.allocate(12).order(ByteOrder.BIG_ENDIAN) + .putLong(cellCount) + .putInt(resolution) + .array(); } /** @@ -661,4 +660,29 @@ private void cleanup(Path p) { } } } + + private long dirSize(Path dir) { + try { + return Files.walk(dir) + .filter(Files::isRegularFile) + .mapToLong(p -> { + try { + return Files.size(p); + } catch (IOException e) { + return 0L; + } + }) + .sum(); + } catch (IOException e) { + return 0L; + } + } + + private long fileSize(Path file) { + try { + return Files.exists(file) ? Files.size(file) : 0L; + } catch (IOException e) { + return 0L; + } + } } diff --git a/src/test/java/com/dedicatedcode/paikka/service/importer/StandaloneBoundaryImporterTest.java b/src/test/java/com/dedicatedcode/paikka/service/importer/StandaloneBoundaryImporterTest.java new file mode 100644 index 0000000..f3d1ae2 --- /dev/null +++ b/src/test/java/com/dedicatedcode/paikka/service/importer/StandaloneBoundaryImporterTest.java @@ -0,0 +1,325 @@ +/* + * This file is part of paikka. + * + * Paikka is free software: you can redistribute it and/or + * modify it under the terms of the GNU Affero General Public License + * as published by the Free Software Foundation, either version 3 or + * any later version. + * + * Paikka is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied + * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * You should have received a copy of the GNU Affero General Public License + * along with Paikka. If not, see . + */ + +package com.dedicatedcode.paikka.service.importer; + +import com.dedicatedcode.paikka.config.PaikkaConfiguration; +import org.junit.jupiter.api.*; +import org.rocksdb.Options; +import org.rocksdb.RocksDB; +import org.rocksdb.RocksDBException; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Integration tests for StandaloneBoundaryImporter. + * Uses the schleswig-holstein-boundaries.osm.pbf test file to verify + * the three RocksDB output databases and name streaming. + */ +class StandaloneBoundaryImporterTest { + + private static Path tempOutputDir; + private static Path tempPbfFile; + + @BeforeAll + static void setUp() throws Exception { + tempOutputDir = Files.createTempDirectory("paikka-boundary-test"); + tempPbfFile = Files.createTempFile("paikka-boundary-test", ".pbf"); + + try (InputStream is = StandaloneBoundaryImporter.class.getClassLoader() + .getResourceAsStream("schleswig-holstein-boundaries.osm.pbf")) { + assertNotNull(is, "schleswig-holstein-boundaries.osm.pbf not found in test resources"); + Files.copy(is, tempPbfFile, StandardCopyOption.REPLACE_EXISTING); + } + + PaikkaConfiguration config = new PaikkaConfiguration(); + + PaikkaConfiguration.ImportConfiguration importCfg = new PaikkaConfiguration.ImportConfiguration(); + importCfg.setThreads(4); + config.setImportConfiguration(importCfg); + + PaikkaConfiguration.SimplificationConfiguration simplCfg = new PaikkaConfiguration.SimplificationConfiguration(); + simplCfg.setContinentTolerance(0.005); + simplCfg.setCountryTolerance(0.00045); + simplCfg.setStateTolerance(0.00009); + simplCfg.setPoiTolerance(0.000018); + simplCfg.setDefaultTolerance(0.000045); + config.setSimplificationConfiguration(simplCfg); + + GeometrySimplificationService simplService = new GeometrySimplificationService(config); + StandaloneBoundaryImporter importer = new StandaloneBoundaryImporter(simplService, config); + importer.importBoundaries(Collections.singletonList(tempPbfFile.toString()), tempOutputDir.toString()); + } + + @AfterAll + static void tearDown() { + if (tempOutputDir != null && Files.exists(tempOutputDir)) { + deleteDirectory(tempOutputDir.toFile()); + } + if (tempPbfFile != null && Files.exists(tempPbfFile)) { + tempPbfFile.toFile().delete(); + } + } + + @Test + void testOutputDatabasesExist() { + assertTrue(Files.exists(tempOutputDir.resolve("h3_to_osm")), "h3_to_osm directory should exist"); + assertTrue(Files.exists(tempOutputDir.resolve("region_metadata")), "region_metadata directory should exist"); + assertTrue(Files.exists(tempOutputDir.resolve("region_geometry")), "region_geometry directory should exist"); + assertTrue(Files.exists(tempOutputDir.resolve("osm_names.tsv")), "osm_names.tsv should exist"); + } + + @Test + void testH3ToOsmHasEntries() throws RocksDBException { + Path dbPath = tempOutputDir.resolve("h3_to_osm"); + try (Options opts = new Options().setCreateIfMissing(false); + RocksDB db = RocksDB.open(opts, dbPath.toString())) { + + var it = db.newIterator(); + it.seekToFirst(); + assertTrue(it.isValid(), "h3_to_osm should have at least one entry"); + + byte[] key = it.key(); + assertEquals(8, key.length, "H3 cell key should be 8 bytes"); + + byte[] val = it.value(); + assertTrue(val.length >= 8, "H3 value should be at least 8 bytes"); + assertEquals(0, val.length % 8, "H3 value should be a multiple of 8 bytes"); + + int count = 0; + it.seekToFirst(); + while (it.isValid()) { + count++; + it.next(); + } + System.out.println("Total h3_to_osm entries: " + count); + assertTrue(count > 100, "Should have more than 100 H3 cell entries, got: " + count); + } + } + + @Test + void testRegionMetadataFormat() throws RocksDBException { + Path dbPath = tempOutputDir.resolve("region_metadata"); + try (Options opts = new Options().setCreateIfMissing(false); + RocksDB db = RocksDB.open(opts, dbPath.toString())) { + + var it = db.newIterator(); + it.seekToFirst(); + assertTrue(it.isValid(), "region_metadata should have entries"); + + byte[] key = it.key(); + assertEquals(8, key.length, "Region metadata key should be 8 bytes (OSM ID)"); + + byte[] val = it.value(); + assertEquals(12, val.length, "Value should be 12 bytes (8-byte cell count + 4-byte resolution)"); + + ByteBuffer bb = ByteBuffer.wrap(val).order(ByteOrder.BIG_ENDIAN); + long cellCount = bb.getLong(); + int resolution = bb.getInt(); + + assertTrue(cellCount > 0, "Cell count should be positive, got: " + cellCount); + assertTrue(resolution >= 4 && resolution <= 9, + "Resolution should be between 4 and 9, got: " + resolution); + + int count = 0; + it.seekToFirst(); + while (it.isValid()) { + count++; + it.next(); + } + System.out.println("Total region_metadata entries: " + count); + assertTrue(count >= 10, "Should have at least 10 boundaries, got: " + count); + } + } + + @Test + void testAllRegionMetadataEntriesHaveValidResolution() throws RocksDBException { + Path dbPath = tempOutputDir.resolve("region_metadata"); + try (Options opts = new Options().setCreateIfMissing(false); + RocksDB db = RocksDB.open(opts, dbPath.toString())) { + + var it = db.newIterator(); + it.seekToFirst(); + int checked = 0; + while (it.isValid()) { + byte[] val = it.value(); + assertEquals(12, val.length, + "Every region_metadata entry should be 12 bytes"); + + ByteBuffer bb = ByteBuffer.wrap(val).order(ByteOrder.BIG_ENDIAN); + long cellCount = bb.getLong(); + int resolution = bb.getInt(); + + assertTrue(cellCount > 0, + "Cell count should be positive for entry " + checked); + assertTrue(resolution >= 4 && resolution <= 9, + "Resolution should be 4-9 for entry " + checked + ", got: " + resolution); + + checked++; + it.next(); + } + System.out.println("Verified " + checked + " region_metadata entries"); + assertTrue(checked >= 10, "Should verify at least 10 entries, got: " + checked); + } + } + + @Test + void testRegionGeometryHasEntries() throws RocksDBException { + Path dbPath = tempOutputDir.resolve("region_geometry"); + try (Options opts = new Options().setCreateIfMissing(false); + RocksDB db = RocksDB.open(opts, dbPath.toString())) { + + var it = db.newIterator(); + it.seekToFirst(); + assertTrue(it.isValid(), "region_geometry should have entries"); + + byte[] key = it.key(); + assertEquals(8, key.length, "Region geometry key should be 8 bytes"); + + byte[] val = it.value(); + assertTrue(val.length > 0, "WKB value should not be empty"); + + int count = 0; + it.seekToFirst(); + while (it.isValid()) { + count++; + it.next(); + } + System.out.println("Total region_geometry entries: " + count); + assertTrue(count >= 10, "Should have at least 10 geometries, got: " + count); + } + } + + @Test + void testOsmNamesFileNotEmpty() throws IOException { + Path namesPath = tempOutputDir.resolve("osm_names.tsv"); + assertTrue(Files.size(namesPath) > 0, "osm_names.tsv should not be empty"); + + String firstLine = Files.readAllLines(namesPath).getFirst(); + assertTrue(firstLine.contains("\t"), "osm_names.tsv should be tab-separated"); + System.out.println("osm_names.tsv first line: " + firstLine); + } + + @Test + void testH3CellsMapToValidOsmIds() throws RocksDBException { + Set metadataOsmIds = new HashSet<>(); + Path metaDbPath = tempOutputDir.resolve("region_metadata"); + + try (Options opts = new Options().setCreateIfMissing(false); + RocksDB db = RocksDB.open(opts, metaDbPath.toString())) { + + var it = db.newIterator(); + it.seekToFirst(); + while (it.isValid()) { + long osmId = ByteBuffer.wrap(it.key()).order(ByteOrder.BIG_ENDIAN).getLong(); + metadataOsmIds.add(osmId); + it.next(); + } + } + assertFalse(metadataOsmIds.isEmpty(), "Should have metadata entries to cross-reference"); + + Path h3DbPath = tempOutputDir.resolve("h3_to_osm"); + try (Options opts = new Options().setCreateIfMissing(false); + RocksDB db = RocksDB.open(opts, h3DbPath.toString())) { + + var it = db.newIterator(); + it.seekToFirst(); + boolean foundMatch = false; + int checked = 0; + while (it.isValid() && checked < 100) { + byte[] val = it.value(); + for (int i = 0; i < val.length; i += 8) { + long osmId = ByteBuffer.wrap(val, i, 8).order(ByteOrder.BIG_ENDIAN).getLong(); + if (metadataOsmIds.contains(osmId)) { + foundMatch = true; + break; + } + } + if (foundMatch) break; + it.next(); + checked++; + } + assertTrue(foundMatch, + "H3 cells should reference OSM IDs present in region_metadata"); + } + } + + @Test + void testRegionGeometryMatchesMetadata() throws RocksDBException { + Set metadataOsmIds = new HashSet<>(); + Path metaDbPath = tempOutputDir.resolve("region_metadata"); + + try (Options opts = new Options().setCreateIfMissing(false); + RocksDB db = RocksDB.open(opts, metaDbPath.toString())) { + + var it = db.newIterator(); + it.seekToFirst(); + while (it.isValid()) { + metadataOsmIds.add( + ByteBuffer.wrap(it.key()).order(ByteOrder.BIG_ENDIAN).getLong()); + it.next(); + } + } + + Set geometryOsmIds = new HashSet<>(); + Path geomDbPath = tempOutputDir.resolve("region_geometry"); + + try (Options opts = new Options().setCreateIfMissing(false); + RocksDB db = RocksDB.open(opts, geomDbPath.toString())) { + + var it = db.newIterator(); + it.seekToFirst(); + while (it.isValid()) { + geometryOsmIds.add( + ByteBuffer.wrap(it.key()).order(ByteOrder.BIG_ENDIAN).getLong()); + it.next(); + } + } + + assertEquals(metadataOsmIds, geometryOsmIds, + "Same OSM IDs should exist in region_metadata and region_geometry"); + } + + @Test + void testNoTemporaryFilesLeaked() { + Path tmpDir = tempOutputDir.resolve("tmp"); + assertFalse(Files.exists(tmpDir), + "Temporary directory should be cleaned up after import"); + } + + private static void deleteDirectory(java.io.File dir) { + if (dir.isDirectory()) { + java.io.File[] children = dir.listFiles(); + if (children != null) { + for (java.io.File child : children) { + deleteDirectory(child); + } + } + } + dir.delete(); + } +} diff --git a/src/test/resources/schleswig-holstein-boundaries.osm.pbf b/src/test/resources/schleswig-holstein-boundaries.osm.pbf new file mode 100644 index 0000000..12c9ab1 Binary files /dev/null and b/src/test/resources/schleswig-holstein-boundaries.osm.pbf differ