txnap 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.
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Txnap
4
+ class Railtie < Rails::Railtie
5
+ initializer "txnap.install" do
6
+ ActiveSupport.on_load(:active_record) { Txnap.install! }
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Txnap
4
+ class Registry
5
+ def initialize(&warning_handler)
6
+ @warning_handler = warning_handler
7
+ @mutex = Mutex.new
8
+ @traces = {}.compare_by_identity
9
+ end
10
+
11
+ def start(connection, trace)
12
+ stale_trace = @mutex.synchronize do
13
+ stale = @traces[connection]
14
+ @traces[connection] = trace
15
+ stale
16
+ end
17
+ @warning_handler&.call("discarded a stale trace before a new BEGIN") if stale_trace
18
+ trace
19
+ end
20
+
21
+ def fetch(connection)
22
+ @mutex.synchronize { @traces[connection] }
23
+ end
24
+
25
+ def bind_transaction(connection, transaction)
26
+ return unless transaction
27
+
28
+ @mutex.synchronize { @traces[connection]&.bind_transaction(transaction) }
29
+ end
30
+
31
+ def finalize(connection, transaction)
32
+ @mutex.synchronize do
33
+ trace = @traces[connection]
34
+ return unless trace
35
+ matches_transaction = trace.matches_transaction?(transaction)
36
+ matches_terminal_without_binding = trace.transaction.nil? && trace.terminal?
37
+ return unless matches_transaction || matches_terminal_without_binding
38
+
39
+ @traces.delete(connection)
40
+ end
41
+ end
42
+
43
+ def size
44
+ @mutex.synchronize { @traces.size }
45
+ end
46
+
47
+ def reset!
48
+ @mutex.synchronize { @traces.clear }
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Txnap
4
+ EventMetadata = Data.define(:sql, :name, :call_site) do
5
+ def to_h
6
+ {sql: sql, name: name, call_site: call_site}
7
+ end
8
+ end
9
+
10
+ Gap = Data.define(:duration, :after, :before, :locks) do
11
+ def to_h
12
+ {
13
+ duration_ms: duration * 1000.0,
14
+ after: after&.to_h,
15
+ before: before&.to_h
16
+ }
17
+ end
18
+ end
19
+
20
+ LockCandidate = Data.define(:statement, :table, :kind, :call_site) do
21
+ def to_h
22
+ {statement: statement, table: table, kind: kind, call_site: call_site}
23
+ end
24
+ end
25
+
26
+ class Report
27
+ attr_reader :outcome,
28
+ :transaction_duration,
29
+ :database_time,
30
+ :longest_gap,
31
+ :gaps,
32
+ :locks,
33
+ :sql_count
34
+
35
+ def initialize(outcome:, transaction_duration:, database_time:, gaps:, sql_count:)
36
+ @outcome = outcome
37
+ @transaction_duration = transaction_duration
38
+ @database_time = database_time
39
+ @gaps = gaps.freeze
40
+ @longest_gap = gaps.first
41
+ @locks = (@longest_gap&.locks || []).freeze
42
+ @sql_count = sql_count
43
+ end
44
+
45
+ def to_h
46
+ {
47
+ outcome: outcome,
48
+ transaction_duration_ms: transaction_duration * 1000.0,
49
+ database_time_ms: database_time * 1000.0,
50
+ longest_gap_ms: longest_gap.duration * 1000.0,
51
+ sql_count: sql_count,
52
+ gap: longest_gap.to_h,
53
+ gaps: gaps.map(&:to_h),
54
+ locks: locks.map(&:to_h)
55
+ }
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Txnap
4
+ module SqlEventClassifier
5
+ module_function
6
+
7
+ def classify(payload)
8
+ return :cache if payload[:cached] == true || payload[:name] == "CACHE"
9
+ return :schema if payload[:name] == "SCHEMA"
10
+ return :sql unless payload[:name] == "TRANSACTION"
11
+
12
+ classify_transaction_sql(payload[:sql])
13
+ end
14
+
15
+ def classify_transaction_sql(sql)
16
+ statement = Array(sql).join(" ").strip
17
+
18
+ return :begin if statement.match?(/\A(?:BEGIN\b|START\s+TRANSACTION\b)/i)
19
+ return :commit if statement.match?(/\ACOMMIT\b/i)
20
+ return :transaction if statement.match?(/\AROLLBACK\s+TO\b/i)
21
+ return :rollback if statement.match?(/\AROLLBACK\b/i)
22
+
23
+ :transaction
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Txnap
4
+ module Subscribers
5
+ class SqlSubscriber
6
+ class << self
7
+ def install
8
+ @subscription ||= begin
9
+ subscriber = new
10
+ ActiveSupport::Notifications.monotonic_subscribe("sql.active_record") do |*args|
11
+ subscriber.call(*args)
12
+ end
13
+ end
14
+ end
15
+ end
16
+
17
+ def call(_name, start, finish, _id, payload)
18
+ connection = payload[:connection]
19
+ return unless connection
20
+
21
+ case SqlEventClassifier.classify(payload)
22
+ when :cache, :schema
23
+ nil
24
+ when :begin
25
+ start_trace(connection, start, finish, payload)
26
+ when :commit, :rollback
27
+ registry.fetch(connection)&.record_terminal(start: start, finish: finish, sql: payload[:sql])
28
+ when :transaction
29
+ registry.fetch(connection)&.record_transaction(start: start, finish: finish, sql: payload[:sql])
30
+ when :sql
31
+ record_sql(connection, start, finish, payload)
32
+ end
33
+ rescue => error
34
+ Txnap.warn("sql subscriber failed: #{error.class}: #{error.message}")
35
+ end
36
+
37
+ private
38
+
39
+ def registry
40
+ Txnap.registry
41
+ end
42
+
43
+ def start_trace(connection, start, finish, payload)
44
+ registry.start(
45
+ connection,
46
+ Trace.new(start: start, finish: finish, sql: payload[:sql])
47
+ )
48
+ end
49
+
50
+ def record_sql(connection, start, finish, payload)
51
+ trace = registry.fetch(connection)
52
+ return unless trace
53
+
54
+ call_site = capture_call_site(trace, start)
55
+ lock_candidate = LockHeuristics.detect(payload[:sql], call_site: call_site)
56
+ trace.record_sql(
57
+ start: start,
58
+ finish: finish,
59
+ sql: payload[:sql],
60
+ name: payload[:name],
61
+ call_site: call_site,
62
+ lock_candidate: lock_candidate
63
+ )
64
+ end
65
+
66
+ def capture_call_site(trace, start)
67
+ case Txnap.configuration.capture_call_sites
68
+ when :always
69
+ CallSite.capture
70
+ when :sampled
71
+ CallSite.capture if trace.would_update_longest_gap?(start)
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Txnap
4
+ module Subscribers
5
+ class TransactionSubscriber
6
+ class << self
7
+ def install
8
+ @subscriptions ||= begin
9
+ subscriber = new
10
+ [
11
+ ActiveSupport::Notifications.monotonic_subscribe("start_transaction.active_record") do |*args|
12
+ subscriber.start(*args)
13
+ end,
14
+ ActiveSupport::Notifications.monotonic_subscribe("transaction.active_record") do |*args|
15
+ subscriber.finish(*args)
16
+ end
17
+ ]
18
+ end
19
+ end
20
+ end
21
+
22
+ def start(_name, _start, _finish, _id, payload)
23
+ connection = payload[:connection]
24
+ return unless connection
25
+
26
+ Txnap.registry.bind_transaction(connection, payload[:transaction])
27
+ rescue => error
28
+ Txnap.warn("transaction start subscriber failed: #{error.class}: #{error.message}")
29
+ end
30
+
31
+ def finish(_name, _start, _finish, _id, payload)
32
+ connection = payload[:connection]
33
+ return unless connection
34
+
35
+ trace = Txnap.registry.finalize(connection, payload[:transaction])
36
+ Txnap.notify(trace&.to_report(payload[:outcome]))
37
+ rescue IdleGapDetected
38
+ raise
39
+ rescue => error
40
+ Txnap.warn("transaction finish subscriber failed: #{error.class}: #{error.message}")
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Txnap
4
+ class Trace
5
+ TOP_GAPS_LIMIT = 3
6
+ LOCK_CANDIDATES_LIMIT = 20
7
+ SQL_LENGTH_LIMIT = 500
8
+
9
+ attr_reader :started_at, :database_time, :sql_count, :transaction
10
+
11
+ def initialize(start:, finish:, sql:)
12
+ @started_at = finish
13
+ @previous_finish = finish
14
+ @previous_event = metadata(sql, "TRANSACTION", nil)
15
+ @database_time = positive_duration(start, finish)
16
+ @sql_count = 0
17
+ @gaps = []
18
+ @lock_candidates = []
19
+ @transaction = nil
20
+ @ended_at = nil
21
+ end
22
+
23
+ def bind_transaction(transaction)
24
+ @transaction ||= transaction
25
+ end
26
+
27
+ def matches_transaction?(candidate)
28
+ transaction && candidate && transaction.equal?(candidate)
29
+ end
30
+
31
+ def terminal?
32
+ !@ended_at.nil?
33
+ end
34
+
35
+ def would_update_longest_gap?(start)
36
+ gap_duration(start) > longest_gap_duration
37
+ end
38
+
39
+ def record_sql(start:, finish:, sql:, name:, call_site:, lock_candidate: nil)
40
+ event = metadata(sql, name, call_site)
41
+ observe_event(start: start, finish: finish, event: event)
42
+ @sql_count += 1
43
+ add_lock_candidate(lock_candidate)
44
+ end
45
+
46
+ def record_transaction(start:, finish:, sql:)
47
+ observe_event(
48
+ start: start,
49
+ finish: finish,
50
+ event: metadata(sql, "TRANSACTION", nil)
51
+ )
52
+ end
53
+
54
+ def record_terminal(start:, finish:, sql:)
55
+ effective_start = [start, @previous_finish].max
56
+ @ended_at = effective_start
57
+ record_transaction(start: start, finish: finish, sql: sql)
58
+ end
59
+
60
+ def to_report(outcome)
61
+ return unless terminal?
62
+
63
+ Report.new(
64
+ outcome: outcome,
65
+ transaction_duration: positive_duration(started_at, @ended_at),
66
+ database_time: database_time,
67
+ gaps: @gaps.dup,
68
+ sql_count: sql_count
69
+ )
70
+ end
71
+
72
+ private
73
+
74
+ def observe_event(start:, finish:, event:)
75
+ effective_start = [start, @previous_finish].max
76
+ effective_finish = [finish, effective_start].max
77
+
78
+ observe_gap(effective_start, event)
79
+ @database_time += effective_finish - effective_start
80
+ @previous_finish = effective_finish
81
+ @previous_event = event
82
+ end
83
+
84
+ def observe_gap(next_start, next_event)
85
+ gap = Gap.new(
86
+ duration: gap_duration(next_start),
87
+ after: @previous_event,
88
+ before: next_event,
89
+ locks: @lock_candidates.dup.freeze
90
+ )
91
+ @gaps << gap
92
+ @gaps.sort_by! { |candidate| -candidate.duration }
93
+ @gaps.pop while @gaps.length > TOP_GAPS_LIMIT
94
+ end
95
+
96
+ def gap_duration(next_start)
97
+ [next_start - @previous_finish, 0.0].max
98
+ end
99
+
100
+ def longest_gap_duration
101
+ @gaps.first&.duration || 0.0
102
+ end
103
+
104
+ def add_lock_candidate(candidate)
105
+ return unless candidate
106
+ return if @lock_candidates.any? do |existing|
107
+ existing.statement == candidate.statement && existing.table == candidate.table
108
+ end
109
+ return if @lock_candidates.length >= LOCK_CANDIDATES_LIMIT
110
+
111
+ @lock_candidates << candidate
112
+ end
113
+
114
+ def metadata(sql, name, call_site)
115
+ normalized_sql = Array(sql).join(" ").gsub(/\s+/, " ").strip
116
+ normalized_sql = "#{normalized_sql[0, SQL_LENGTH_LIMIT - 1]}…" if normalized_sql.length > SQL_LENGTH_LIMIT
117
+ EventMetadata.new(sql: normalized_sql.freeze, name: name, call_site: call_site).freeze
118
+ end
119
+
120
+ def positive_duration(start, finish)
121
+ [finish - start, 0.0].max
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Txnap
4
+ VERSION = "0.1.0"
5
+ end
data/lib/txnap.rb ADDED
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ require "active_support"
6
+ require "active_support/isolated_execution_state"
7
+ require "active_support/notifications"
8
+
9
+ require_relative "txnap/version"
10
+ require_relative "txnap/errors"
11
+ require_relative "txnap/configuration"
12
+ require_relative "txnap/execution_state"
13
+ require_relative "txnap/sql_event_classifier"
14
+ require_relative "txnap/call_site"
15
+ require_relative "txnap/lock_heuristics"
16
+ require_relative "txnap/report"
17
+ require_relative "txnap/trace"
18
+ require_relative "txnap/registry"
19
+ require_relative "txnap/formatter"
20
+ require_relative "txnap/notifier"
21
+ require_relative "txnap/subscribers/sql_subscriber"
22
+ require_relative "txnap/subscribers/transaction_subscriber"
23
+
24
+ module Txnap
25
+ class << self
26
+ def configure
27
+ yield(configuration)
28
+ configuration.validate!
29
+ end
30
+
31
+ def configuration
32
+ @configuration ||= Configuration.new
33
+ end
34
+
35
+ def registry
36
+ @registry ||= Registry.new { |message| warn(message) }
37
+ end
38
+
39
+ def install!
40
+ return false if @installed
41
+
42
+ Subscribers::SqlSubscriber.install
43
+ Subscribers::TransactionSubscriber.install
44
+ @installed = true
45
+ end
46
+
47
+ def suppress(&block)
48
+ ExecutionState.suppress(&block)
49
+ end
50
+
51
+ def logger
52
+ configuration.logger || rails_logger || default_logger
53
+ end
54
+
55
+ def warn(message)
56
+ logger.warn("[txnap] #{message}")
57
+ rescue
58
+ nil
59
+ end
60
+
61
+ def notify(report)
62
+ Notifier.new(configuration: configuration, logger: logger).call(report)
63
+ end
64
+
65
+ def reset!
66
+ @configuration = Configuration.new
67
+ registry.reset!
68
+ end
69
+
70
+ private
71
+
72
+ def rails_logger
73
+ Rails.logger if defined?(Rails) && Rails.respond_to?(:logger)
74
+ end
75
+
76
+ def default_logger
77
+ @default_logger ||= Logger.new($stderr)
78
+ end
79
+ end
80
+ end
81
+
82
+ require_relative "txnap/railtie" if defined?(Rails::Railtie)
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ active_record_version = ARGV.fetch(0) { ENV.fetch("ACTIVE_RECORD_VERSION") }
6
+ adapter = ARGV.fetch(1) { ENV.fetch("ADAPTER", "sqlite3") }
7
+
8
+ gem "activerecord", active_record_version
9
+ gem (adapter == "postgresql") ? "pg" : "sqlite3"
10
+
11
+ require "active_record"
12
+ require "active_support/notifications"
13
+
14
+ database_config =
15
+ if adapter == "postgresql"
16
+ {
17
+ adapter: "postgresql",
18
+ url: ARGV.fetch(2) do
19
+ ENV.fetch("DATABASE_URL", "postgresql://postgres:postgres@127.0.0.1:55433/txnap")
20
+ end
21
+ }
22
+ else
23
+ {adapter: "sqlite3", database: ":memory:"}
24
+ end
25
+
26
+ ActiveRecord::Base.establish_connection(database_config)
27
+ connection = ActiveRecord::Base.connection
28
+ connection.create_table(:event_probe_records, force: true) do |table|
29
+ table.string :name, null: false
30
+ end
31
+
32
+ class EventProbeRecord < ActiveRecord::Base
33
+ end
34
+
35
+ events = []
36
+ subscriptions = %w[
37
+ sql.active_record
38
+ start_transaction.active_record
39
+ transaction.active_record
40
+ ].map do |event_name|
41
+ ActiveSupport::Notifications.monotonic_subscribe(event_name) do |name, start, finish, _id, payload|
42
+ events << {
43
+ event: name,
44
+ start: start,
45
+ finish: finish,
46
+ duration_ms: (finish - start) * 1000,
47
+ name: payload[:name],
48
+ sql: payload[:sql],
49
+ cached: payload[:cached],
50
+ outcome: payload[:outcome],
51
+ connection_id: payload[:connection]&.object_id,
52
+ transaction_id: payload[:transaction]&.object_id
53
+ }.compact
54
+ end
55
+ end
56
+
57
+ ActiveRecord::Base.transaction do
58
+ sleep 0.01
59
+ EventProbeRecord.create!(name: "commit")
60
+
61
+ ActiveRecord::Base.transaction(requires_new: true) do
62
+ EventProbeRecord.create!(name: "savepoint")
63
+ end
64
+
65
+ connection.cache do
66
+ EventProbeRecord.first
67
+ EventProbeRecord.first
68
+ end
69
+ sleep 0.01
70
+ end
71
+
72
+ begin
73
+ ActiveRecord::Base.transaction do
74
+ EventProbeRecord.create!(name: "rollback")
75
+ raise ActiveRecord::Rollback
76
+ end
77
+
78
+ ActiveRecord::Base.transaction do
79
+ ActiveRecord::Base.transaction(requires_new: true) do
80
+ EventProbeRecord.create!(name: "restart")
81
+ raise ActiveRecord::Rollback
82
+ end
83
+ EventProbeRecord.create!(name: "after restart")
84
+ end
85
+ ensure
86
+ subscriptions.each { |subscription| ActiveSupport::Notifications.unsubscribe(subscription) }
87
+ end
88
+
89
+ puts JSON.pretty_generate(
90
+ active_record: ActiveRecord.version.to_s,
91
+ adapter: connection.adapter_name,
92
+ connection_id: connection.object_id,
93
+ events: events
94
+ )