From bfbe6fda52700eb3703a3147b6714749e0d0d0e1 Mon Sep 17 00:00:00 2001 From: Frerich Raabe Date: Sat, 16 May 2026 01:28:56 +0200 Subject: [PATCH 1/7] Fix Collectable implementation silently ignoring write errors The collector function in the Collectable implementation for LargeObject discarded the return value of LargeObject.write/2, causing write failures (e.g. :read_only, :not_found) to go unnoticed and data to be silently lost. Now raises a RuntimeError with a descriptive message when write fails. --- CHANGELOG.md | 4 ++++ lib/pg_large_objects/large_object.ex | 9 +++++++-- test/pg_large_objects/large_object_test.exs | 16 ++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9697787..4141bdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## v0.2.7 - Unreleased + + * Fix `Collectable` implementation silently ignoring write errors. + ## v0.2.6 - 2026-05-01 * Fix grammar and typos in the documentation diff --git a/lib/pg_large_objects/large_object.ex b/lib/pg_large_objects/large_object.ex index 2affbd8..40bca32 100644 --- a/lib/pg_large_objects/large_object.ex +++ b/lib/pg_large_objects/large_object.ex @@ -370,8 +370,13 @@ defimpl Collectable, for: PgLargeObjects.LargeObject do collector = fn lob, {:cont, data} -> - LargeObject.write(lob, data) - lob + case LargeObject.write(lob, data) do + :ok -> + lob + + {:error, reason} -> + raise "failed to write to large object: #{inspect(reason)}" + end lob, :done -> LargeObject.close(lob) diff --git a/test/pg_large_objects/large_object_test.exs b/test/pg_large_objects/large_object_test.exs index b190af3..35c3278 100644 --- a/test/pg_large_objects/large_object_test.exs +++ b/test/pg_large_objects/large_object_test.exs @@ -309,6 +309,22 @@ defmodule PgLargeObjects.LargeObjectTest do end end + describe "Collectable implementation" do + test "raises when writing to a read-only object" do + oid = put_large_object!("hello") + + TestRepo.transaction(fn -> + {:ok, lob} = LargeObject.open(TestRepo, oid, mode: :read) + + assert_raise RuntimeError, ~r/failed to write to large object/, fn -> + ["new data"] + |> Stream.into(lob) + |> Stream.run() + end + end) + end + end + defp with_object(data, opts \\ [], fun) do oid = put_large_object!(data) From 2a082fd1e04d6d75ae77a0dd6f47b79e5a6b9455 Mon Sep 17 00:00:00 2001 From: Frerich Raabe Date: Sat, 16 May 2026 01:29:29 +0200 Subject: [PATCH 2/7] Fix Collectable implementation silently ignoring close errors The collector function discarded the return value of LargeObject.close/1 on the :done signal. If close failed (e.g. object already closed or deleted), the error was swallowed silently. Now raises a RuntimeError with a descriptive message when close fails. --- CHANGELOG.md | 1 + lib/pg_large_objects/large_object.ex | 9 +++++++-- test/pg_large_objects/large_object_test.exs | 18 ++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4141bdd..ba7565d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## v0.2.7 - Unreleased * Fix `Collectable` implementation silently ignoring write errors. + * Fix `Collectable` implementation silently ignoring close errors. ## v0.2.6 - 2026-05-01 diff --git a/lib/pg_large_objects/large_object.ex b/lib/pg_large_objects/large_object.ex index 40bca32..57d7974 100644 --- a/lib/pg_large_objects/large_object.ex +++ b/lib/pg_large_objects/large_object.ex @@ -379,8 +379,13 @@ defimpl Collectable, for: PgLargeObjects.LargeObject do end lob, :done -> - LargeObject.close(lob) - lob + case LargeObject.close(lob) do + :ok -> + lob + + {:error, reason} -> + raise "failed to close large object: #{inspect(reason)}" + end _lob, :halt -> :ok diff --git a/test/pg_large_objects/large_object_test.exs b/test/pg_large_objects/large_object_test.exs index 35c3278..cd3cedf 100644 --- a/test/pg_large_objects/large_object_test.exs +++ b/test/pg_large_objects/large_object_test.exs @@ -323,6 +323,24 @@ defmodule PgLargeObjects.LargeObjectTest do end end) end + + test "raises when closing an already-closed object" do + oid = put_large_object!("hello") + + TestRepo.transaction(fn -> + {:ok, lob} = LargeObject.open(TestRepo, oid, mode: :write) + + # Close the fd manually so the Collectable's close on :done will fail + LargeObject.close(lob) + + assert_raise RuntimeError, ~r/failed to close large object/, fn -> + # Empty stream: no writes, only :done triggers close + [] + |> Stream.into(lob) + |> Stream.run() + end + end) + end end defp with_object(data, opts \\ [], fun) do From 7491558d47917290840b38745bfe631f64141a6a Mon Sep 17 00:00:00 2001 From: Frerich Raabe Date: Sat, 16 May 2026 01:30:55 +0200 Subject: [PATCH 3/7] Fix Enumerable reduce crashing with CaseClauseError on read errors The next_fun in Stream.resource only pattern-matched {:ok, ""} and {:ok, data} from read/2. If read returned {:error, reason} (e.g. object deleted mid-stream), it crashed with an unhelpful CaseClauseError. Now raises a descriptive RuntimeError on read failure. Also makes the after_fun resilient to close failures during cleanup. --- CHANGELOG.md | 1 + lib/pg_large_objects/large_object.ex | 7 ++++++- test/pg_large_objects/large_object_test.exs | 17 +++++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba7565d..3be6075 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * Fix `Collectable` implementation silently ignoring write errors. * Fix `Collectable` implementation silently ignoring close errors. + * Fix `Enumerable` implementation crashing with `CaseClauseError` on read errors. ## v0.2.6 - 2026-05-01 diff --git a/lib/pg_large_objects/large_object.ex b/lib/pg_large_objects/large_object.ex index 57d7974..8e75068 100644 --- a/lib/pg_large_objects/large_object.ex +++ b/lib/pg_large_objects/large_object.ex @@ -403,11 +403,16 @@ defimpl Enumerable, for: PgLargeObjects.LargeObject do case PgLargeObjects.LargeObject.read(lob, lob.bufsize) do {:ok, ""} -> {:halt, lob} {:ok, data} -> {[data], lob} + {:error, reason} -> raise "failed to read from large object: #{inspect(reason)}" end end after_fun = fn lob -> - PgLargeObjects.LargeObject.close(lob) + try do + PgLargeObjects.LargeObject.close(lob) + rescue + _ -> :ok + end end Stream.resource(start_fun, next_fun, after_fun).(acc, fun) diff --git a/test/pg_large_objects/large_object_test.exs b/test/pg_large_objects/large_object_test.exs index cd3cedf..5741ce5 100644 --- a/test/pg_large_objects/large_object_test.exs +++ b/test/pg_large_objects/large_object_test.exs @@ -343,6 +343,23 @@ defmodule PgLargeObjects.LargeObjectTest do end end + describe "Enumerable implementation" do + test "raises on read error" do + oid = put_large_object!("hello") + + TestRepo.transaction(fn -> + {:ok, lob} = LargeObject.open(TestRepo, oid) + + # Delete the object so reads will fail + LargeObject.remove(TestRepo, lob.oid) + + assert_raise RuntimeError, fn -> + Enum.to_list(lob) + end + end) + end + end + defp with_object(data, opts \\ [], fun) do oid = put_large_object!(data) From 1934d981c21523d8cff3d08f3b2c706f5a9ccf2c Mon Sep 17 00:00:00 2001 From: Frerich Raabe Date: Sat, 16 May 2026 01:31:27 +0200 Subject: [PATCH 4/7] Fix Enumerable.count/1 returning invalid protocol value on error When size/1 failed, the with block fell through returning {:error, :not_found} which is not a valid Enumerable.count/1 return value. The protocol requires {:ok, count} or {:error, module}. Now explicitly returns {:error, __MODULE__} on failure. --- CHANGELOG.md | 1 + lib/pg_large_objects/large_object.ex | 5 +++-- test/pg_large_objects/large_object_test.exs | 12 ++++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3be6075..046b17c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ * Fix `Collectable` implementation silently ignoring write errors. * Fix `Collectable` implementation silently ignoring close errors. * Fix `Enumerable` implementation crashing with `CaseClauseError` on read errors. + * Fix `Enumerable.count/1` returning invalid protocol value on error. ## v0.2.6 - 2026-05-01 diff --git a/lib/pg_large_objects/large_object.ex b/lib/pg_large_objects/large_object.ex index 8e75068..6fabd28 100644 --- a/lib/pg_large_objects/large_object.ex +++ b/lib/pg_large_objects/large_object.ex @@ -419,8 +419,9 @@ defimpl Enumerable, for: PgLargeObjects.LargeObject do end def count(lob) do - with {:ok, size} <- PgLargeObjects.LargeObject.size(lob) do - {:ok, ceil(size / lob.bufsize)} + case PgLargeObjects.LargeObject.size(lob) do + {:ok, size} -> {:ok, ceil(size / lob.bufsize)} + {:error, _} -> {:error, __MODULE__} end end diff --git a/test/pg_large_objects/large_object_test.exs b/test/pg_large_objects/large_object_test.exs index 5741ce5..a2a7c3f 100644 --- a/test/pg_large_objects/large_object_test.exs +++ b/test/pg_large_objects/large_object_test.exs @@ -358,6 +358,18 @@ defmodule PgLargeObjects.LargeObjectTest do end end) end + + test "count/1 returns {:error, module} when object is invalid" do + oid = put_large_object!("hello") + + TestRepo.transaction(fn -> + {:ok, lob} = LargeObject.open(TestRepo, oid) + LargeObject.remove(TestRepo, lob.oid) + + result = Enumerable.count(lob) + assert result == {:error, Enumerable.PgLargeObjects.LargeObject} + end) + end end defp with_object(data, opts \\ [], fun) do From dfd6d49a4e55f5ba0864f3211840e6a957647f11 Mon Sep 17 00:00:00 2001 From: Frerich Raabe Date: Sat, 16 May 2026 01:31:57 +0200 Subject: [PATCH 5/7] Fix Enumerable.slice/1 crashing with MatchError when object is invalid The slice function used {:ok, size} = count(lob) which crashed with a MatchError if the object was invalid. Now returns {:error, __MODULE__} per the Enumerable protocol contract when count fails. --- CHANGELOG.md | 1 + lib/pg_large_objects/large_object.ex | 38 ++++++++++++--------- test/pg_large_objects/large_object_test.exs | 12 +++++++ 3 files changed, 34 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 046b17c..972897e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ * Fix `Collectable` implementation silently ignoring close errors. * Fix `Enumerable` implementation crashing with `CaseClauseError` on read errors. * Fix `Enumerable.count/1` returning invalid protocol value on error. + * Fix `Enumerable.slice/1` crashing with `MatchError` when object is invalid. ## v0.2.6 - 2026-05-01 diff --git a/lib/pg_large_objects/large_object.ex b/lib/pg_large_objects/large_object.ex index 6fabd28..729542b 100644 --- a/lib/pg_large_objects/large_object.ex +++ b/lib/pg_large_objects/large_object.ex @@ -428,25 +428,29 @@ defimpl Enumerable, for: PgLargeObjects.LargeObject do def member?(_lob, _element), do: {:error, __MODULE__} def slice(lob) do - slicing_fun = fn - start, length, 1 -> - PgLargeObjects.LargeObject.seek(lob, start * lob.bufsize) - - for _ <- 0..(length - 1) do - {:ok, data} = PgLargeObjects.LargeObject.read(lob, lob.bufsize) - data + case count(lob) do + {:ok, size} -> + slicing_fun = fn + start, length, 1 -> + {:ok, _} = PgLargeObjects.LargeObject.seek(lob, start * lob.bufsize) + + for _ <- 0..(length - 1) do + {:ok, data} = PgLargeObjects.LargeObject.read(lob, lob.bufsize) + data + end + + start, length, step -> + for i <- 0..(length - 1)//step do + {:ok, _} = PgLargeObjects.LargeObject.seek(lob, (start + i) * lob.bufsize) + {:ok, data} = PgLargeObjects.LargeObject.read(lob, lob.bufsize) + data + end end - start, length, step -> - for i <- 0..(length - 1)//step do - PgLargeObjects.LargeObject.seek(lob, (start + i) * lob.bufsize) - {:ok, data} = PgLargeObjects.LargeObject.read(lob, lob.bufsize) - data - end - end + {:ok, size, slicing_fun} - {:ok, size} = count(lob) - - {:ok, size, slicing_fun} + {:error, _} -> + {:error, __MODULE__} + end end end diff --git a/test/pg_large_objects/large_object_test.exs b/test/pg_large_objects/large_object_test.exs index a2a7c3f..77cbbdf 100644 --- a/test/pg_large_objects/large_object_test.exs +++ b/test/pg_large_objects/large_object_test.exs @@ -370,6 +370,18 @@ defmodule PgLargeObjects.LargeObjectTest do assert result == {:error, Enumerable.PgLargeObjects.LargeObject} end) end + + test "slice/1 returns {:error, module} when object is invalid" do + oid = put_large_object!("hello") + + TestRepo.transaction(fn -> + {:ok, lob} = LargeObject.open(TestRepo, oid) + LargeObject.remove(TestRepo, lob.oid) + + result = Enumerable.slice(lob) + assert result == {:error, Enumerable.PgLargeObjects.LargeObject} + end) + end end defp with_object(data, opts \\ [], fun) do From a9386435849091d4adac19757f8a1668caf17c68 Mon Sep 17 00:00:00 2001 From: Frerich Raabe Date: Sat, 16 May 2026 01:32:34 +0200 Subject: [PATCH 6/7] Fix Enumerable slicing function ignoring seek and read errors The slicing function (returned by slice/1) discarded the return value of seek/3 for step=1, causing reads from wrong positions on failure. For both step values, read errors would crash with MatchError. Now raises descriptive RuntimeErrors on seek or read failures. --- CHANGELOG.md | 1 + lib/pg_large_objects/large_object.ex | 23 +++++++++++++++------ test/pg_large_objects/large_object_test.exs | 18 ++++++++++++++++ 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 972897e..5f719d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ * Fix `Enumerable` implementation crashing with `CaseClauseError` on read errors. * Fix `Enumerable.count/1` returning invalid protocol value on error. * Fix `Enumerable.slice/1` crashing with `MatchError` when object is invalid. + * Fix `Enumerable` slicing function ignoring seek/read errors. ## v0.2.6 - 2026-05-01 diff --git a/lib/pg_large_objects/large_object.ex b/lib/pg_large_objects/large_object.ex index 729542b..aa18f91 100644 --- a/lib/pg_large_objects/large_object.ex +++ b/lib/pg_large_objects/large_object.ex @@ -432,18 +432,29 @@ defimpl Enumerable, for: PgLargeObjects.LargeObject do {:ok, size} -> slicing_fun = fn start, length, 1 -> - {:ok, _} = PgLargeObjects.LargeObject.seek(lob, start * lob.bufsize) + case PgLargeObjects.LargeObject.seek(lob, start * lob.bufsize) do + {:ok, _} -> :ok + {:error, reason} -> raise "failed to seek in large object: #{inspect(reason)}" + end for _ <- 0..(length - 1) do - {:ok, data} = PgLargeObjects.LargeObject.read(lob, lob.bufsize) - data + case PgLargeObjects.LargeObject.read(lob, lob.bufsize) do + {:ok, data} -> data + {:error, reason} -> raise "failed to read from large object: #{inspect(reason)}" + end end start, length, step -> for i <- 0..(length - 1)//step do - {:ok, _} = PgLargeObjects.LargeObject.seek(lob, (start + i) * lob.bufsize) - {:ok, data} = PgLargeObjects.LargeObject.read(lob, lob.bufsize) - data + case PgLargeObjects.LargeObject.seek(lob, (start + i) * lob.bufsize) do + {:ok, _} -> :ok + {:error, reason} -> raise "failed to seek in large object: #{inspect(reason)}" + end + + case PgLargeObjects.LargeObject.read(lob, lob.bufsize) do + {:ok, data} -> data + {:error, reason} -> raise "failed to read from large object: #{inspect(reason)}" + end end end diff --git a/test/pg_large_objects/large_object_test.exs b/test/pg_large_objects/large_object_test.exs index 77cbbdf..8cbae16 100644 --- a/test/pg_large_objects/large_object_test.exs +++ b/test/pg_large_objects/large_object_test.exs @@ -382,6 +382,24 @@ defmodule PgLargeObjects.LargeObjectTest do assert result == {:error, Enumerable.PgLargeObjects.LargeObject} end) end + + test "slice/1 raises on seek error" do + oid = put_large_object!("hello") + + TestRepo.transaction(fn -> + {:ok, lob} = LargeObject.open(TestRepo, oid, bufsize: 2) + + # Get slice function + {:ok, _size, slicing_fun} = Enumerable.slice(lob) + + # Delete object so seek fails + LargeObject.remove(TestRepo, lob.oid) + + assert_raise RuntimeError, fn -> + slicing_fun.(0, 1, 1) + end + end) + end end defp with_object(data, opts \\ [], fun) do From e1772cf96aa2b3c17fc460579d6d56f7c9013c5b Mon Sep 17 00:00:00 2001 From: Frerich Raabe Date: Sun, 17 May 2026 21:57:07 +0200 Subject: [PATCH 7/7] Make Credo happy Credo complained that the `slice/1` implementation was getting a little complex, and I tend to agree. Let's factory some code out into a separate helper function. --- lib/pg_large_objects/large_object.ex | 58 ++++++++++++++-------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/lib/pg_large_objects/large_object.ex b/lib/pg_large_objects/large_object.ex index aa18f91..8a6797f 100644 --- a/lib/pg_large_objects/large_object.ex +++ b/lib/pg_large_objects/large_object.ex @@ -430,38 +430,38 @@ defimpl Enumerable, for: PgLargeObjects.LargeObject do def slice(lob) do case count(lob) do {:ok, size} -> - slicing_fun = fn - start, length, 1 -> - case PgLargeObjects.LargeObject.seek(lob, start * lob.bufsize) do - {:ok, _} -> :ok - {:error, reason} -> raise "failed to seek in large object: #{inspect(reason)}" - end - - for _ <- 0..(length - 1) do - case PgLargeObjects.LargeObject.read(lob, lob.bufsize) do - {:ok, data} -> data - {:error, reason} -> raise "failed to read from large object: #{inspect(reason)}" - end - end - - start, length, step -> - for i <- 0..(length - 1)//step do - case PgLargeObjects.LargeObject.seek(lob, (start + i) * lob.bufsize) do - {:ok, _} -> :ok - {:error, reason} -> raise "failed to seek in large object: #{inspect(reason)}" - end - - case PgLargeObjects.LargeObject.read(lob, lob.bufsize) do - {:ok, data} -> data - {:error, reason} -> raise "failed to read from large object: #{inspect(reason)}" - end - end - end - - {:ok, size, slicing_fun} + {:ok, size, fn start, length, step -> slicing_fun(lob, start, length, step) end} {:error, _} -> {:error, __MODULE__} end end + + defp slicing_fun(lob, start, length, 1) do + case PgLargeObjects.LargeObject.seek(lob, start * lob.bufsize) do + {:ok, _} -> :ok + {:error, reason} -> raise "failed to seek in large object: #{inspect(reason)}" + end + + for _ <- 0..(length - 1) do + case PgLargeObjects.LargeObject.read(lob, lob.bufsize) do + {:ok, data} -> data + {:error, reason} -> raise "failed to read from large object: #{inspect(reason)}" + end + end + end + + defp slicing_fun(lob, start, length, step) do + for i <- 0..(length - 1)//step do + case PgLargeObjects.LargeObject.seek(lob, (start + i) * lob.bufsize) do + {:ok, _} -> :ok + {:error, reason} -> raise "failed to seek in large object: #{inspect(reason)}" + end + + case PgLargeObjects.LargeObject.read(lob, lob.bufsize) do + {:ok, data} -> data + {:error, reason} -> raise "failed to read from large object: #{inspect(reason)}" + end + end + end end