Carbonite

repository·main·Indexed 42 days ago

https://github.com/bitcrowd/carbonite

An Elixir library for implementing Change-Data-Capture (CDC) in PostgreSQL. It uses database triggers to automatically record INSERT, UPDATE, and DELETE mutations into a central audit trail, ensuring data changes and transaction metadata are captured atomically. Requires PostgreSQL 13 or above. Features include Ecto integration, configurable audit triggers, partitioned audit trails, and event outboxes for processing and exporting captured data.

Tokens
10.9K
Snippets
44
Records
50
Agent score
53%

What's inside Carbonite

  1. Handle empty transactions and deferred triggers

    main

    By default, Carbonite records a Carbonite.Transaction even if no actual data changes occur in the database. If you want to avoid storing "empty" transactions (where no changes were recorded), you must:

    1. Set the trigger to INITIALLY DEFERRED during creation: Carbonite.Migrations.create_trigger(:table, initially: :deferred)
    2. Re-order your application logic so the transaction is only inserted if changes are actually going to happen.

    Example of the re-ordered logic:

    Ecto.Multi.new()
    |> Ecto.Multi.run(:rabbit, &maybe_insert_rabbit/2)
    |> Ecto.Multi.run(:carbonite_transaction, &maybe_insert_carbonite_transaction/2)
    |> MyApp.Repo.transaction()
    # In the migration where the trigger is created
    Carbonite.Migrations.create_trigger(:rabbits, initially: :deferred)
  2. How Carbonite works

    main

    Carbonite implements the Change-Data-Capture (CDC) pattern using PostgreSQL triggers to ensure a complete and consistent audit trail. It is built around the concept that a database transaction is the natural unit of auditing.

    Core Components

    • changes table: A central table where all mutations (INSERT, UPDATE, DELETE) of participating tables are recorded. Triggers on audited tables automatically capture data and write it here.
    • transactions table: Stores information about database transactions. Developers must insert a row into this table within a transaction to record arbitrary metadata. This metadata is then associated with all changes occurring in that same transaction.
    • triggers table: Stores per-table settings for the trigger procedure, such as which columns to exclude from being captured.

    Consistency Guarantees

    Carbonite uses foreign keys and PostgreSQL's internal transaction ID (pg_current_xact_id()) to ensure:

    1. No mutation without a recorded Change: Triggers ensure every change is captured.
    2. No Change without a Transaction: A changes record must reference a valid transactions record. Attempting to modify an audited table without first inserting a record into the transactions table will result in a database error.
    3. Atomic Commits: Because triggers run within the same transaction as the data mutation, the mutation and its audit trail are committed as a single atomic unit (ACID).

    Limitations

    • TRUNCATE is not supported: Carbonite does not instrument TRUNCATE statements.
    • Performance Impact: Using triggers incurs a performance penalty as additional indexes and tables are touched during mutations.
  3. How Event Outboxes work for processing captured data

    main

    An Carbonite.Outbox acts as a named cursor on the ordered list of transactions in your audit trail. It is used to offload captured data to external stores, export data, or purge old records.

    Key characteristics:

    • Ordering: Transactions are processed in ascending order by their id attribute (roughly in order of insertion).
    • Statefulness: The outbox remembers its last processed transaction position and can persist an arbitrary map (a "memo") to pass data between consecutive processor runs.
    • Concurrency: Carbonite.process/4 does not acquire a lock or start a transaction. You must ensure only one process runs for a specific outbox at a time (e.g., using Oban's unique jobs).
    • Idempotency: If the processor function raises an exception, the outbox position is not updated. The next run will re-process the same batch, so your processor must be idempotent.
    # Create an outbox in a migration
    Carbonite.Migrations.create_outbox("rabbit_holes")
    
    # Process the outbox
    Carbonite.process(MyApp.Repo, "rabbit_holes", fn transactions, _memo ->
      for transaction <- transactions do
        send_to_external_database(transaction)
      end
    
      :cont
    end)
  4. Partition the audit trail using prefixes

    main

    To separate audit data into different database schemas, use the carbonite_prefix option. This allows you to "partition" your captured data. All Carbonite functions that accept carbonite_prefix will target the specified partition.

    Example:

    Carbonite.Migrations.up(1, carbonite_prefix: "carbonite_animals")
    Carbonite.Migrations.create_trigger(:rabbits, carbonite_prefix: "carbonite_animals")
  5. Requirements for Carbonite

    main

    Carbonite requires PostgreSQL version 13 or above because it relies on the pg_current_xact_id function.

    If you are using an older version of PostgreSQL, you will encounter the following error: ** (Postgrex.Error) ERROR 42704 (undefined_object) type "xid8" does not exist

  6. Install Carbonite via Hex

    main

    To use Carbonite in your Elixir project, add it to your mix.exs dependencies. Carbonite is based on Ecto and Postgrex and has no further dependencies.

    # mix.exs
    def deps do
      [
        {:carbonite, "~> 0.17.0"}
      ]
    end
  7. Initialize Carbonite with a migration

    main

    To start using Carbonite, you must create an initial migration to set up its database schema and triggers. Use the provided Mix task to generate the migration file, then customize it in your up and down functions.

    1. Generate the migration: mix carbonite.gen.initial_migration -r MyApp.Repo

    2. Edit the generated file to:

      • Call Carbonite.Migrations.up/1 to install core tables.
      • Call Carbonite.Migrations.create_trigger/1 for every table you want to audit.
      • Optionally call Carbonite.Migrations.create_outbox/1 to enable an outbox for processing transactions later.
    mix carbonite.gen.initial_migration -r MyApp.Repo
  8. Bypass change capture in tests

    main

    In testing environments (especially when using factories like ExMachina), you may want to temporarily disable Carbonite's change capturing to avoid the overhead of creating Carbonite.Transaction records.

    There are two primary strategies:

    Keep the default trigger mode as :capture and use Carbonite.override_mode/2 to selectively disable it within specific test blocks. This ensures you don't accidentally forget to wrap production code in a transaction.

    # Helper to wrap a function in ignored mode
    def with_ignored_carbonite(fun) do
      Carbonite.override_mode(MyApp.Repo, to: :ignore)
      result = fun.()
      Carbonite.override_mode(MyApp.Repo, to: :capture)
      result
    end

    Set the trigger mode to :ignore globally for the :test environment in your configuration. You then manually enable capturing only in tests where you need to assert on audit data.

    Configuration:

    # config/test.exs
    config :my_app, carbonite_mode: :ignore

    Migration Setup:

    @mode Application.compile_env!(:my_app, :carbonite_mode)
    
    def up do
      Carbonite.Migrations.create_trigger(:rabbits)
      Carbonite.Migrations.put_trigger_config(:rabbits, :mode, @mode)
    end
  9. Update Carbonite schema during upgrades

    main

    When upgrading to a new version of Carbonite that includes database schema changes, create a new migration in your application. You must run the Carbonite migration for each of your configured carbonite_prefix partitions.

    Example migration structure:

    defmodule MyApp.Repo.Migrations.UpdateCarbonite do
      use Ecto.Migration
    
      def up do
        # Replace '3' with the version number specified in the Carbonite changelog
        Carbonite.Migrations.up(3)
      end
    
      def down do
        Carbonite.Migrations.down(3)
      end
    end
    # priv/repo/migrations/20210704201534_update_carbonite.exs
    defmodule MyApp.Repo.Migrations.UpdateCarbonite do
      use Ecto.Migration
    
      def up do
        Carbonite.Migrations.up(3)
      end
    
      def down do
        Carbonite.Migrations.down(3)
      end
    end
  10. Manage transaction metadata with Carbonite.Transaction

    main

    Carbonite allows you to attach arbitrary metadata to database transactions. This is useful for recording context (like a user_id or account_id) at a system boundary without explicitly passing it through every function call in your application logic.

    Metadata is stored in the process dictionary and can be retrieved or updated using put_meta/2 and current_meta/0. When creating a new Carbonite.Transaction via changeset/1, the metadata currently in the process dictionary is automatically merged with any metadata provided in the params.

    # Store metadata at a system boundary
    Carbonite.Transaction.put_meta(:user_id, 123)
    
    # Retrieve it later
    meta = Carbonite.Transaction.current_meta()
    # => %{user_id: 123}
    
    # When creating a transaction changeset, the metadata is automatically included
    changeset = Carbonite.Transaction.changeset(%{meta: %{reason: "update"}})
    # The resulting changeset :meta field will contain %{user_id: 123, reason: "update"}
  11. Understand the Carbonite.Outbox schema

    main

    The Carbonite.Outbox module is used to store metadata for outboxes, specifically tracking the last_transaction_id to ensure reliable event delivery. A last_transaction_id of 0 indicates that no transactions have been processed yet for that specific outbox.

    Each outbox is identified by a unique name.

  12. Generate the initial Carbonite migration

    main

    To install Carbonite's internal data structures into your database, use the carbonite.gen.initial_migration Mix task. This generates a new Ecto migration file in your repository's migrations directory that runs all necessary Carbonite migrations.

    Run the following command in your terminal:

    mix carbonite.gen.initial_migration
    mix carbonite.gen.initial_migration