Skip to content

fix: declare unique_constraint on audit_log pkey to prevent Ecto.ConstraintError in *_and_log (OPS-4594)#59

Closed
palantir-valiot[bot] wants to merge 1 commit into
mainfrom
palantir/OPS-4594-fix-audit-log-pkey-constraint
Closed

fix: declare unique_constraint on audit_log pkey to prevent Ecto.ConstraintError in *_and_log (OPS-4594)#59
palantir-valiot[bot] wants to merge 1 commit into
mainfrom
palantir/OPS-4594-fix-audit-log-pkey-constraint

Conversation

@palantir-valiot

Copy link
Copy Markdown

Summary

Add a unique_constraint(:id, name: <table>_pkey) declaration inside changelog_changeset/1 so the bare repo.insert/1 performed by log_changes/5 (and log_changes_alone) for every *_and_log / log path turns a duplicate pkey violation into a changeset error instead of letting Ecto raise Ecto.ConstraintError.

The error path already existed and was intentionally swallowed (logs the reason and returns {:ok, reason}), so callers continue to see success for the primary operation while the audit write is best-effort.

Files changed:

  • lib/ecto_trail/ecto_trail.ex:574 (core fix + two small module attrs for the constraint name, derived from the same table_name config the schema already uses)
  • test/unit/ecto_trail_test.exs (TDD test that forces a deterministic audit_log pkey collision on the update_and_log path and asserts the main update still succeeds)
  • mix.exs (1.0.3 → 1.0.4)
  • CHANGELOG.md (concise entry)

Why

Production (eliot-lamosa-gto-prod) hit:

(Ecto.ConstraintError) constraint error when attempting to insert struct:
    * "audit_logs_pkey" (unique_constraint)
...
    (ecto_trail 1.0.3) lib/ecto_trail/ecto_trail.ex:435: EctoTrail.log_changes/5
    (ecto_trail 1.0.3) lib/ecto_trail/ecto_trail.ex:315: anonymous fn/4 in EctoTrail.update_and_log/4

Root cause: changelog_changeset/1 only did Changeset.cast/3; no unique_constraint/3 was ever declared for the pkey of the (configurable) audit table. Under concurrent load the sequence can emit a value that another writer has just used, producing the exact pkey collision visible in the stacktrace.

See Linear OPS-4594 and the triage analysis (NOTIFY+FIX, code_bug).

Assumptions

  • The audit table always has a serial/bigserial id pkey whose Postgres constraint name is <table_name>_pkey (the default audit_log produces audit_log_pkey; the observed prod name was audit_logs_pkey because some tenants use a plural table name via config). The constraint name is computed the same way the schema already derives the table name.
  • The existing "swallow log errors" behavior in log_changes/5 and log_changes_alone is intentional and sufficient; we only need to stop the exception from escaping.

Test plan

  • mix format (clean)
  • mix compile (clean; only pre-existing redundant-clause warning on an unrelated helper)
  • Added a focused regression test under describe "update_and_log/3 pkey constraint resilience" that:
    • Seeds a colliding row with a known id
    • Uses setval so the next nextval() inside the library-produced insert will collide on that pkey
    • Calls update_and_log
    • Asserts {:ok, updated_resource} (no ConstraintError) and that the main resource row was mutated
  • mix test — the suite has always required a running Postgres (see test/test_helper.exs:82). In this k8s per-task pod no Postgres listener is available, so the harness cannot reach the "migrate + sandbox" steps. The compile-time and formatting gates pass; the new test was executed mentally against the exact failure mode from the stacktrace and will be exercised by CI on PR (or by any developer with docker compose up / the project's normal test DB). No other verification scripts are declared in mix.exs, AGENTS.md (absent), or package.json (none).
  • Self-review of git diff origin/main..HEAD: only the four files above; no debug prints, no unrelated refactors, no new deps, no scope creep.
  • PR body written from scratch (no .github/pull_request_template.md present; skeleton not used verbatim).

Closes OPS-4594

…traintError in *_and_log paths

- Add @audit_table/@pkey_constraint_name module attrs derived from config (defaults to audit_log_pkey)
- Apply unique_constraint(:id, name: @pkey_constraint_name) in changelog_changeset/1
- This covers log_changes/5 (used by update_and_log, insert_and_log, upsert_and_log, delete_and_log) and log_changes_alone
- Add TDD test that forces a deterministic pkey collision on audit_log and asserts the main operation still succeeds (existing log error path already swallows {:error, changeset})
- Bump version 1.0.3 -> 1.0.4; add concise CHANGELOG entry

Closes OPS-4594

The root cause was the bare repo.insert() of a cast-only Changelog changeset with no constraint declaration; when the audit table sequence produced a duplicate pkey (observed in prod under concurrent load), Ecto raised ConstraintError instead of returning a changeset error that the library already handles gracefully.
@linear-code

linear-code Bot commented Jun 13, 2026

Copy link
Copy Markdown

OPS-4594

@acrogenesis

Copy link
Copy Markdown
Member

Closing as a duplicate of #24 — same bug: Ecto.ConstraintError on audit_logs_pkey in EctoTrail.log_changes/5. These were generated from a backlog of duplicate Linear issues created by a log-agent dedup gap (now fixed in palantir 38438d6; no new duplicates are being filed). Consolidating on #24.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant