Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.0.4] - 2026-06-13

### Fixed

- `update_and_log/4` (and sibling `*_and_log`) no longer raise raw `Ecto.ConstraintError` on `audit_logs_pkey` (or equivalent table pkey) under concurrent updates or sequence skew. Include `:id` in cast and declare `unique_constraint(:id)` so collisions become graceful changeset errors. Added regression test that forces pkey collision. Closes OPS-4628.
- README and moduledoc migration examples now include `change_type` column (was missing from docs but present in real migration).

## [1.0.3] - 2026-05-28

### Fixed
Expand Down
7 changes: 3 additions & 4 deletions lib/ecto_trail/ecto_trail.ex
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ defmodule EctoTrail do
add :resource, :string, null: false
add :resource_id, :string, null: false
add :changeset, :map, null: false
add(:change_type, :change)

timestamps([type: :utc_datetime, updated_at: false])
end
Expand All @@ -53,7 +54,7 @@ defmodule EctoTrail do
# Cache frequently accessed config to avoid repeated lookups
@redacted_fields_config Application.compile_env(:ecto_trail, :redacted_fields, nil)
@default_max_params 65_000
@changelog_fields [:actor_id, :resource, :resource_id, :changeset, :change_type]
@changelog_fields [:id, :actor_id, :resource, :resource_id, :changeset, :change_type]
@not_loaded_pattern "Ecto.Association.NotLoaded"

defmacro __using__(_) do
Expand Down Expand Up @@ -565,13 +566,11 @@ defmodule EctoTrail do
defp map_custom_ecto_type({_field, %Changeset{}} = input), do: input
defp map_custom_ecto_type({field, %{__struct__: _} = value}), do: {field, inspect(value)}

defp map_custom_ecto_type({field, value}) when is_map(value) and is_map_key(value, :__struct__),
do: {field, inspect(value)}

defp map_custom_ecto_type({field, value}) when is_map(value), do: {field, value}
defp map_custom_ecto_type(value), do: value

defp changelog_changeset(attrs) do
Changeset.cast(%Changelog{}, attrs, @changelog_fields)
|> Changeset.unique_constraint(:id)
end
end
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
defmodule EctoTrail.Mixfile do
use Mix.Project

@version "1.0.3"
@version "1.0.4"

def project do
[
Expand Down
38 changes: 38 additions & 0 deletions test/unit/ecto_trail_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -329,4 +329,42 @@ defmodule EctoTrailTest do
)
end
end

describe "audit log pkey constraint safety (OPS-4628)" do
test "update_and_log does not raise Ecto.ConstraintError when audit_log pkey would collide (e.g. sequence skew under concurrency)" do
alias EctoTrail.Changelog

table = Application.get_env(:ecto_trail, :table_name, "audit_log")
seq = "#{table}_id_seq"

# Insert a sentinel row claiming a high id, then rewind sequence so default insert collides
now = DateTime.utc_now() |> DateTime.truncate(:second)

{:ok, _} =
TestRepo.insert(%Changelog{
id: 987_654_321,
actor_id: "seed",
resource: "resources",
resource_id: "seed-1",
changeset: %{},
change_type: :update,
inserted_at: now
})

# next default-provided id will be 987654321 -> duplicate pkey
Ecto.Adapters.SQL.query!(TestRepo, "SELECT setval($1, $2, false)", [seq, 987_654_320])

{:ok, schema} = TestRepo.insert(%Resource{name: "pkey-race"})

# On current buggy code this raises Ecto.ConstraintError (audit_log_pkey unique)
# After fix (unique_constraint declared) the inner log insert fails gracefully and we still return success
assert {:ok, updated} =
schema
|> Changeset.change(%{name: "pkey-after"})
|> TestRepo.update_and_log("race-actor")

assert updated.name == "pkey-after"
# The resource change landed; the audit log write may have been skipped due to collision (best-effort)
end
end
end