public static void writeOutput(FeatureGroup features, WriteableTileArchive output, DiskBacked fileSize,
    TileArchiveMetadata tileArchiveMetadata, Path layerStatsPath, PlanetilerConfig config, Stats stats) {
    var timer = stats.startStage("archive");

    int chunksToRead = Math.max(1, features.chunksToRead());
    int readThreads = Math.min(config.featureReadThreads(), chunksToRead);
    int threads = config.threads();
    int processThreads = threads < 8 ? threads : (threads - readThreads);
    int tileWriteThreads = config.tileWriteThreads();

    // when using more than 1 read thread: (N read threads) -> (1 merge thread) -> ...
    // when using 1 read thread we just have: (1 read & merge thread) -> ...
    Worker readWorker = null;
    Iterable<FeatureGroup.TileFeatures> inputTiles;
    String secondStageName;
    if (readThreads == 1) {
      secondStageName = "read";
      inputTiles = features;
    } else {
      secondStageName = "merge";
      var reader = features.parallelIterator(readThreads);
      inputTiles = reader.result();
      readWorker = reader.readWorker();
    }

    TileArchiveWriter writer = new TileArchiveWriter(inputTiles, output, config, tileArchiveMetadata, stats);

    var pipeline = WorkerPipeline.start("archive", stats);

    // a larger tile queue size helps keep cores busy, but needs a lot of RAM
    // 5k works fine with 100GB of RAM, so adjust the queue size down from there
    // but no less than 100
    int queueSize = Math.max(
      100,
      (int) (5_000d * ProcessInfo.getMaxMemoryBytes() / 100_000_000_000d)
    );

    /*
     * To emit tiles in order, fork the input queue and send features to both the encoder and writer. The writer
     * waits on them to be encoded in the order they were received, and the encoder processes them in parallel.
     * One batch might take a long time to process, so make the queues very big to avoid idle encoding CPUs.
     *
     * Note:
     * In the future emitting tiles out order might be especially interesting when tileWriteThreads>1,
     * since when multiple threads/files are included there's no order that needs to be preserved.
     * So some of the restrictions could be lifted then.
     */
    WorkQueue<TileBatch> writerQueue = new WorkQueue<>("archive_writer_queue", queueSize, 1, stats);
    WorkQueue<TileBatch> layerStatsQueue = new WorkQueue<>("archive_layerstats_queue", queueSize, 1, stats);
    WorkerPipeline<TileBatch> encodeBranch = pipeline
      .<TileBatch>fromGenerator(secondStageName, next -> {
        try (writerQueue; layerStatsQueue) {
          var writerEnqueuer = writerQueue.threadLocalWriter();
          var statsEnqueuer = layerStatsQueue.threadLocalWriter();
          writer.readFeaturesAndBatch(batch -> {
            next.accept(batch);
            writerEnqueuer.accept(batch); // also send immediately to writer
            if (config.outputLayerStats()) {
              statsEnqueuer.accept(batch);
            }
          });
        }
        // use only 1 thread since readFeaturesAndBatch needs to be single-threaded
      }, 1)
      .addBuffer("reader_queue", queueSize)
      .sinkTo("encode", processThreads, writer::tileEncoderSink);

    // ensure to initialize the archive BEFORE starting to write any tiles
    output.initialize();

    // the tile writer will wait on the result of each batch to ensure tiles are written in order
    WorkerPipeline<TileBatch> writeBranch = pipeline.readFromQueue(writerQueue)
      .sinkTo("write", tileWriteThreads, writer::tileWriter);

    WorkerPipeline<TileBatch> layerStatsBranch = null;

    if (config.outputLayerStats()) {
      layerStatsBranch = pipeline.readFromQueue(layerStatsQueue)
        .sinkTo("stats", 1, tileStatsWriter(layerStatsPath));
    }

    var loggers = ProgressLoggers.create()
      .addRatePercentCounter("features", features.numFeaturesWritten(), writer.featuresProcessed, true)
      .addFileSize(features)
      .addRateCounter("tiles", writer::tilesEmitted)
      .addFileSize(fileSize)
      .newLine()
      .addProcessStats()
      .newLine();
    if (readWorker != null) {
      loggers.addThreadPoolStats("read", readWorker);
    }
    loggers.addPipelineStats(encodeBranch)
      .addPipelineStats(writeBranch);
    if (layerStatsBranch != null) {
      loggers.addPipelineStats(layerStatsBranch);
    }
    loggers.newLine()
      .add(writer::getLastTileLogDetails);

    final CompletableFuture<Void> tileWritersFuture = writeBranch.done();
    final CompletableFuture<Void> layerStatsFuture =
      layerStatsBranch == null ? CompletableFuture.completedFuture(null) : layerStatsBranch.done();
    final CompletableFuture<Void> archiveFinisher =
      CompletableFuture.allOf(tileWritersFuture, layerStatsFuture).thenRun(writer::finishArchive);

    var doneFuture = joinFutures(tileWritersFuture, layerStatsFuture, encodeBranch.done(), archiveFinisher);
    loggers.awaitAndLog(doneFuture, config.logInterval());
    writer.printTileStats();
    timer.stop();
  }
Reads all {@code features}, encodes them in parallel, and writes to {@code output}.
planetiler-core/src/main/java/com/onthegomap/planetiler/archive/TileArchiveWriter.java