llm_cost_tracker 0.5.3 → 0.6.1

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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/app/controllers/llm_cost_tracker/calls_controller.rb +35 -21
  4. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +3 -1
  5. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +4 -5
  6. data/lib/llm_cost_tracker/active_record_adapter.rb +49 -0
  7. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +117 -0
  8. data/lib/llm_cost_tracker/doctor.rb +2 -0
  9. data/lib/llm_cost_tracker/event.rb +1 -0
  10. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +29 -0
  11. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +33 -0
  12. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +14 -6
  13. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -4
  14. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +30 -3
  15. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +1 -1
  16. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +3 -1
  17. data/lib/llm_cost_tracker/inbox_event.rb +9 -0
  18. data/lib/llm_cost_tracker/ingestor_lease.rb +9 -0
  19. data/lib/llm_cost_tracker/period_grouping.rb +4 -3
  20. data/lib/llm_cost_tracker/pricing/lookup.rb +44 -11
  21. data/lib/llm_cost_tracker/railtie.rb +1 -0
  22. data/lib/llm_cost_tracker/storage/active_record_backend.rb +54 -3
  23. data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +13 -0
  24. data/lib/llm_cost_tracker/storage/active_record_inbox.rb +165 -0
  25. data/lib/llm_cost_tracker/storage/active_record_inbox_batch.rb +92 -0
  26. data/lib/llm_cost_tracker/storage/active_record_ingestor.rb +174 -0
  27. data/lib/llm_cost_tracker/storage/active_record_ingestor_lease.rb +38 -0
  28. data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +84 -0
  29. data/lib/llm_cost_tracker/storage/active_record_periods.rb +31 -0
  30. data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +41 -0
  31. data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +42 -0
  32. data/lib/llm_cost_tracker/storage/active_record_rollups.rb +31 -69
  33. data/lib/llm_cost_tracker/storage/active_record_store.rb +42 -9
  34. data/lib/llm_cost_tracker/stream_collector.rb +18 -7
  35. data/lib/llm_cost_tracker/tag_sql.rb +3 -3
  36. data/lib/llm_cost_tracker/tags_column.rb +7 -1
  37. data/lib/llm_cost_tracker/tracker.rb +3 -0
  38. data/lib/llm_cost_tracker/version.rb +1 -1
  39. data/lib/llm_cost_tracker.rb +36 -1
  40. metadata +17 -19
  41. data/docs/architecture.md +0 -28
  42. data/docs/budgets.md +0 -45
  43. data/docs/configuration.md +0 -65
  44. data/docs/cookbook.md +0 -185
  45. data/docs/dashboard-overview.png +0 -0
  46. data/docs/dashboard.md +0 -38
  47. data/docs/extending.md +0 -32
  48. data/docs/operations.md +0 -44
  49. data/docs/pricing.md +0 -94
  50. data/docs/querying.md +0 -36
  51. data/docs/streaming.md +0 -70
  52. data/docs/technical/README.md +0 -10
  53. data/docs/technical/data-flow.md +0 -67
  54. data/docs/technical/extension-points.md +0 -111
  55. data/docs/technical/module-map.md +0 -197
  56. data/docs/technical/operational-notes.md +0 -77
  57. data/docs/upgrading.md +0 -46
@@ -7,41 +7,74 @@ module LlmCostTracker
7
7
  module Lookup
8
8
  Match = Data.define(:source, :key, :prices, :matched_by)
9
9
  MUTEX = Monitor.new
10
+ CACHE_MISS = Object.new.freeze
11
+ NO_MATCH = Object.new.freeze
12
+ MAX_LOOKUP_CACHE_ENTRIES = 512
10
13
 
11
14
  class << self
12
15
  def call(provider:, model:)
13
16
  provider_name = provider.to_s
14
17
  model_name = model.to_s
18
+ generation = LlmCostTracker.configuration_generation
19
+ cache_key = [generation, provider_name, model_name]
20
+ cached = cached_lookup(cache_key)
21
+ return cached unless cached.equal?(CACHE_MISS)
22
+
15
23
  provider_model = provider_name.empty? ? model_name : "#{provider_name}/#{model_name}"
16
24
  normalized_model = normalize_model_name(model_name)
17
- current = current_price_tables
25
+ current = current_price_tables(generation)
18
26
 
19
- explain_table(current.fetch(:pricing_overrides), :pricing_overrides, provider_model, model_name,
20
- normalized_model) ||
27
+ match =
28
+ explain_table(current.fetch(:pricing_overrides), :pricing_overrides, provider_model, model_name,
29
+ normalized_model) ||
21
30
  explain_table(current.fetch(:file_prices), :prices_file, provider_model, model_name, normalized_model) ||
22
31
  explain_table(Pricing::PRICES, :bundled, provider_model, model_name, normalized_model)
32
+ cache_lookup(cache_key, match)
33
+ match
23
34
  end
24
35
 
25
36
  private
26
37
 
27
- def current_price_tables
28
- file_prices = PriceRegistry.file_prices(LlmCostTracker.configuration.prices_file)
29
- overrides = PriceRegistry.normalize_price_table(LlmCostTracker.configuration.pricing_overrides)
30
- cache_key = [file_prices.object_id, LlmCostTracker.configuration.pricing_overrides.hash]
31
-
38
+ def current_price_tables(generation)
32
39
  cached = @prices_cache
33
- return cached[:value] if cached && cached[:key] == cache_key
40
+ return cached[:value] if cached && cached[:generation] == generation
34
41
 
35
42
  MUTEX.synchronize do
36
43
  cached = @prices_cache
37
- return cached[:value] if cached && cached[:key] == cache_key
44
+ return cached[:value] if cached && cached[:generation] == generation
38
45
 
46
+ config = LlmCostTracker.configuration
47
+ file_prices = PriceRegistry.file_prices(config.prices_file)
48
+ overrides = PriceRegistry.normalize_price_table(config.pricing_overrides)
39
49
  value = { pricing_overrides: overrides, file_prices: file_prices }.freeze
40
- @prices_cache = { key: cache_key, value: value }.freeze
50
+ @prices_cache = { generation: generation, value: value }.freeze
41
51
  value
42
52
  end
43
53
  end
44
54
 
55
+ def cached_lookup(cache_key)
56
+ cached = @lookup_cache
57
+ return CACHE_MISS unless cached && cached[:generation] == cache_key.first
58
+ return CACHE_MISS unless cached[:values].key?(cache_key)
59
+
60
+ match = cached[:values].fetch(cache_key)
61
+ match.equal?(NO_MATCH) ? nil : match
62
+ end
63
+
64
+ def cache_lookup(cache_key, match)
65
+ MUTEX.synchronize do
66
+ cached = @lookup_cache
67
+ values = if cached && cached[:generation] == cache_key.first
68
+ cached[:values].dup
69
+ else
70
+ {}
71
+ end
72
+ values.clear if values.size >= MAX_LOOKUP_CACHE_ENTRIES
73
+ values[cache_key] = match || NO_MATCH
74
+ @lookup_cache = { generation: cache_key.first, values: values.freeze }.freeze
75
+ end
76
+ end
77
+
45
78
  def explain_table(table, source, provider_model, model_name, normalized_model)
46
79
  return nil if table.empty?
47
80
 
@@ -3,6 +3,7 @@
3
3
  module LlmCostTracker
4
4
  class Railtie < Rails::Railtie
5
5
  generators do
6
+ require_relative "generators/llm_cost_tracker/add_ingestion_generator"
6
7
  require_relative "generators/llm_cost_tracker/add_period_totals_generator"
7
8
  require_relative "generators/llm_cost_tracker/add_latency_ms_generator"
8
9
  require_relative "generators/llm_cost_tracker/add_provider_response_id_generator"
@@ -3,6 +3,8 @@
3
3
  require "securerandom"
4
4
 
5
5
  require_relative "registry"
6
+ require_relative "active_record_inbox"
7
+ require_relative "active_record_ingestor"
6
8
  require_relative "active_record_store"
7
9
 
8
10
  module LlmCostTracker
@@ -14,7 +16,11 @@ module LlmCostTracker
14
16
  def save(event)
15
17
  require_relative "../llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
16
18
 
17
- ActiveRecordStore.save(event)
19
+ if ActiveRecordInbox.enabled?
20
+ ActiveRecordInbox.save(event)
21
+ else
22
+ ActiveRecordStore.save(event)
23
+ end
18
24
  event
19
25
  rescue LoadError => e
20
26
  raise Error, "ActiveRecord storage requires the active_record gem: #{e.message}"
@@ -49,6 +55,8 @@ module LlmCostTracker
49
55
  private
50
56
 
51
57
  def active_record_capture_check
58
+ return active_record_inbox_capture_check if ActiveRecordInbox.enabled?
59
+
52
60
  provider, model = sample_priced_identity
53
61
  response_id = "lct_verify_#{SecureRandom.hex(8)}"
54
62
  notifications = []
@@ -81,17 +89,51 @@ module LlmCostTracker
81
89
  ActiveSupport::Notifications.unsubscribe(subscription) if subscription
82
90
  end
83
91
 
92
+ def active_record_inbox_capture_check
93
+ provider, model = sample_priced_identity
94
+ response_id = "lct_verify_#{SecureRandom.hex(8)}"
95
+ notifications = []
96
+ subscription = subscribe_to_verification(response_id, notifications)
97
+
98
+ event = LlmCostTracker.track(
99
+ provider: provider,
100
+ model: model,
101
+ input_tokens: 1,
102
+ output_tokens: 1,
103
+ provider_response_id: response_id,
104
+ feature: VERIFY_TAG
105
+ )
106
+ LlmCostTracker.flush!
107
+ persisted = LlmCostTracker::LlmApiCall.where(provider_response_id: response_id).exists?
108
+
109
+ if persisted && notifications.any?
110
+ return active_record_capture_success("manual event emitted and persisted through durable inbox")
111
+ end
112
+
113
+ VerificationResult.new(:error, "active_record capture", capture_failure_message(persisted, notifications))
114
+ rescue LlmCostTracker::BudgetExceededError => e
115
+ VerificationResult.new(:error, "active_record capture", "blocked by budget guardrail: #{e.message}")
116
+ rescue LlmCostTracker::Error => e
117
+ VerificationResult.new(:error, "active_record capture", e.message)
118
+ rescue StandardError => e
119
+ VerificationResult.new(:error, "active_record capture", "#{e.class}: #{e.message}")
120
+ ensure
121
+ cleanup_verification_call(response_id) if response_id
122
+ LlmCostTracker::InboxEvent.where(event_id: event.event_id).delete_all if event
123
+ ActiveSupport::Notifications.unsubscribe(subscription) if subscription
124
+ end
125
+
84
126
  def subscribe_to_verification(response_id, notifications)
85
127
  ActiveSupport::Notifications.subscribe(LlmCostTracker::Tracker::EVENT_NAME) do |*, payload|
86
128
  notifications << payload if payload[:provider_response_id] == response_id
87
129
  end
88
130
  end
89
131
 
90
- def active_record_capture_success
132
+ def active_record_capture_success(message = "manual event emitted and persisted inside rollback")
91
133
  VerificationResult.new(
92
134
  :ok,
93
135
  "active_record capture",
94
- "manual event emitted and persisted inside rollback"
136
+ message
95
137
  )
96
138
  end
97
139
 
@@ -102,6 +144,15 @@ module LlmCostTracker
102
144
  "missing #{missing.join(' and ')} for synthetic manual event"
103
145
  end
104
146
 
147
+ def cleanup_verification_call(response_id)
148
+ relation = LlmCostTracker::LlmApiCall.where(provider_response_id: response_id)
149
+ rows = relation.pluck(:id, :tracked_at, :total_cost)
150
+ return if rows.empty?
151
+
152
+ relation.delete_all
153
+ ActiveRecordRollups.decrement!(rows)
154
+ end
155
+
105
156
  def sample_priced_identity
106
157
  key = LlmCostTracker::PriceRegistry.builtin_prices.find do |model_id, prices|
107
158
  model_id.include?("/") && prices[:input] && prices[:output]
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Storage
5
+ module ActiveRecordConnectionCleanup
6
+ def self.release!
7
+ ActiveRecord::Base.connection_handler.clear_active_connections!
8
+ rescue StandardError
9
+ nil
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ require_relative "../active_record_adapter"
7
+ require_relative "../cost"
8
+ require_relative "../event"
9
+ require_relative "../inbox_event"
10
+ require_relative "active_record_periods"
11
+
12
+ module LlmCostTracker
13
+ module Storage
14
+ class ActiveRecordInbox
15
+ TABLE_NAME = "llm_cost_tracker_inbox_events"
16
+ LEASE_TABLE_NAME = "llm_cost_tracker_ingestor_leases"
17
+ MAX_ATTEMPTS = 5
18
+
19
+ class << self
20
+ def reset!
21
+ remove_instance_variable(:@enabled) if instance_variable_defined?(:@enabled)
22
+ end
23
+
24
+ def enabled?
25
+ return @enabled unless @enabled.nil?
26
+
27
+ model = LlmCostTracker::LlmApiCall
28
+ @enabled = model.columns_hash.key?("event_id") &&
29
+ model.connection.data_source_exists?(TABLE_NAME) &&
30
+ model.connection.data_source_exists?(LEASE_TABLE_NAME)
31
+ rescue StandardError
32
+ @enabled = false
33
+ end
34
+
35
+ def save(event)
36
+ insert_row(row_for(event))
37
+ ActiveRecordIngestor.ensure_started
38
+ event
39
+ end
40
+
41
+ def pending_period_totals(periods, time:)
42
+ return periods.to_h { |period| [period, 0.0] } unless enabled?
43
+
44
+ periods.to_h do |period|
45
+ [period, pending_period_total(period, time)]
46
+ end
47
+ end
48
+
49
+ def event_from_row(row)
50
+ payload = JSON.parse(row.payload)
51
+ cost = payload["cost"] && LlmCostTracker::Cost.new(**symbolize_keys(payload["cost"]))
52
+
53
+ LlmCostTracker::Event.new(
54
+ event_id: payload.fetch("event_id"),
55
+ provider: payload.fetch("provider"),
56
+ model: payload.fetch("model"),
57
+ input_tokens: payload.fetch("input_tokens"),
58
+ output_tokens: payload.fetch("output_tokens"),
59
+ total_tokens: payload.fetch("total_tokens"),
60
+ cache_read_input_tokens: payload.fetch("cache_read_input_tokens"),
61
+ cache_write_input_tokens: payload.fetch("cache_write_input_tokens"),
62
+ hidden_output_tokens: payload.fetch("hidden_output_tokens"),
63
+ pricing_mode: payload["pricing_mode"],
64
+ cost: cost,
65
+ tags: payload.fetch("tags"),
66
+ latency_ms: payload["latency_ms"],
67
+ stream: payload.fetch("stream"),
68
+ usage_source: payload["usage_source"],
69
+ provider_response_id: payload["provider_response_id"],
70
+ tracked_at: Time.iso8601(payload.fetch("tracked_at"))
71
+ )
72
+ end
73
+
74
+ private
75
+
76
+ def row_for(event)
77
+ now = Time.now.utc
78
+ {
79
+ event_id: event.event_id,
80
+ total_cost: event.cost&.total_cost,
81
+ tracked_at: event.tracked_at,
82
+ payload: JSON.generate(payload_for(event)),
83
+ attempts: 0,
84
+ created_at: now,
85
+ updated_at: now
86
+ }
87
+ end
88
+
89
+ def payload_for(event)
90
+ {
91
+ event_id: event.event_id,
92
+ provider: event.provider,
93
+ model: event.model,
94
+ input_tokens: event.input_tokens,
95
+ output_tokens: event.output_tokens,
96
+ total_tokens: event.total_tokens,
97
+ cache_read_input_tokens: event.cache_read_input_tokens,
98
+ cache_write_input_tokens: event.cache_write_input_tokens,
99
+ hidden_output_tokens: event.hidden_output_tokens,
100
+ pricing_mode: event.pricing_mode,
101
+ cost: event.cost&.to_h,
102
+ tags: event.tags || {},
103
+ latency_ms: event.latency_ms,
104
+ stream: event.stream,
105
+ usage_source: event.usage_source,
106
+ provider_response_id: event.provider_response_id,
107
+ tracked_at: event.tracked_at.iso8601(6)
108
+ }
109
+ end
110
+
111
+ def insert_row(row)
112
+ connection = LlmCostTracker::LlmApiCall.connection
113
+ if connection.transaction_open? && !sqlite_database?(connection)
114
+ insert_with_separate_connection(row)
115
+ else
116
+ execute_insert(connection, row)
117
+ end
118
+ rescue ActiveRecord::ConnectionTimeoutError => e
119
+ raise LlmCostTracker::Error,
120
+ "ActiveRecord inbox could not checkout a separate database connection: #{e.message}"
121
+ end
122
+
123
+ def insert_with_separate_connection(row)
124
+ pool = LlmCostTracker::LlmApiCall.connection_pool
125
+ connection = pool.checkout
126
+ begin
127
+ connection.transaction(requires_new: true) { execute_insert(connection, row) }
128
+ ensure
129
+ pool.checkin(connection)
130
+ end
131
+ end
132
+
133
+ def execute_insert(connection, row)
134
+ columns = row.keys
135
+ quoted_columns = columns.map { |column| connection.quote_column_name(column) }.join(", ")
136
+ quoted_values = columns.map { |column| connection.quote(row.fetch(column)) }.join(", ")
137
+ table = connection.quote_table_name(TABLE_NAME)
138
+
139
+ connection.execute("INSERT INTO #{table} (#{quoted_columns}) VALUES (#{quoted_values})")
140
+ end
141
+
142
+ def pending_period_total(period, time)
143
+ LlmCostTracker::InboxEvent
144
+ .where("attempts < ?", MAX_ATTEMPTS)
145
+ .where(tracked_at: period_range(period, time))
146
+ .sum(:total_cost)
147
+ .to_f
148
+ end
149
+
150
+ def period_range(period, time)
151
+ utc_time = time.to_time.utc
152
+ ActiveRecordPeriods.range_start(period, utc_time)..utc_time
153
+ end
154
+
155
+ def symbolize_keys(hash)
156
+ hash.transform_keys(&:to_sym)
157
+ end
158
+
159
+ def sqlite_database?(connection)
160
+ ActiveRecordAdapter.sqlite?(connection)
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../inbox_event"
4
+ require_relative "active_record_inbox"
5
+ require_relative "active_record_store"
6
+
7
+ module LlmCostTracker
8
+ module Storage
9
+ class ActiveRecordInboxBatch
10
+ BATCH_SIZE = 100
11
+ LOCK_TIMEOUT_SECONDS = 30
12
+
13
+ def initialize(identity:)
14
+ @identity = identity
15
+ end
16
+
17
+ def ingest
18
+ rows = claim
19
+ return 0 if rows.empty?
20
+
21
+ valid_rows, events = decode(rows)
22
+ persist(valid_rows, events) if events.any?
23
+ rows.size
24
+ rescue StandardError => e
25
+ rows_to_mark = valid_rows&.any? ? valid_rows : rows
26
+ mark_failed(rows_to_mark, e) if rows_to_mark&.any?
27
+ raise
28
+ end
29
+
30
+ def pending? = model.where("attempts < ?", ActiveRecordInbox::MAX_ATTEMPTS).exists?
31
+
32
+ def claimable? = claimable_scope(Time.now.utc - LOCK_TIMEOUT_SECONDS).exists?
33
+
34
+ def mark_failed(rows, error)
35
+ message = "#{error.class}: #{error.message}".byteslice(0, 1_000)
36
+ now = Time.now.utc
37
+ model
38
+ .where(id: rows.map(&:id), locked_by: identity)
39
+ .update_all(last_error: message, locked_at: now, locked_by: nil, updated_at: now)
40
+ rescue StandardError
41
+ nil
42
+ end
43
+
44
+ private
45
+
46
+ attr_reader :identity
47
+
48
+ def claim
49
+ now = Time.now.utc
50
+ cutoff = now - LOCK_TIMEOUT_SECONDS
51
+ model.transaction do
52
+ rows = claimable_scope(cutoff).order(:id).limit(BATCH_SIZE).lock.to_a
53
+ ids = rows.map(&:id)
54
+ next [] if ids.empty?
55
+
56
+ updates = model.sanitize_sql_array(
57
+ ["locked_at = ?, locked_by = ?, attempts = attempts + 1, updated_at = ?", now, identity, now]
58
+ )
59
+ model.where(id: ids).update_all(updates)
60
+ model.where(id: ids, locked_by: identity).order(:id).to_a
61
+ end
62
+ end
63
+
64
+ def decode(rows)
65
+ valid_rows = []
66
+ events = []
67
+ rows.each do |row|
68
+ events << ActiveRecordInbox.event_from_row(row)
69
+ valid_rows << row
70
+ rescue StandardError => e
71
+ mark_failed([row], e)
72
+ end
73
+ [valid_rows, events]
74
+ end
75
+
76
+ def persist(rows, events)
77
+ LlmCostTracker::LlmApiCall.transaction do
78
+ ActiveRecordStore.insert_many(events)
79
+ model.where(id: rows.map(&:id), locked_by: identity).delete_all
80
+ end
81
+ end
82
+
83
+ def claimable_scope(cutoff)
84
+ model
85
+ .where("attempts < ?", ActiveRecordInbox::MAX_ATTEMPTS)
86
+ .where("locked_at IS NULL OR locked_at < ?", cutoff)
87
+ end
88
+
89
+ def model = LlmCostTracker::InboxEvent
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ require_relative "../inbox_event"
6
+ require_relative "../logging"
7
+ require_relative "active_record_connection_cleanup"
8
+ require_relative "active_record_inbox"
9
+ require_relative "active_record_inbox_batch"
10
+ require_relative "active_record_ingestor_lease"
11
+
12
+ module LlmCostTracker
13
+ module Storage
14
+ class ActiveRecordIngestor
15
+ INTERVAL_SECONDS = 0.25
16
+ IDLE_INTERVAL_SECONDS = 1.0
17
+ MAX_IDLE_INTERVAL_SECONDS = 5.0
18
+ LEASE_SECONDS = 10
19
+ FLUSH_TIMEOUT_SECONDS = 10
20
+ class << self
21
+ def ensure_started
22
+ return unless ActiveRecordInbox.enabled?
23
+
24
+ thread = mutex.synchronize do
25
+ reset_after_fork!
26
+ unless @thread&.alive?
27
+ @stop_requested = false
28
+ generation = next_generation
29
+ @thread = Thread.new { run(generation) }
30
+ @thread.name = "llm_cost_tracker_ingestor" if @thread.respond_to?(:name=)
31
+ @thread.report_on_exception = false if @thread.respond_to?(:report_on_exception=)
32
+ end
33
+ @thread
34
+ end
35
+ wake_thread(thread)
36
+ end
37
+
38
+ def flush!(timeout: FLUSH_TIMEOUT_SECONDS, require_lease: false)
39
+ return true unless ActiveRecordInbox.enabled?
40
+
41
+ deadline = Time.now.utc + timeout
42
+ loop do
43
+ return true unless pending_events?
44
+ return false if Time.now.utc >= deadline
45
+
46
+ processed = ingest_once(require_lease: require_lease)
47
+ return false if processed.zero? && !sleep_until_next_flush(deadline)
48
+ end
49
+ end
50
+
51
+ def shutdown!(timeout: FLUSH_TIMEOUT_SECONDS, drain: true)
52
+ ActiveRecordInbox.reset!
53
+ thread = mutex.synchronize do
54
+ @stop_requested = true
55
+ next_generation
56
+ @thread
57
+ end
58
+ wake_thread(thread)
59
+ thread&.join([timeout, 1].min)
60
+ drain ? flush!(timeout: timeout, require_lease: true) : true
61
+ rescue StandardError => e
62
+ handle_error(e)
63
+ false
64
+ ensure
65
+ mutex.synchronize { @thread = nil if @thread.equal?(thread) }
66
+ end
67
+
68
+ def reset!
69
+ thread = mutex.synchronize do
70
+ @stop_requested = true
71
+ next_generation
72
+ thread = @thread
73
+ @thread = nil
74
+ @pid = nil
75
+ @identity = nil
76
+ thread
77
+ end
78
+ wake_thread(thread)
79
+ end
80
+
81
+ def ingest_once(require_lease: true)
82
+ return 0 unless ActiveRecordInbox.enabled?
83
+ return 0 unless claimable_events?
84
+ return 0 if require_lease && !acquire_lease
85
+
86
+ inbox_batch.ingest
87
+ rescue StandardError => e
88
+ handle_error(e)
89
+ 0
90
+ end
91
+
92
+ private
93
+
94
+ def mutex = @mutex ||= Mutex.new
95
+
96
+ def run(generation)
97
+ idle_interval = IDLE_INTERVAL_SECONDS
98
+ loop do
99
+ break if stop_requested?(generation)
100
+
101
+ processed = executor_wrap { ingest_once }
102
+ ActiveRecordConnectionCleanup.release!
103
+ if processed.zero?
104
+ sleep(idle_interval)
105
+ idle_interval = [idle_interval * 2, MAX_IDLE_INTERVAL_SECONDS].min
106
+ else
107
+ idle_interval = IDLE_INTERVAL_SECONDS
108
+ end
109
+ rescue StandardError => e
110
+ handle_error(e)
111
+ ActiveRecordConnectionCleanup.release!
112
+ sleep(idle_interval)
113
+ end
114
+ ensure
115
+ ActiveRecordConnectionCleanup.release!
116
+ mutex.synchronize { @thread = nil if @thread.equal?(Thread.current) }
117
+ end
118
+
119
+ def sleep_until_next_flush(deadline)
120
+ duration = [INTERVAL_SECONDS, deadline - Time.now.utc].min
121
+ sleep(duration) if duration.positive?
122
+ end
123
+
124
+ def stop_requested?(generation) = mutex.synchronize { @stop_requested || generation != @generation }
125
+
126
+ def reset_after_fork!
127
+ return if @pid == Process.pid
128
+
129
+ @pid = Process.pid
130
+ @thread = nil
131
+ @identity = nil
132
+ end
133
+
134
+ def next_generation = (@generation = @generation.to_i + 1)
135
+
136
+ def wake_thread(thread)
137
+ thread&.wakeup if thread&.alive?
138
+ rescue ThreadError
139
+ nil
140
+ end
141
+
142
+ def executor_wrap(&)
143
+ executor = rails_executor
144
+ return yield unless executor
145
+
146
+ executor.wrap(&)
147
+ end
148
+
149
+ def rails_executor
150
+ return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application.respond_to?(:executor)
151
+
152
+ Rails.application.executor
153
+ rescue StandardError
154
+ nil
155
+ end
156
+
157
+ def identity = @identity ||= "pid-#{Process.pid}-#{SecureRandom.hex(6)}"
158
+
159
+ def acquire_lease = ActiveRecordIngestorLease.new(identity: identity, seconds: LEASE_SECONDS).acquire
160
+
161
+ def pending_events? = inbox_batch.pending?
162
+
163
+ def claimable_events? = inbox_batch.claimable?
164
+
165
+ def inbox_batch = ActiveRecordInboxBatch.new(identity: identity)
166
+
167
+ def handle_error(error)
168
+ Logging.warn("ActiveRecord ingestor failed: #{error.class}: #{error.message}") unless
169
+ LlmCostTracker.configuration.storage_error_behavior == :ignore
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../ingestor_lease"
4
+
5
+ module LlmCostTracker
6
+ module Storage
7
+ class ActiveRecordIngestorLease
8
+ LEASE_NAME = "default"
9
+
10
+ def initialize(identity:, seconds:)
11
+ @identity = identity
12
+ @seconds = seconds
13
+ end
14
+
15
+ def acquire
16
+ now = Time.now.utc
17
+ LlmCostTracker::IngestorLease.transaction do
18
+ lease = LlmCostTracker::IngestorLease.lock.find_by(name: LEASE_NAME)
19
+ lease ||= LlmCostTracker::IngestorLease.create!(name: LEASE_NAME)
20
+ next false unless available?(lease, now)
21
+
22
+ lease.update!(locked_by: identity, locked_until: now + seconds)
23
+ true
24
+ end
25
+ rescue ActiveRecord::RecordNotUnique
26
+ false
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :identity, :seconds
32
+
33
+ def available?(lease, now)
34
+ lease.locked_by.nil? || lease.locked_by == identity || lease.locked_until.nil? || lease.locked_until < now
35
+ end
36
+ end
37
+ end
38
+ end