Skip to content

Conversation

@VGasparini
Copy link
Contributor

@VGasparini VGasparini commented Dec 1, 2025

Add Callbacks support to IronTrail

Overview

This PR adds a callback system to IronTrail, allowing users to execute custom PostgreSQL functions automatically after each change is logged. This enables use cases like real-time notifications, data pipelines, custom auditing, and business logic hooks without modifying the gem or application code.

Changes

  • Callbacks Support: New irontrail_change_callbacks table to register custom PostgreSQL functions
  • Automatic Callback Execution: The trigger function now automatically calls registered callback functions after logging changes for INSERT, UPDATE, and DELETE operations
  • Enable/Disable Per Table: Callbacks can be toggled on/off without removing them
  • Rails Model Support: IrontrailChangeCallback model includes IronTrail::Model for tracking changes to the callbacks themselves

How It Works

Callbacks are PostgreSQL functions that receive three parameters:

  • change_id (BIGINT) - The ID of the newly created irontrail_changes record
  • rec_table (TEXT) - The name of the table that was changed
  • operation (TEXT) - The operation type ('i', 'u', or 'd')

Callback functions can query the irontrail_changes table using the change_id to access full record data (rec_old, rec_new, rec_delta, metadata, etc.).

Use Cases

1. Real-Time Notifications

Trigger external notifications when sensitive data changes:

CREATE OR REPLACE FUNCTION notify_sensitive_changes(
  change_id BIGINT,
  rec_table TEXT,
  operation TEXT
) RETURNS VOID AS $$
DECLARE
  change_record RECORD;
BEGIN
  SELECT rec_new, rec_old INTO change_record
  FROM irontrail_changes
  WHERE id = change_id;
  
  -- Send notification via pg_notify
  PERFORM pg_notify('data_changes', json_build_object(
    'table', rec_table,
    'operation', operation,
    'change_id', change_id
  )::text);
END;
$$ LANGUAGE plpgsql;

-- Register the callback
INSERT INTO irontrail_change_callbacks (rec_table, function_name, enabled)
VALUES ('users', 'notify_sensitive_changes', true);

2. Data Pipeline Triggers

Queue data for external processing:

CREATE OR REPLACE FUNCTION enqueue_for_analytics(
  change_id BIGINT,
  rec_table TEXT,
  operation TEXT
) RETURNS VOID AS $$
BEGIN
  INSERT INTO analytics_queue (change_id, queued_at)
  VALUES (change_id, NOW());
END;
$$ LANGUAGE plpgsql;

INSERT INTO irontrail_change_callbacks (rec_table, function_name, enabled)
VALUES ('transactions', 'enqueue_for_analytics', true);

3. Custom Compliance Logging

Log specific changes to a separate compliance table:

CREATE OR REPLACE FUNCTION log_gdpr_changes(
  change_id BIGINT,
  rec_table TEXT,
  operation TEXT
) RETURNS VOID AS $$
DECLARE
  change_record RECORD;
BEGIN
  SELECT rec_old, rec_new, actor_id INTO change_record
  FROM irontrail_changes
  WHERE id = change_id;
  
  -- Only log if email or phone changed
  IF change_record.rec_old->>'email' IS DISTINCT FROM change_record.rec_new->>'email'
     OR change_record.rec_old->>'phone' IS DISTINCT FROM change_record.rec_new->>'phone' THEN
    INSERT INTO gdpr_audit_log (change_id, actor_id, changed_at)
    VALUES (change_id, change_record.actor_id, NOW());
  END IF;
END;
$$ LANGUAGE plpgsql;

INSERT INTO irontrail_change_callbacks (rec_table, function_name, enabled)
VALUES ('customers', 'log_gdpr_changes', true);

Managing Callbacks

Disable an Callback Temporarily

UPDATE irontrail_change_callbacks 
SET enabled = false 
WHERE rec_table = 'users' AND function_name = 'notify_sensitive_changes';

Enable Multiple Callbacks for a Table

Multiple callbacks can be registered for the same table - they will all execute in order:

# Callback 1: Send notification
ActiveRecord::Base.connection.execute(<<~SQL)
  INSERT INTO irontrail_change_callbacks (rec_table, function_name, enabled)
  VALUES ('payments', 'notify_payment_changes', true)
SQL

# Callback 2: Update cache
ActiveRecord::Base.connection.execute(<<~SQL)
  INSERT INTO irontrail_change_callbacks (rec_table, function_name, enabled)
  VALUES ('payments', 'invalidate_payment_cache', true)
SQL

Important Notes

  • ✅ Callbacks execute within the same transaction as the original change
  • ✅ If an callback raises an exception, it gets logged to irontrail_trigger_errors without affecting the change or other callbacks
  • ✅ Callbacks should be fast and efficient to avoid performance issues
  • ✅ Callback functions have full access to PostgreSQL features
  • ✅ The IrontrailChangeCallback model itself is tracked by IronTrail

Testing

Comprehensive test coverage included:

  • Callback execution for INSERT, UPDATE, DELETE operations
  • Multiple callbacks per table
  • Enable/disable functionality
  • Callback isolation per table
  • Model validations and scopes

See CONTRIBUTING.md for detailed testing instructions.

Migration

Users upgrading to v0.2.0 will need to run the new migration generator:

rails g iron_trail:migration
rails db:migrate

This creates the irontrail_change_callbacks table required for the feature.

Breaking Changes

None - this is a purely additive feature that doesn't affect existing functionality.

@VGasparini VGasparini self-assigned this Dec 1, 2025
@VGasparini VGasparini added the enhancement New feature or request label Dec 1, 2025
@VGasparini VGasparini changed the title New feature - Extensions New feature - Callbacks Dec 1, 2025
@VGasparini VGasparini requested a review from Markhyz December 1, 2025 16:44
Copy link
Member

@mhfs mhfs left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @VGasparini 🤌,

@andrepiske and I are discussing it but we're still not fully decided on the direction.

Please hold on this PR. These are just some initial comments from our convo.

@@ -0,0 +1,26 @@
class CreateIrontrailChangeCallbacks < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
def up
create_table :irontrail_change_callbacks, id: :bigserial do |t|
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd call the feature Iron Trail Extensions instead of callbacks.

create_table :irontrail_change_callbacks, id: :bigserial do |t|
t.column :rec_table, :text, null: false
t.column :function_name, :text, null: false
t.column :enabled, :boolean, default: true, null: false
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for enable. Record can be created/deleted as needed to serve the same purpose.

"create_irontrail_trigger_function.rb.erb",
"db/migrate/create_irontrail_trigger_function.rb"
)
unless self.class.migration_exists?(migration_dir, "create_irontrail_changes")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't think these unlesss are necessary.

path = File.expand_path("#{function_name}.sql", __dir__)
sql = File.read(path)
connection.execute(sql)
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why this new method and a new irontrail_log_row_function_with_callbacks.sql file duplicating the function that already exists on irontrail_log_row_function.sql?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any reason to create this file instead of using the existing one?

operation_char = 'd';
END IF;

IF (change_id IS NOT NULL) THEN
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

was this if necessary in your tests? it seems to be checking something that should always be satisfied

'Extension: ' || ext_func_name || E'\n' || err_ctx,
TG_OP, TG_TABLE_NAME, row_to_json(OLD), row_to_json(NEW),
'Extension function: ' || ext_func_name || ' for change_id: ' || change_id,
STATEMENT_TIMESTAMP());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should add some sort of indication that this error is from an extension (and which one in a structured way) and not the irontrail core.


OWN_TRACKABLE_TABLES = %w[
irontrail_change_callbacks
].freeze
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove this constant and declare table on OWN_TABLES above

@mhfs
Copy link
Member

mhfs commented Dec 2, 2025

We decided to not introduce features that adds unknown performance implications to the trigger/function loop.

@mhfs mhfs closed this Dec 2, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Development

Successfully merging this pull request may close these issues.

4 participants