llm_cost_tracker 0.5.3 → 0.6.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 +4 -4
- data/CHANGELOG.md +30 -0
- data/app/controllers/llm_cost_tracker/calls_controller.rb +35 -21
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +3 -1
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +4 -5
- data/docs/architecture.md +1 -1
- data/docs/configuration.md +1 -1
- data/docs/technical/data-flow.md +8 -5
- data/docs/technical/operational-notes.md +21 -1
- data/docs/upgrading.md +1 -0
- data/lib/llm_cost_tracker/active_record_adapter.rb +49 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +117 -0
- data/lib/llm_cost_tracker/doctor.rb +2 -0
- data/lib/llm_cost_tracker/event.rb +1 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +33 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +14 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +30 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +3 -1
- data/lib/llm_cost_tracker/inbox_event.rb +9 -0
- data/lib/llm_cost_tracker/ingestor_lease.rb +9 -0
- data/lib/llm_cost_tracker/period_grouping.rb +4 -3
- data/lib/llm_cost_tracker/pricing/lookup.rb +44 -11
- data/lib/llm_cost_tracker/railtie.rb +1 -0
- data/lib/llm_cost_tracker/storage/active_record_backend.rb +54 -3
- data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +13 -0
- data/lib/llm_cost_tracker/storage/active_record_inbox.rb +165 -0
- data/lib/llm_cost_tracker/storage/active_record_inbox_batch.rb +92 -0
- data/lib/llm_cost_tracker/storage/active_record_ingestor.rb +174 -0
- data/lib/llm_cost_tracker/storage/active_record_ingestor_lease.rb +38 -0
- data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +84 -0
- data/lib/llm_cost_tracker/storage/active_record_periods.rb +31 -0
- data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +41 -0
- data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +42 -0
- data/lib/llm_cost_tracker/storage/active_record_rollups.rb +31 -69
- data/lib/llm_cost_tracker/storage/active_record_store.rb +42 -9
- data/lib/llm_cost_tracker/stream_collector.rb +18 -7
- data/lib/llm_cost_tracker/tag_sql.rb +3 -3
- data/lib/llm_cost_tracker/tags_column.rb +7 -1
- data/lib/llm_cost_tracker/tracker.rb +3 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +36 -1
- metadata +17 -2
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "active_record_adapter"
|
|
4
|
+
|
|
3
5
|
module LlmCostTracker
|
|
4
6
|
module PeriodGrouping
|
|
5
7
|
PERIOD_FORMATS = {
|
|
@@ -34,10 +36,9 @@ module LlmCostTracker
|
|
|
34
36
|
column = period_column_expression(column)
|
|
35
37
|
formats = PERIOD_FORMATS.fetch(period)
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
when /postgres/i
|
|
39
|
+
if ActiveRecordAdapter.postgresql?(connection)
|
|
39
40
|
postgres_period_expression(period, column, formats)
|
|
40
|
-
|
|
41
|
+
elsif ActiveRecordAdapter.mysql?(connection)
|
|
41
42
|
"DATE_FORMAT(#{column}, #{connection.quote(formats.fetch(:mysql))})"
|
|
42
43
|
else
|
|
43
44
|
"strftime(#{connection.quote(formats.fetch(:sqlite))}, #{column})"
|
|
@@ -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
|
-
|
|
20
|
-
|
|
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[:
|
|
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[:
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
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,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
|