Etlify 0.11.0 adds an enabled: flag on Etlify::CRM.register that lets you
turn a CRM integration into a pure no-op at the process level — typically to
keep Etlify dormant in development or test environments while leaving other
CRMs active.
New features:
enabled:option onEtlify::CRM.register(defaulttrue).Etlify::CRM.enabled?(name)public helper (returnstruefor unknown CRMs as a safe default).- Model API stays truthy when disabled —
record.hubspot_sync!andrecord.hubspot_delete!returntrueso calling code that branches on the return value keeps working unchanged.
No database migration required for this upgrade.
Disable a CRM entirely, typically outside of production:
Etlify::CRM.register(
:hubspot,
adapter: Etlify::Adapters::HubspotV3Adapter.new(
access_token: ENV["HUBSPOT_PRIVATE_APP_TOKEN"]
),
enabled: Rails.env.production? || Rails.env.staging?,
options: { job_class: Etlify::SyncJob }
)
Etlify::CRM.register(
:another_crm,
adapter: AnotherAdapter.new,
# enabled: true # default
)enabled: must be a boolean — anything else raises ArgumentError at
registration time. Omitting the option keeps the previous behaviour
(enabled: true), so existing setups work unchanged.
| Entry point | Return value | Side effects |
|---|---|---|
record.<crm>_sync! / record.crm_sync! |
true |
No job enqueued, no adapter call |
record.<crm>_delete! / record.crm_delete! |
true |
No adapter call |
Etlify::Synchronizer.call |
:disabled |
No adapter call, no write to crm_synchronisations |
Etlify::Deleter.call |
:disabled |
No adapter call |
Etlify::BatchSynchronizer.call |
stats hash with disabled: true, skipped: records.size |
No adapter call |
Etlify::StaleRecords::BatchSync.call |
disabled CRMs silently skipped, enabled CRMs processed normally | Stats only reflect enabled CRMs |
Existing crm_synchronisations rows are left untouched when a CRM is
disabled — flipping enabled: back to true resumes normal sync without any
extra action (records that became stale in the meantime will be picked up by
the next BatchSync).
- In a non-production environment, set
enabled: falseon your CRM registration and confirm:record.hubspot_sync!returnstrueand does not enqueue a job.record.hubspot_delete!returnstrue.CrmSynchronisation.where(crm_name: "hubspot")is not written.- No outgoing HTTP request to the CRM (mock adapter or network logs).
- In a spec, toggle
enabledfor a single CRM and assert other CRMs keep syncing throughEtlify::StaleRecords::BatchSync. - Re-enable the CRM and confirm that stale records are synced on the next run.
Fully backward-compatible. The default enabled: true preserves the
pre-0.11.0 behaviour for every existing CRM registration.
Etlify 0.10.0 introduces batch synchronization with built-in rate limiting:
New features:
BatchSyncJob— A single job per CRM replaces N individualSyncJobinstances for batch syncRateLimiter— Sleep-based rate limiting installed permanently on the adapter atCRM.registertimeBatchSynchronizer— Batch-aware synchronizer usingadapter.batch_upsert!(100 records/request for HubSpot)- HubSpot batch operations —
batch_upsert!andbatch_delete!via native batch endpoints DefaultHttp— Shared HTTP client extracted for adapter reuse
No database migration required for this upgrade.
Add rate_limit to your CRM registration to enable automatic throttling:
Etlify::CRM.register(
:hubspot,
adapter: Etlify::Adapters::HubspotV3Adapter.new(
access_token: ENV["HUBSPOT_PRIVATE_APP_TOKEN"]
),
options: {
rate_limit: { max_requests: 100, period: 10 },
max_sync_errors: 5,
}
)The rate limiter is installed permanently on the adapter. All sync paths are throttled: BatchSyncJob, individual SyncJob, inline crm_sync!(async: false), and pending sync flushes.
Without rate_limit, no throttling is applied (current behaviour preserved).
The job_class option on CRM.register and model DSL is still supported. Custom job classes will also benefit from rate limiting since the rate limiter lives on the adapter, not on the job.
Before: Enqueues one SyncJob per stale record (N jobs).
After: Enqueues one BatchSyncJob per CRM with all stale record pairs.
This is not a breaking change — the API is identical, only the internal job dispatch changed. The return value ({total:, per_model:, errors:}) is unchanged.
A cache-based lock ensures only one BatchSyncJob per CRM runs at a time. If a second BatchSyncJob is enqueued for the same CRM while one is running, it is silently dropped.
If the adapter supports batch_upsert! (HubSpot, NullAdapter), BatchSyncJob uses BatchSynchronizer to group records and call batch_upsert! (up to 100 records per API request for HubSpot). If the adapter does not support batch_upsert!, it falls back to sequential Synchronizer.call per record.
If you have a custom adapter and want to benefit from rate limiting, add a rate_limiter= accessor and call @rate_limiter&.throttle! before each HTTP request:
class MyCrmAdapter
attr_accessor :rate_limiter
private
def request(method, path, body: nil)
@rate_limiter&.throttle!
# ... perform HTTP request
end
endIf you also want batch support, implement batch_upsert! returning a Hash{id_property_value => crm_id}:
def batch_upsert!(object_type:, records:, id_property:)
# ... call CRM batch API
# return { "john@example.com" => "123", "jane@example.com" => "456" }
end-
rate_limitconfigured in initializer for each CRM -
BatchSync.call(async: true)enqueuesBatchSyncJob(not NSyncJob) - Individual
model.crm_sync!still works and is rate-limited -
bundle exec rspecpasses - No 429 errors in production logs after deployment
All changes are backward compatible:
SyncJobis kept for individualmodel.crm_sync!callsBatchSync.call(async: false)(inline mode) is unchanged- Adapters without
rate_limiter=orbatch_upsert!continue to work rate_limitis optional — no throttle when absent
Etlify 0.9.4 introduces three new features and two bug fixes:
New features:
stale_scope— Restrict which records theStaleRecords::Finderconsiders at SQL levelerror_count— Track consecutive sync failures and auto-exclude broken recordssync_dependencies— Buffer syncs until dependencies have acrm_id
Bug fixes:
StaleRecords::Findernow handleshas_one :throughdependencies where the through association is abelongs_toStaleRecords::Findernow handles STI subclasses withoutPG::UndefinedColumnerrors
rails g etlify:add_error_count
rails db:migrateThis adds an error_count integer column (default: 0) to crm_synchronisations.
Only required if you plan to use sync_dependencies:
rails g etlify:migration create_etlify_pending_syncs
rails db:migrateRecords exceeding this limit are automatically excluded from StaleRecords::Finder.
Default: 3.
# Global
Etlify.configure do |config|
config.max_sync_errors = 5
end
# Or per-CRM
Etlify::CRM.register(
:hubspot,
adapter: Etlify::Adapters::HubspotV3Adapter.new(
access_token: ENV["HUBSPOT_PRIVATE_APP_TOKEN"]
),
options: { max_sync_errors: 5 }
)To manually re-enable sync on a record after fixing the root cause:
CrmSynchronisation.find(id).reset_error_count!Restricts which records StaleRecords::Finder considers. Applied at SQL level
before any record is processed. Prevents unnecessary CrmSynchronisation rows
for records that sync_if would skip.
class User < ApplicationRecord
include Etlify::Model
hubspot_etlified_with(
serializer: Etlify::Serializers::UserSerializer,
crm_object_type: "contacts",
id_property: "email",
stale_scope: ->(rel) { rel.where(active: true) }
)
endModels without stale_scope are not affected — the Finder behaves as before.
Buffers sync when a dependency has no crm_id yet. Automatically retries once
the dependency is synced. Supports both etlified models (via CrmSynchronisation)
and legacy models with a direct #{crm_name}_id column.
class Employee < ApplicationRecord
include Etlify::Model
hubspot_etlified_with(
serializer: Etlify::Serializers::EmployeeSerializer,
crm_object_type: "contacts",
id_property: "email",
dependencies: [:company],
sync_dependencies: [:company]
)
endNote:
dependenciescontrols freshness checks (re-sync when dependency changes).sync_dependenciescontrols ordering (block until dependency has acrm_id). They can overlap but serve different purposes.
- Migration adds
error_counttocrm_synchronisations - Migration creates
etlify_pending_syncstable (if usingsync_dependencies) - Records with
error_count >= max_sync_errorsare excluded fromStaleRecords::Finder -
CrmSynchronisation#reset_error_count!re-enables sync -
stale_scopecorrectly restricts the Finder query -
sync_dependenciesbuffers and flushes correctly - STI models sync without
PG::UndefinedColumnerrors -
has_one :through(viabelongs_to) dependencies are detected as stale
All features are backward compatible. Existing code continues to work without
changes. The error_count migration is strongly recommended to avoid retrying
permanently broken records.
- Nothing to do (bugfix: custom
job_classsupport inBatchSyncvia CRM options)
- Nothing to do (bugfix)
- Nothing to do (bugfix)
Etlify 0.9.0 introduces multi-CRM support and requires a crm_name column
in your crm_synchronisations table. Jobs and model DSLs have also evolved.
Key changes:
- Multi-CRM support via
Etlify::CRM.register(:hubspot, ...) - Each sync line is now scoped by
crm_name - Updated job signature:
perform(model, id, crm_name) - Model DSLs renamed to
<crm>_etlified_with(...)
Update config/initializers/etlify.rb:
require "etlify"
Etlify.configure do |config|
Etlify::CRM.register(
:hubspot,
adapter: Etlify::Adapters::HubspotV3Adapter.new(
access_token: ENV["HUBSPOT_PRIVATE_APP_TOKEN"]
),
options: { job_class: "Etlify::SyncObjectWorker" }
)
# Default values (optional)
# config.digest_strategy = Etlify::Digest.method(:stable_sha256)
# config.job_queue_name = "low"
# config.cache_store = Rails.cache || ActiveSupport::Cache::MemoryStore.new
endAdd the new crm_name column and rebuild the unique index.
class AddCrmNameToCrmSynchronisations < ActiveRecord::Migration[7.2] # Change with your version
def self.up
add_column :crm_synchronisations, :crm_name, :string
add_index :crm_synchronisations, :crm_name
remove_index(
:crm_synchronisations,
[:resource_type, :resource_id],
unique: true,
name: "idx_crm_sync_on_resource"
)
add_index(
:crm_synchronisations,
[:resource_type, :resource_id, :crm_name],
unique: true,
name: "idx_crm_sync_on_resource"
)
# Set default crm_name to 'hubspot' for existing records
execute <<-SQL.squish
UPDATE crm_synchronisations
SET crm_name = 'hubspot'
WHERE crm_name IS NULL
SQL
change_column_null :crm_synchronisations, :crm_name, false
end
def self.down
remove_index :crm_synchronisations, :crm_name
remove_column :crm_synchronisations, :crm_name
remove_index(
:crm_synchronisations,
[:resource_type, :resource_id, :crm_name],
unique: true,
name: "idx_crm_sync_on_resource"
)
add_index(
:crm_synchronisations,
[:resource_type, :resource_id],
unique: true,
name: "idx_crm_sync_on_resource"
)
end
endTip: If you already have duplicates on
(resource_type, resource_id), deduplicate before applying the unique index.
Each model must now declare its CRM configuration explicitly.
class User < ApplicationRecord
include Etlify::Model
hubspot_etlified_with(
serializer: Etlify::Serializers::UserSerializer,
crm_object_type: "contacts",
id_property: "email",
dependencies: [:company],
sync_if: ->(record) { record.email.present? }
)
endYou can declare multiple CRMs per model by repeating the macro.
module Etlify
class SyncObjectWorker
include Sidekiq::Worker
sidekiq_options(
retry: false,
queue: :low,
lock: :until_executed,
lock_timeout: 0,
lock_args_method: :lock_args
)
def perform(model_name, id, crm_name)
model = model_name.constantize
record = model.find_by(id: id)
return unless record
Etlify::Synchronizer.call(record, crm_name: crm_name.to_sym)
end
def self.lock_args(*args)
[*args]
end
end
endCrmSynchronisation.where(crm_name: nil).count
CrmSynchronisation.group(:crm_name).count
CrmSynchronisation.find_by(crm_name: "hubspot")
User.first&.crm_sync!(crm_name: :hubspot, async: false)- Add
crm_nameto admin views or dashboards. - Update any filters, exports, or queries to include
crm_name.
- Migration adds
crm_nameand backfills existing rows. - Unique index
(resource_type, resource_id, crm_name)exists. - Finder and BatchSync correctly filter by
crm_name. - Jobs receive
crm_nameargument. - Serializers output expected payloads.
- Console call works:
User.first.crm_sync!(crm_name: :hubspot, async: false).
The down method in the migration restores the old schema.
If data deduplication was done, ensure a CSV export exists before running down.
Happy syncing! 🚀