From 7fb53ba134949e2420b1139b01107f3cd54d5382 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Fri, 25 Sep 2026 22:14:21 -0600 Subject: [PATCH 1/2] Reduce allocations when decoding scalar and numeric results --- benchmark/decoding.jl | 73 ++++++++++++++++++++++++++++++++ src/api/API.jl | 4 +- src/api/types.jl | 78 +++++++++++++++++++++++----------- test/decimals.jl | 55 ++++++++++++++++++++++++ test/result_decoding.jl | 93 +++++++++++++++++++++++++++++++++++++++++ test/runtests.jl | 2 + 6 files changed, 279 insertions(+), 26 deletions(-) create mode 100644 benchmark/decoding.jl create mode 100644 test/result_decoding.jl diff --git a/benchmark/decoding.jl b/benchmark/decoding.jl new file mode 100644 index 0000000..d70674b --- /dev/null +++ b/benchmark/decoding.jl @@ -0,0 +1,73 @@ +# Run in an environment containing this checkout and BenchmarkTools. An +# optional port enables prepared-query measurements against a disposable +# local PostgreSQL server with a trust-authenticated postgres account: +# julia --project=/path/to/env benchmark/decoding.jl [port] +using Postgres, BenchmarkTools +using Postgres: DBInterface, StructUtils, Parsers, Tables +using Postgres.API: DataDecimals + +const API = Postgres.API +const STYLE = Postgres.PostgresStyle() +const BENCH_ROW = NamedTuple{(:id, :small, :big, :ratio, :flag), Tuple{Int32, Int16, Int64, Float64, Bool}} + +function wire_row(values, oids; names=[Symbol("c", i) for i in eachindex(values)]) + io = IOBuffer() + write(io, hton(Int16(length(values)))) + for value in values + if value === nothing + write(io, hton(Int32(-1))) + else + write(io, hton(Int32(sizeof(value))), value) + end + end + return API.DataRow(take!(io), names, oids, Dict(API.DEFAULT_TYPE_REGISTRY)) +end + +function untyped(row) + data = Vector{Any}(undef, length(row.names)) + types = Type[Any for _ in row.names] + StructUtils.applyeach(STYLE, Postgres.RowClosure(data, types, 1), row) + return data +end +typed(row) = StructUtils.make(BENCH_ROW, row, STYLE) + +function report(label, benchmark) + trial = run(benchmark; samples=200, seconds=2) + estimate = median(trial) + times = sort(trial.times) + println(label, " ns=", estimate.time, " bytes=", estimate.memory, " allocs=", estimate.allocs, + " min_ns=", first(times), " p90_ns=", times[cld(9length(times), 10)], " max_ns=", last(times)) +end + +function main() + println("Julia ", VERSION, " Postgres=", pathof(Postgres), " Parsers=", pkgversion(Parsers), " StructUtils=", pkgversion(StructUtils)) + row = wire_row(["123456", "-12345", "9223372036854775806", "12345.6789012345", "t"], [23,21,20,701,16]; names=collect(fieldnames(BENCH_ROW))) + @assert typed(row) == (id=123456, small=-12345, big=9223372036854775806, ratio=12345.6789012345, flag=true) + report("untyped_scalar_row", @benchmarkable untyped($row) evals=100) + report("typed_scalar_row", @benchmarkable typed($row) evals=100) + for (label, oid, value) in (("int64",20,"9223372036854775806"), ("float64",701,"12345.6789012345"), ("bool",16,"t"), ("numeric",1700,"123456789.0123400"), ("int_array",1007,"{1,2,3,4,5,6,7,8,9}"), ("json",3802,"{\"a\":123,\"b\":[1,2,3]}")) + row = wire_row(fill(value, 16), fill(oid,16)) + report("untyped_16_"*label, @benchmarkable untyped($row) evals=100) + end + length(ARGS) == 0 && return + conn = DBInterface.connect(Postgres.Connection,"127.0.0.1","postgres",nothing;dbname="postgres",port=parse(Int,ARGS[1]),sslmode="disable") + try + sql = "SELECT i::int4 AS id, (i % 30000)::int2 AS small, (i::int8 * 1000000000000)::int8 AS big, (i::float8 / 7) AS ratio, i % 2 = 0 AS flag FROM generate_series(1,10000) i" + stmt = DBInterface.prepare(conn,sql) + @assert length(DBInterface.execute(stmt)) == 10000 + @assert length(DBInterface.execute(stmt,nothing,Vector{BENCH_ROW})) == 10000 + report("live_untyped_10k", @benchmarkable DBInterface.execute($stmt) evals=1) + report("live_typed_10k", @benchmarkable DBInterface.execute($stmt,nothing,Vector{BENCH_ROW}) evals=1) + DBInterface.close!(stmt) + dtype = NamedTuple{(:amount,), Tuple{API.PGDecimal}} + stmt = DBInterface.prepare(conn, "SELECT (i::numeric / 100)::numeric(20,4) AS amount FROM generate_series(1,10000) i") + @assert length(DBInterface.execute(stmt)) == 10000 + @assert length(DBInterface.execute(stmt,nothing,Vector{dtype})) == 10000 + report("live_untyped_decimal_10k", @benchmarkable DBInterface.execute($stmt) evals=1) + report("live_typed_decimal_10k", @benchmarkable DBInterface.execute($stmt,nothing,Vector{$dtype}) evals=1) + DBInterface.close!(stmt) + finally + DBInterface.close!(conn) + end +end +main() diff --git a/src/api/API.jl b/src/api/API.jl index 0adba4b..4d82165 100644 --- a/src/api/API.jl +++ b/src/api/API.jl @@ -1004,9 +1004,9 @@ function StructUtils.applyeach(::AbstractPostgresStyle, f, dr::DataRow) f(dr.names[i], nothing) else (len >= 0 && pos + len - 1 <= nbuf) || throw(Error("truncated DataRow message from server")) - str = unsafe_string(pointer(buf, pos), len) + bytes = @view buf[pos:pos+len-1] pos += len - @inbounds applycast(f, dr.names[i], dr.typeIds[i], str, dr.type_registry) + @inbounds applycast(f, dr.names[i], dr.typeIds[i], bytes, dr.type_registry) end end pos == nbuf + 1 || throw(Error("DataRow message has trailing bytes")) diff --git a/src/api/types.jl b/src/api/types.jl index 8a2628b..8341800 100644 --- a/src/api/types.jl +++ b/src/api/types.jl @@ -448,6 +448,16 @@ end end function parse_numeric(val::String, on_overflow::Symbol=:warn)::NumericValue + @static if pkgversion(Parsers) >= v"3" + # PostgreSQL emits fixed-point numeric text. The native decimal parser + # preserves its coefficient and scale without an intermediate BigInt. + # Longer coefficients and exponents retain the scale, bounds and error + # behavior of the fallback below. + if sizeof(val) <= 77 && !any(c -> c == 'e' || c == 'E', val) + value = Parsers.tryparse(PGDecimal, val) + value !== nothing && DataDecimals.scale(value) <= 16383 && return value + end + end num = parse_numeric_parts(val) if num !== nothing && num.scale <= 16383 && MIN_DECIMAL_COEFFICIENT <= num.coeff <= MAX_DECIMAL_COEFFICIENT return PGDecimal(DataDecimals.Int256(num.coeff), num.scale) @@ -460,6 +470,13 @@ function parse_numeric(val::String, on_overflow::Symbol=:warn)::NumericValue end function parse_decimal(::Type{T}, val::String) where {T<:DataDecimals.AbstractDecimal} + @static if pkgversion(Parsers) >= v"3" + if T <: Union{DataDecimals.Decimal, DataDecimals.DecimalValue} && + sizeof(val) <= 77 && !any(c -> c == 'e' || c == 'E', val) + value = Parsers.tryparse(T, val; rounding=DataDecimals.RoundExact) + value !== nothing && return value + end + end num = parse_numeric_parts(val) num === nothing && throw(InexactError(:parse_decimal, T, val)) # Preserve the requested scale for variable-scale values. Fixed-scale @@ -853,31 +870,29 @@ end return decode_bytea_escape(val) end -function parse_boolean(val::String) - (val == "t" || val == "1") && return true - (val == "f" || val == "0") && return false +function parse_boolean(val::Union{String, AbstractVector{UInt8}}) + bytes = val isa String ? codeunits(val) : val + if length(bytes) == 1 + (bytes[1] == UInt8('t') || bytes[1] == UInt8('1')) && return true + (bytes[1] == UInt8('f') || bytes[1] == UInt8('0')) && return false + end throw(PostgresInterfaceError("postgres value cannot be represented as Bool; select multi-bit bit(n) values as text")) end -function parse_value(typeId::Int, val::String, registry::Dict{Int, TypeInfo}) +# DataRow supplies contiguous, bounds-checked views. Copy only at the boundary +# where a parser or result needs an owned String. +const FieldBytes = SubArray{UInt8, 1, Vector{UInt8}, Tuple{UnitRange{Int}}, true} +_field_text(val::String) = val +_field_text(val::FieldBytes) = GC.@preserve val unsafe_string(pointer(val), length(val)) + +function parse_value(typeId::Int, val::Union{String, FieldBytes}, registry::Dict{Int, TypeInfo}) info = type_info(registry, typeId) if info.parser !== nothing - return info.parser(val, registry) + return info.parser(_field_text(val), registry) end T = info.julia_type if T == Bool return parse_boolean(val) - elseif T == Char - return pg_parse_char(val) - elseif T == PGTimestamp - return pg_parse_timestamp(val) - elseif T == DateTime - if typeId == 1184 - return parse_timestamptz(val) - end - return pg_parse_datetime(val) - elseif T == UUID - return UUID(val) elseif T == Int16 return Parsers.parse(Int16, val) elseif T == Int32 @@ -890,6 +905,21 @@ function parse_value(typeId::Int, val::String, registry::Dict{Int, TypeInfo}) return Parsers.parse(Float32, val) elseif T == Float64 return Parsers.parse(Float64, val) + end + # Text, lazy JSON and custom parsers own their bytes. Fixed-width scalar + # decoders above can read directly from the row's bounded byte view. + val = _field_text(val) + if T == Char + return pg_parse_char(val) + elseif T == PGTimestamp + return pg_parse_timestamp(val) + elseif T == DateTime + if typeId == 1184 + return parse_timestamptz(val) + end + return pg_parse_datetime(val) + elseif T == UUID + return UUID(val) elseif T == Date return pg_parse_date(val) elseif T == Time @@ -923,7 +953,7 @@ function parse_value(typeId::Int, val::String, registry::Dict{Int, TypeInfo}) return val end -@inline function applycast(f, name, typeId, val::String, registry::Dict{Int, TypeInfo}) +@inline function applycast(f, name, typeId, val, registry::Dict{Int, TypeInfo}) f(name, parse_value(typeId, val, registry)) return end @@ -931,8 +961,8 @@ end # Typed fields can require a wider range than the default OID mapping. Defer # decoding until StructUtils supplies the declared field type. Other targets, # including Any and registered composites, retain the OID parser behavior. -struct FieldValue - text::String +struct FieldValue{V} + text::V oid::Int registry::Dict{Int, TypeInfo} end @@ -940,32 +970,32 @@ end const WireFieldScalar = Union{Durations.Timestamp, DataDecimals.AbstractDecimal, DateTime} _wire_field(::Type{T}) where {T} = T <: Union{Missing, WireFieldScalar} _wire_field(::Type{<:AbstractVector{T}}) where {T} = T <: WireFieldScalar -_field_source(::Type{T}, v::FieldValue) where {T} = _wire_field(T) ? v.text : parse_value(v.oid, v.text, v.registry) +_field_source(::Type{T}, v::FieldValue) where {T} = _wire_field(T) ? _field_text(v.text) : parse_value(v.oid, v.text, v.registry) StructUtils.make(st::AbstractPostgresStyle, ::Type{T}, v::FieldValue) where {T} = StructUtils.make(st, T, _field_source(T, v)) StructUtils.make(st::AbstractPostgresStyle, ::Type{T}, v::FieldValue, tags) where {T} = StructUtils.make(st, T, _field_source(T, v), tags) @static if isdefined(StructUtils, :InterpClosure) && isdefined(StructUtils, :HotStructClosure) - @inline function applycast(f::Union{StructUtils.InterpClosure, StructUtils.HotStructClosure}, name, typeId, val::String, registry::Dict{Int, TypeInfo}) + @inline function applycast(f::Union{StructUtils.InterpClosure, StructUtils.HotStructClosure}, name, typeId, val, registry::Dict{Int, TypeInfo}) f(name, FieldValue(val, typeId, registry)) return end end @static if isdefined(StructUtils, :StructClosure) - @inline function applycast(f::StructUtils.StructClosure, name, typeId, val::String, registry::Dict{Int, TypeInfo}) + @inline function applycast(f::StructUtils.StructClosure, name, typeId, val, registry::Dict{Int, TypeInfo}) f(name, FieldValue(val, typeId, registry)) return end end @static if isdefined(StructUtils, :FieldSink) - @inline function applycast(f::StructUtils.FieldSink, name, typeId, val::String, registry::Dict{Int, TypeInfo}) + @inline function applycast(f::StructUtils.FieldSink, name, typeId, val, registry::Dict{Int, TypeInfo}) f(name, FieldValue(val, typeId, registry)) return end end -@inline function applycast(f::StructUtils.TupleClosure, name, typeId, val::String, registry::Dict{Int, TypeInfo}) +@inline function applycast(f::StructUtils.TupleClosure, name, typeId, val, registry::Dict{Int, TypeInfo}) f(name, FieldValue(val, typeId, registry)) return end diff --git a/test/decimals.jl b/test/decimals.jl index 084154b..90292a8 100644 --- a/test/decimals.jl +++ b/test/decimals.jl @@ -50,6 +50,61 @@ function test_decimals() @test Postgres.API.parse_array("{1.23,4.56}", DataDecimals.Decimal64{2}) == DataDecimals.Decimal64{2}.(["1.23", "4.56"]) @test Postgres._param(DataDecimals.Decimal64{4}("1.2300")) == "1.2300" end + @testset "Native decimal parsing preserves coefficient and scale" begin + texts = ["0", "-0", "+0", "-0.0000", "+0.0000", " +12.3400\t", "\u200312.3400\u2003", + ".5", "1.", "-.5", "+.5", "00012.3400", "0." * repeat("0", 100), + repeat("1", 76) * ".0000", "0." * repeat("0", 16382) * "1"] + for n in 76:79 + push!(texts, repeat("1", n), "-" * repeat("1", n), "0." * repeat("1", n), "0." * repeat("0", n)) + end + rng = MersenneTwister(0xdec0de) + for _ in 1:200 + digits = String(rand(rng, '0':'9', rand(rng, 1:80))) + split_at = rand(rng, 0:length(digits)) + push!(texts, string(rand(rng, ("", "+", "-")), digits[1:split_at], ".", digits[split_at+1:end])) + end + for text in texts + # BigInt arithmetic is an independent oracle for the bounded + # decimal scanner, including scale that trailing zeros preserve. + stripped = strip(text) + negative = startswith(stripped, "-") + unsigned = (negative || startswith(stripped, "+")) ? stripped[2:end] : stripped + parts = split(unsigned, '.'; limit=2) + scale = length(parts) == 2 ? length(parts[2]) : 0 + coeff = parse(BigInt, join(parts)) * (negative ? -1 : 1) + if typemin(DataDecimals.Int256) <= coeff <= typemax(DataDecimals.Int256) && scale <= 16383 + value = Postgres.API.parse_numeric(text, :error) + @test value isa WireDecimal + @test BigInt(DataDecimals.unscaled(value)) == coeff + @test DataDecimals.scale(value) == scale + typed = Postgres.API.parse_decimal(WireDecimal, text) + @test BigInt(DataDecimals.unscaled(typed)) == coeff + @test DataDecimals.scale(typed) == scale + else + @test_throws Postgres.PostgresInterfaceError Postgres.API.parse_numeric(text, :error) + @test_throws InexactError Postgres.API.parse_decimal(WireDecimal, text) + end + T = DataDecimals.Decimal64{2} + expected = try + T(coeff // big(10)^scale) + catch err + err + end + if expected isa Exception + @test_throws typeof(expected) Postgres.API.parse_decimal(T, text) + else + @test Postgres.API.parse_decimal(T, text) === expected + end + end + for text in ("", " ", "+", "-", ".", "1..2", "1,2", "1/2", "1x", "1\0", "1.2.3", "1e", "1e+") + @test_throws Exception Postgres.API.parse_numeric(text, :error) + @test_throws Exception Postgres.API.parse_decimal(WireDecimal, text) + end + for text in ("0e100001", "0e-100001", "1e100001", "1e-100001") + @test_throws Postgres.PostgresInterfaceError Postgres.API.parse_numeric(text, :error) + @test_throws Postgres.PostgresInterfaceError Postgres.API.parse_decimal(WireDecimal, text) + end + end end function test_decimal_roundtrips(conn) diff --git a/test/result_decoding.jl b/test/result_decoding.jl new file mode 100644 index 0000000..ccf9292 --- /dev/null +++ b/test/result_decoding.jl @@ -0,0 +1,93 @@ +function decoding_row(values, oids; names=[Symbol("c", i) for i in eachindex(values)]) + io = IOBuffer() + write(io, hton(Int16(length(values)))) + for value in values + if value === nothing + write(io, hton(Int32(-1))) + else + write(io, hton(Int32(sizeof(value))), value) + end + end + return Postgres.API.DataRow(take!(io), names, oids, Dict(Postgres.API.DEFAULT_TYPE_REGISTRY)) +end + +function decoding_values(row) + values = Any[] + StructUtils.applyeach(Postgres.PostgresStyle(), (name, value) -> push!(values, value), row) + return values +end + +struct DecodingStyle <: Postgres.AbstractPostgresStyle end +StructUtils.lift(::DecodingStyle, ::Type{Int64}, value::Int32) = Int64(value) + 1, nothing + +function test_result_decoding() + @testset "Bounded scalar decoding" begin + cases = ((21, Int16, ["-32768", "32767", "0", "+12", " 12 "]), + (23, Int32, ["-2147483648", "2147483647", "-0", "42"]), + (20, Int64, ["-9223372036854775808", "9223372036854775807", "0", "42"]), + (26, Cuint, ["0", "4294967295", "42"]), + (700, Float32, ["1.5", "-0.0", "NaN", "Infinity", "-Infinity", "1.1754944e-38"]), + (701, Float64, ["1.5", "-0.0", "NaN", "Infinity", "-Infinity", "5e-324", "1.7976931348623157e308"])) + for (oid, T, texts) in cases, text in texts + # Surround the field with other cells: a decoder must respect both + # ends of its span, including values ending in an exponent or sign. + row = decoding_row(["prefix α", text, "suffix"], [25, oid, 25]; names=[:before, :value, :after]) + expected = parse(T, text) + @test isequal(decoding_values(row), ["prefix α", expected, "suffix"]) + target = NamedTuple{(:before, :value, :after), Tuple{String, T, String}} + typed = StructUtils.make(target, row, Postgres.PostgresStyle()) + @test typed.value isa T + @test isequal(typed.value, expected) + @test typed.before == "prefix α" && typed.after == "suffix" + end + for (text, expected) in (("t", true), ("f", false), ("1", true), ("0", false)) + @test only(decoding_values(decoding_row([text], [16]))) === expected + end + for text in ("", "10", "true", "α") + @test_throws Postgres.PostgresInterfaceError decoding_values(decoding_row([text], [16])) + end + for text in ("", "1e", "+", "12x", "9223372036854775808") + row = decoding_row([text, "12"], [20, 20]) + expected_error = try + Postgres.Parsers.parse(Int64, text) + catch err + typeof(err) + end + @test expected_error <: Exception + @test_throws expected_error decoding_values(row) + end + @test isequal(decoding_values(decoding_row(["", nothing, ""], [25, 23, 25])), ["", nothing, ""]) + + row = decoding_row(["41"], [23]; names=[:value]) + target = NamedTuple{(:value,), Tuple{Int64}} + @test StructUtils.make(target, row, DecodingStyle()).value === Int64(42) + Postgres.API.register_type!(row.type_registry, 23, Int64; parser=(text::String, registry) -> parse(Int64, text) + 100) + @test only(decoding_values(row)) === Int64(141) + @test StructUtils.make(target, row, Postgres.PostgresStyle()).value === Int64(141) + end + + @testset "Decoded values own retained text" begin + row = decoding_row(["hello α🙂", "{\"value\":\"saved α\"}", "custom β", "{first,second}", raw"\x0001ff"], + [25, 3802, 999_999, 1009, 17]) + Postgres.API.register_type!(row.type_registry, 999_999, String; parser=(text::String, registry) -> text) + values = decoding_values(row) + # Result rows, custom parser values, and lazy JSON may outlive the + # buffered message and every later query on the connection. + fill!(row.buf, 0xff) + GC.gc() + @test values[1] == "hello α🙂" + @test JSON.parse(values[2])["value"] == "saved α" + @test values[3] == "custom β" + @test values[4] == ["first", "second"] + @test values[5] == [0x00, 0x01, 0xff] + + row = decoding_row(["hello α🙂", "{\"value\":\"saved β\"}", ""], [25, 3802, 25]; names=[:text, :json, :empty]) + target = NamedTuple{(:text, :json, :empty), Tuple{String, JSONType, String}} + values = StructUtils.make(target, row, Postgres.PostgresStyle()) + fill!(row.buf, 0xff) + GC.gc() + @test values.text == "hello α🙂" + @test JSON.parse(values.json)["value"] == "saved β" + @test values.empty == "" + end +end diff --git a/test/runtests.jl b/test/runtests.jl index 5b5fd86..cc750b7 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -494,6 +494,7 @@ end include("timestamps.jl") include("decimals.jl") +include("result_decoding.jl") include("gssapi.jl") include("isvalid_fake_server.jl") @@ -1013,6 +1014,7 @@ include("isvalid_fake_server.jl") end end + test_result_decoding() test_gssapi() test_isvalid_fake_server() test_isvalid_fragmentation() From 968aa79afdc8f905f672c8f0c7a54295e9603dc9 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Fri, 25 Sep 2026 22:28:23 -0600 Subject: [PATCH 2/2] Test registered temporal decoders through bounded fields --- test/result_decoding.jl | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/result_decoding.jl b/test/result_decoding.jl index ccf9292..f6022f5 100644 --- a/test/result_decoding.jl +++ b/test/result_decoding.jl @@ -66,6 +66,22 @@ function test_result_decoding() @test StructUtils.make(target, row, Postgres.PostgresStyle()).value === Int64(141) end + @testset "Registered temporal types without custom parsers" begin + cases = ((1114, Postgres.API.PGTimestamp, "2024-01-02 03:04:05.123456", Durations.Timestamp{Microsecond}(2024, 1, 2, 3, 4, 5, 123, 456)), + (1184, Postgres.API.PGTimestamp, "2024-01-02 03:04:05.123456+02:30", Durations.Timestamp{Microsecond}(2024, 1, 2, 0, 34, 5, 123, 456)), + (1114, DateTime, "2024-01-02 03:04:05.123", DateTime(2024, 1, 2, 3, 4, 5, 123)), + (1184, DateTime, "2024-01-02 03:04:05.123+02:30", DateTime(2024, 1, 2, 0, 34, 5, 123))) + for (oid, T, text, expected) in cases + row = decoding_row(["prefix", text, "suffix"], [25, oid, 25]; names=[:before, :value, :after]) + Postgres.API.register_type!(row.type_registry, oid, T) + @test decoding_values(row) == ["prefix", expected, "suffix"] + # An Any field must still use the registered decoder, including + # timezone conversion and the chosen timestamp resolution. + target = NamedTuple{(:before, :value, :after), Tuple{String, Any, String}} + @test StructUtils.make(target, row, Postgres.PostgresStyle()).value === expected + end + end + @testset "Decoded values own retained text" begin row = decoding_row(["hello α🙂", "{\"value\":\"saved α\"}", "custom β", "{first,second}", raw"\x0001ff"], [25, 3802, 999_999, 1009, 17])