solidflow 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4880f6ff9e531227b618a8fe2ff91bbb09688d60d6c9544b4a90e36b5922e00e
4
+ data.tar.gz: f63bf94ff8865affdb2ba9f7ca1909eb84c83ca46d1fe0d3d7c6c30ca03eecc8
5
+ SHA512:
6
+ metadata.gz: d1a230750a5489a0c2818e419545ead56b863d0a5a591ee7e2c3bc90751e641b9a82ad86aef6ed05ebdb6a6883b6ea771dcc044af2ecd8bb515318f7ce27f667
7
+ data.tar.gz: 61804d906296b93dfe1be46b663bd2ae4e09dc90f4df0c429d3c999afe2854105e7e3d6540dddf146bfc30fa22e2c0d75a81c78acd3a103ff9f9fcb6263037cd
data/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # SolidFlow
2
+
3
+ **SolidFlow** — Durable workflows for Ruby and Rails. Deterministic workflows, event‑sourced history, timers, signals, tasks, and SAGA compensations. Backed by ActiveJob and ActiveRecord. Production‑ready, scalable, and extensible.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem "solidflow", path: "solidflow"
11
+ ```
12
+
13
+ Then run `bundle install` and install the migrations:
14
+
15
+ ```bash
16
+ bin/rails solidflow:install:migrations
17
+ bin/rails db:migrate
18
+ ```
19
+
20
+ ## Quick Start
21
+
22
+ Define a workflow and a task:
23
+
24
+ ```ruby
25
+ class ReserveInventoryTask < SolidFlow::Task
26
+ def perform(order_id:)
27
+ Inventory.reserve(order_id: order_id)
28
+ end
29
+ end
30
+
31
+ class OrderFulfillmentWorkflow < SolidFlow::Workflow
32
+ signal :payment_captured
33
+
34
+ step :reserve_inventory, task: :reserve_inventory_task
35
+
36
+ step :await_payment do
37
+ wait.for(seconds: 30)
38
+ wait.for_signal(:payment_captured) unless ctx[:payment_received]
39
+ end
40
+
41
+ on_signal :payment_captured do |payload|
42
+ ctx[:payment_received] = true
43
+ ctx[:txn_id] = payload.fetch("txn_id")
44
+ end
45
+ end
46
+ ```
47
+
48
+ Kick off an execution:
49
+
50
+ ```ruby
51
+ execution = OrderFulfillmentWorkflow.start(order_id: "ORD-1")
52
+ OrderFulfillmentWorkflow.signal(execution.id, :payment_captured, txn_id: "txn-123")
53
+ ```
54
+
55
+ ## CLI
56
+
57
+ ```bash
58
+ bin/solidflow start OrderFulfillmentWorkflow --args '{"order_id":"ORD-1"}'
59
+ bin/solidflow signal <execution_id> payment_captured --payload '{"txn_id":"txn-123"}'
60
+ bin/solidflow query <execution_id> status
61
+ ```
62
+
63
+ ## Testing
64
+
65
+ ```ruby
66
+ execution = SolidFlow::Testing.start_and_drain(OrderFulfillmentWorkflow, order_id: "ORD-1")
67
+ expect(execution.state).to eq("completed")
68
+ ```
69
+
70
+ See `solid_flow_instructions.md` for the full architecture and feature specification.
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require "bundler/gem_tasks"
2
+
3
+ require_relative "lib/solidflow"
4
+
5
+ desc "Run test suite"
6
+ task :spec do
7
+ exec("bundle exec rspec")
8
+ end
9
+
10
+ task default: :spec
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidFlow
4
+ module Jobs
5
+ class RunExecutionJob < ActiveJob::Base
6
+ queue_as do
7
+ SolidFlow.configuration.default_execution_queue
8
+ end
9
+
10
+ retry_exceptions = [ActiveRecord::Deadlocked]
11
+ retry_exceptions << ActiveRecord::LockWaitTimeout if defined?(ActiveRecord::LockWaitTimeout)
12
+ retry_on(*retry_exceptions, attempts: 5, wait: :exponentially_longer)
13
+
14
+ def perform(execution_id)
15
+ SolidFlow::Runner.new.run(execution_id)
16
+ rescue Errors::ExecutionNotFound => e
17
+ SolidFlow.logger.warn("SolidFlow execution not found: #{execution_id} (#{e.class}: #{e.message})")
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidFlow
4
+ module Jobs
5
+ class RunTaskJob < ActiveJob::Base
6
+ queue_as do
7
+ SolidFlow.configuration.default_task_queue
8
+ end
9
+
10
+ def perform(execution_id, step_name, task_name, arguments, headers)
11
+ headers = headers.deep_symbolize_keys
12
+ arguments = arguments.deep_symbolize_keys if arguments.respond_to?(:deep_symbolize_keys)
13
+
14
+ task_class = SolidFlow.task_registry.fetch(task_name)
15
+ workflow_class = SolidFlow.configuration.workflow_registry.fetch(headers.fetch(:workflow_name))
16
+ attempt = headers[:attempt] || 1
17
+ idempotency_key = headers[:idempotency_key]
18
+ compensation = headers[:compensation]
19
+
20
+ result = task_class.execute(arguments: arguments || {}, headers:)
21
+
22
+ SolidFlow.store.with_execution(execution_id) do |_|
23
+ if compensation
24
+ SolidFlow.store.record_compensation_result(
25
+ execution_id:,
26
+ step: step_name.to_sym,
27
+ compensation_task: headers[:compensation_task],
28
+ result:
29
+ )
30
+ else
31
+ SolidFlow.store.record_task_result(
32
+ execution_id:,
33
+ workflow_class:,
34
+ step: step_name.to_sym,
35
+ result:,
36
+ attempt:,
37
+ idempotency_key: idempotency_key
38
+ )
39
+ end
40
+ end
41
+
42
+ SolidFlow.instrument(
43
+ "solidflow.task.completed",
44
+ execution_id:,
45
+ workflow: workflow_class.workflow_name,
46
+ step: step_name,
47
+ task: task_name,
48
+ attempt:,
49
+ result:,
50
+ compensation: compensation
51
+ )
52
+ rescue Errors::TaskFailure => failure
53
+ retryable = retryable?(workflow_class, step_name, attempt, headers)
54
+
55
+ SolidFlow.store.with_execution(execution_id) do |_|
56
+ if headers[:compensation]
57
+ SolidFlow.store.record_compensation_failure(
58
+ execution_id:,
59
+ step: step_name.to_sym,
60
+ compensation_task: headers[:compensation_task],
61
+ error: {
62
+ message: failure.message,
63
+ class: failure.details&.fetch(:class, failure.class.name),
64
+ backtrace: Array(failure.details&.fetch(:backtrace, failure.backtrace))
65
+ }
66
+ )
67
+ else
68
+ SolidFlow.store.record_task_failure(
69
+ execution_id:,
70
+ workflow_class:,
71
+ step: step_name.to_sym,
72
+ attempt:,
73
+ error: {
74
+ message: failure.message,
75
+ class: failure.details&.fetch(:class, failure.class.name),
76
+ backtrace: Array(failure.details&.fetch(:backtrace, failure.backtrace))
77
+ },
78
+ retryable:
79
+ )
80
+ end
81
+ end
82
+
83
+ SolidFlow.instrument(
84
+ "solidflow.task.failed",
85
+ execution_id:,
86
+ workflow: workflow_class.workflow_name,
87
+ step: step_name,
88
+ task: task_name,
89
+ attempt:,
90
+ error: failure,
91
+ compensation: headers[:compensation]
92
+ )
93
+ end
94
+
95
+ private
96
+
97
+ def retryable?(workflow_class, step_name, attempt, headers)
98
+ return false if headers[:compensation]
99
+
100
+ step_definition = workflow_class.steps.find { |step| step.name.to_sym == step_name.to_sym }
101
+ policy = step_definition&.retry_policy || {}
102
+ max_attempts = policy[:max_attempts] || 1
103
+ attempt < max_attempts
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidFlow
4
+ module Jobs
5
+ class TimerSweepJob < ActiveJob::Base
6
+ queue_as do
7
+ SolidFlow.configuration.default_timer_queue
8
+ end
9
+
10
+ def perform(batch_size: 100)
11
+ now = SolidFlow.configuration.time_provider.call
12
+
13
+ SolidFlow::Timer.transaction do
14
+ SolidFlow::Timer
15
+ .scheduled
16
+ .where("run_at <= ?", now)
17
+ .limit(batch_size)
18
+ .lock("FOR UPDATE SKIP LOCKED")
19
+ .each do |timer|
20
+ SolidFlow.store.mark_timer_fired(timer_id: timer.id)
21
+ SolidFlow.instrument(
22
+ "solidflow.timer.fired",
23
+ execution_id: timer.execution_id,
24
+ timer_id: timer.id,
25
+ step: timer.step
26
+ )
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidFlow
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ primary_abstract_class
6
+ end
7
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidFlow
4
+ class Event < ApplicationRecord
5
+ self.table_name = "solidflow_events"
6
+
7
+ belongs_to :execution,
8
+ class_name: "SolidFlow::Execution",
9
+ inverse_of: :events
10
+
11
+ scope :ordered, -> { order(:sequence) }
12
+
13
+ def to_replay_event
14
+ Replay::Event.new(
15
+ id: id,
16
+ type: event_type,
17
+ sequence: sequence,
18
+ payload: payload,
19
+ recorded_at: recorded_at
20
+ )
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidFlow
4
+ class Execution < ApplicationRecord
5
+ self.table_name = "solidflow_executions"
6
+
7
+ has_many :events,
8
+ class_name: "SolidFlow::Event",
9
+ foreign_key: :execution_id,
10
+ inverse_of: :execution,
11
+ dependent: :destroy
12
+
13
+ has_many :timers,
14
+ class_name: "SolidFlow::Timer",
15
+ foreign_key: :execution_id,
16
+ inverse_of: :execution,
17
+ dependent: :destroy
18
+
19
+ has_many :signal_messages,
20
+ class_name: "SolidFlow::SignalMessage",
21
+ foreign_key: :execution_id,
22
+ inverse_of: :execution,
23
+ dependent: :destroy
24
+
25
+ enum state: {
26
+ running: "running",
27
+ completed: "completed",
28
+ failed: "failed",
29
+ cancelled: "cancelled"
30
+ }
31
+
32
+ def ctx_hash
33
+ (self[:ctx] || {}).with_indifferent_access
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidFlow
4
+ class SignalMessage < ApplicationRecord
5
+ self.table_name = "solidflow_signal_messages"
6
+
7
+ belongs_to :execution,
8
+ class_name: "SolidFlow::Execution",
9
+ inverse_of: :signal_messages
10
+
11
+ enum status: {
12
+ pending: "pending",
13
+ consumed: "consumed"
14
+ }
15
+ end
16
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidFlow
4
+ class Timer < ApplicationRecord
5
+ self.table_name = "solidflow_timers"
6
+
7
+ belongs_to :execution,
8
+ class_name: "SolidFlow::Execution",
9
+ inverse_of: :timers
10
+
11
+ enum status: {
12
+ scheduled: "scheduled",
13
+ fired: "fired",
14
+ cancelled: "cancelled"
15
+ }
16
+
17
+ scope :due, ->(now = Time.current) { scheduled.where("run_at <= ?", now) }
18
+ end
19
+ end
data/bin/solidflow ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "solid_flow/cli"
6
+
7
+ SolidFlow::CLI.start(ARGV)
@@ -0,0 +1,71 @@
1
+ class CreateSolidflowCoreTables < ActiveRecord::Migration[7.1]
2
+ def change
3
+ enable_extension "pgcrypto" unless extension_enabled?("pgcrypto")
4
+
5
+ create_table :solidflow_executions, id: :uuid do |t|
6
+ t.string :workflow, null: false
7
+ t.string :state, null: false, default: "running"
8
+ t.jsonb :ctx, null: false, default: {}
9
+ t.string :cursor_step
10
+ t.integer :cursor_index, null: false, default: 0
11
+ t.string :graph_signature
12
+ t.jsonb :metadata, null: false, default: {}
13
+ t.jsonb :last_error
14
+ t.string :shard_key
15
+ t.string :account_id
16
+ t.datetime :started_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
17
+ t.timestamps
18
+ end
19
+
20
+ add_index :solidflow_executions, :workflow
21
+ add_index :solidflow_executions, %i[state workflow]
22
+ add_index :solidflow_executions, :shard_key
23
+ add_index :solidflow_executions, :account_id
24
+
25
+ create_table :solidflow_events, id: :uuid do |t|
26
+ t.uuid :execution_id, null: false
27
+ t.integer :sequence, null: false
28
+ t.string :event_type, null: false
29
+ t.jsonb :payload, null: false, default: {}
30
+ t.string :idempotency_key
31
+ t.datetime :recorded_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
32
+ t.timestamps
33
+ end
34
+
35
+ add_index :solidflow_events, %i[execution_id sequence], unique: true
36
+ add_index :solidflow_events, :event_type
37
+ add_index :solidflow_events, %i[execution_id idempotency_key], unique: true, where: "idempotency_key IS NOT NULL"
38
+ add_foreign_key :solidflow_events, :solidflow_executions, column: :execution_id
39
+
40
+ create_table :solidflow_timers, id: :uuid do |t|
41
+ t.uuid :execution_id, null: false
42
+ t.string :step, null: false
43
+ t.datetime :run_at, null: false
44
+ t.string :status, null: false, default: "scheduled"
45
+ t.jsonb :instruction, null: false, default: {}
46
+ t.jsonb :metadata, null: false, default: {}
47
+ t.datetime :fired_at
48
+ t.timestamps
49
+ end
50
+
51
+ add_index :solidflow_timers, :execution_id
52
+ add_index :solidflow_timers, %i[status run_at]
53
+ add_foreign_key :solidflow_timers, :solidflow_executions, column: :execution_id
54
+
55
+ create_table :solidflow_signal_messages, id: :uuid do |t|
56
+ t.uuid :execution_id, null: false
57
+ t.string :signal_name, null: false
58
+ t.jsonb :payload, null: false, default: {}
59
+ t.jsonb :metadata, null: false, default: {}
60
+ t.string :status, null: false, default: "pending"
61
+ t.datetime :received_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
62
+ t.datetime :consumed_at
63
+ t.timestamps
64
+ end
65
+
66
+ add_index :solidflow_signal_messages, :execution_id
67
+ add_index :solidflow_signal_messages, %i[execution_id status]
68
+ add_index :solidflow_signal_messages, %i[execution_id signal_name status], name: "index_solidflow_signals_lookup"
69
+ add_foreign_key :solidflow_signal_messages, :solidflow_executions, column: :execution_id
70
+ end
71
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "json"
5
+
6
+ module SolidFlow
7
+ class CLI < Thor
8
+ desc "start WORKFLOW", "Start a workflow execution"
9
+ option :args, type: :string, default: "{}", desc: "JSON payload with workflow input arguments"
10
+ def start(workflow_name)
11
+ workflow_class = SolidFlow.configuration.workflow_registry.fetch(workflow_name)
12
+ arguments = parse_json(options[:args])
13
+
14
+ execution = workflow_class.start(**symbolize_keys(arguments))
15
+ say("Started execution #{execution.id}", :green)
16
+ rescue Errors::ConfigurationError, JSON::ParserError => e
17
+ say("Failed to start workflow: #{e.message}", :red)
18
+ exit(1)
19
+ end
20
+
21
+ desc "signal EXECUTION_ID SIGNAL", "Send a signal to a workflow execution"
22
+ option :workflow, type: :string, desc: "Workflow name (optional; inferred if omitted)"
23
+ option :payload, type: :string, default: "{}", desc: "JSON payload for the signal"
24
+ def signal(execution_id, signal_name)
25
+ workflow_class = resolve_workflow(options[:workflow], execution_id)
26
+ payload = parse_json(options[:payload])
27
+
28
+ workflow_class.signal(execution_id, signal_name.to_sym, payload)
29
+ say("Signal #{signal_name} enqueued for execution #{execution_id}", :green)
30
+ rescue Errors::ConfigurationError, JSON::ParserError => e
31
+ say("Failed to send signal: #{e.message}", :red)
32
+ exit(1)
33
+ end
34
+
35
+ desc "query EXECUTION_ID QUERY", "Execute a read-only query against a workflow"
36
+ option :workflow, type: :string, desc: "Workflow name (optional; inferred if omitted)"
37
+ def query(execution_id, query_name)
38
+ workflow_class = resolve_workflow(options[:workflow], execution_id)
39
+ result = workflow_class.query(execution_id, query_name.to_sym)
40
+ say(JSON.pretty_generate(result))
41
+ rescue Errors::ConfigurationError => e
42
+ say("Failed to run query: #{e.message}", :red)
43
+ exit(1)
44
+ end
45
+
46
+ no_commands do
47
+ def parse_json(string)
48
+ return {} if string.nil? || string.strip.empty?
49
+
50
+ JSON.parse(string)
51
+ end
52
+
53
+ def symbolize_keys(hash)
54
+ return hash unless hash.respond_to?(:transform_keys)
55
+
56
+ hash.transform_keys(&:to_sym)
57
+ end
58
+
59
+ def resolve_workflow(name, execution_id)
60
+ return SolidFlow.configuration.workflow_registry.fetch(name) if name
61
+
62
+ SolidFlow.store.with_execution(execution_id, lock: false) do |execution|
63
+ return SolidFlow.configuration.workflow_registry.fetch(execution[:workflow])
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module SolidFlow
6
+ module Determinism
7
+ module_function
8
+
9
+ def graph_signature(workflow_class)
10
+ payload = {
11
+ workflow: workflow_class.name,
12
+ steps: workflow_class.steps.map do |step|
13
+ {
14
+ name: step.name,
15
+ task: step.task,
16
+ block: step.block? ? "block" : nil,
17
+ retry: step.retry_policy,
18
+ timeouts: step.timeouts,
19
+ options: step.options
20
+ }
21
+ end,
22
+ signals: workflow_class.signals.keys.map(&:to_s).sort,
23
+ queries: workflow_class.queries.keys.map(&:to_s).sort,
24
+ compensations: workflow_class.compensations.transform_keys(&:to_s).transform_values(&:to_s).sort.to_h
25
+ }
26
+
27
+ Digest::SHA256.hexdigest(JSON.generate(payload))
28
+ end
29
+
30
+ def assert_graph!(workflow_class, persisted_signature)
31
+ signature = graph_signature(workflow_class)
32
+ return signature unless persisted_signature
33
+
34
+ return signature if signature == persisted_signature
35
+
36
+ raise Errors::NonDeterministicWorkflowError.new(expected: persisted_signature, actual: signature)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module SolidFlow
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace SolidFlow
8
+ engine_name "solidflow"
9
+
10
+ initializer "solidflow.active_job" do
11
+ ActiveSupport.on_load(:active_job) do
12
+ queue_adapter # touch to ensure ActiveJob loaded
13
+ end
14
+ end
15
+
16
+ initializer "solidflow.append_migrations" do |app|
17
+ unless app.root.to_s.match?(root.to_s)
18
+ config_file = root.join("db/migrate")
19
+ app.config.paths["db/migrate"].concat([config_file.to_s])
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidFlow
4
+ module Errors
5
+ # Base error for all SolidFlow failures.
6
+ class SolidFlowError < StandardError; end
7
+
8
+ class ConfigurationError < SolidFlowError; end
9
+ class DeterminismError < SolidFlowError; end
10
+ class ExecutionNotFound < SolidFlowError; end
11
+ class Cancelled < SolidFlowError; end
12
+ class TimeoutError < SolidFlowError; end
13
+ class TaskFailure < SolidFlowError
14
+ attr_reader :details
15
+
16
+ def initialize(message = nil, details: nil)
17
+ @details = details
18
+ super(message || "Task execution failed")
19
+ end
20
+ end
21
+
22
+ class NonDeterministicWorkflowError < DeterminismError
23
+ attr_reader :expected, :actual
24
+
25
+ def initialize(expected:, actual:)
26
+ @expected = expected
27
+ @actual = actual
28
+ super("Workflow definition diverged from persisted graph signature (expected: #{expected}, actual: #{actual})")
29
+ end
30
+ end
31
+
32
+ class UnknownSignal < SolidFlowError
33
+ def initialize(signal)
34
+ super("Unknown signal `#{signal}`")
35
+ end
36
+ end
37
+
38
+ class UnknownQuery < SolidFlowError
39
+ def initialize(query)
40
+ super("Unknown query `#{query}`")
41
+ end
42
+ end
43
+
44
+ class InvalidStep < SolidFlowError
45
+ def initialize(step)
46
+ super("Step `#{step}` is not defined on this workflow")
47
+ end
48
+ end
49
+
50
+ class WaitInstructionError < SolidFlowError; end
51
+ end
52
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module SolidFlow
6
+ module Idempotency
7
+ module_function
8
+
9
+ def evaluate(key, workflow:, step:)
10
+ case key
11
+ when Proc
12
+ workflow.instance_exec(&key).to_s
13
+ when Symbol
14
+ workflow.public_send(key).to_s
15
+ when String
16
+ key
17
+ when Array
18
+ key.compact.map(&:to_s).join(":")
19
+ when nil
20
+ default(workflow.execution.id, step.name)
21
+ else
22
+ key.to_s
23
+ end
24
+ end
25
+
26
+ def default(execution_id, step_name, attempt = 0)
27
+ Digest::SHA256.hexdigest([execution_id, step_name, attempt].join(":"))
28
+ end
29
+
30
+ def digest(*parts)
31
+ Digest::SHA256.hexdigest(parts.flatten.compact.map(&:to_s).join("|"))
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidFlow
4
+ module Instrumentation
5
+ module_function
6
+
7
+ def subscribe(logger: SolidFlow.logger)
8
+ ActiveSupport::Notifications.subscribe(/solidflow\./) do |event_name, start, finish, _id, payload|
9
+ next unless logger
10
+
11
+ duration = (finish - start) * 1000.0
12
+ logger.info("[#{event_name}] (#{format('%.1fms', duration)}) #{payload.compact}")
13
+ end
14
+ end
15
+ end
16
+ end