truffler 0.1.4 → 0.1.6

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/README.md +79 -12
  4. data/lib/generators/truffler/install/templates/migration.rb.tt +14 -11
  5. data/lib/generators/truffler/upgrade/templates/backfill_spends_migration.rb.tt +1 -1
  6. data/lib/generators/truffler/upgrade/templates/backfill_spends_tenant_key_migration.rb.tt +25 -0
  7. data/lib/generators/truffler/upgrade/templates/labels_search_covering_migration.rb.tt +32 -0
  8. data/lib/generators/truffler/upgrade/upgrade_generator.rb +49 -3
  9. data/lib/tasks/truffler.rake +28 -13
  10. data/lib/truffler/benchmark/runner.rb +1 -1
  11. data/lib/truffler/clients/evaluator.rb +44 -0
  12. data/lib/truffler/configuration.rb +12 -0
  13. data/lib/truffler/current.rb +30 -0
  14. data/lib/truffler/definition.rb +52 -0
  15. data/lib/truffler/embeddings/backfill.rb +40 -11
  16. data/lib/truffler/embeddings/label_vector.rb +1 -1
  17. data/lib/truffler/embeddings/neighbor_store.rb +33 -7
  18. data/lib/truffler/embeddings/vector_store.rb +9 -3
  19. data/lib/truffler/jobs/backfill_job.rb +14 -5
  20. data/lib/truffler/jobs/embed_job.rb +2 -0
  21. data/lib/truffler/jobs/label_flush_job.rb +4 -2
  22. data/lib/truffler/jobs/resume_job.rb +22 -9
  23. data/lib/truffler/label_definition.rb +17 -3
  24. data/lib/truffler/labeling/backfill.rb +115 -37
  25. data/lib/truffler/labeling/labeler.rb +58 -41
  26. data/lib/truffler/labeling/queue.rb +14 -9
  27. data/lib/truffler/labeling/supplied.rb +16 -5
  28. data/lib/truffler/lenses/backfill.rb +28 -10
  29. data/lib/truffler/model.rb +5 -0
  30. data/lib/truffler/providers/backup.rb +1 -1
  31. data/lib/truffler/query_encoding/encoder.rb +75 -49
  32. data/lib/truffler/query_encoding/present_options.rb +54 -0
  33. data/lib/truffler/records/backfill_spend.rb +60 -6
  34. data/lib/truffler/redaction.rb +1 -1
  35. data/lib/truffler/search/encoding.rb +41 -10
  36. data/lib/truffler/search/encoding_cache.rb +12 -1
  37. data/lib/truffler/search/filler.rb +31 -6
  38. data/lib/truffler/search/keystroke.rb +60 -29
  39. data/lib/truffler/search/relaxation.rb +95 -0
  40. data/lib/truffler/search/result.rb +21 -5
  41. data/lib/truffler/search/sql.rb +81 -10
  42. data/lib/truffler/smart_search/dispatcher.rb +36 -24
  43. data/lib/truffler/smart_search/reranker.rb +7 -5
  44. data/lib/truffler/smart_search/run.rb +10 -3
  45. data/lib/truffler/smart_search/starter.rb +13 -11
  46. data/lib/truffler/version.rb +1 -1
  47. data/lib/truffler/vocabulary.rb +10 -0
  48. metadata +7 -1
@@ -2,20 +2,28 @@ module Truffler
2
2
  module Labeling
3
3
  # Relabels one model's records whose labels are missing, stale (labeled
4
4
  # under another vocabulary version), failed, or demoted to backfill
5
- # priority. It walks newest-first below an id cursor, splits each page by
6
- # tenant, packs batch_size records per request at backfill priority, and
7
- # asks only the stale questions. Live pending rows belong to the flush job
8
- # and are never touched.
5
+ # priority. It walks newest-first below an id cursor over the
6
+ # definition's index_scope (one tenant's records with `tenant_key:`),
7
+ # splits each page by tenant, skips disabled tenants, packs batch_size
8
+ # records per request at backfill priority, and asks only the stale
9
+ # questions. Live pending rows belong to the flush job and are never
10
+ # touched.
9
11
  #
10
12
  # Resumable: the cursor moves past a page only once the whole page is
11
13
  # done, and records already current are skipped, so a rerun never asks
12
14
  # Jev about them again. A spend cap stops the run before a request would
13
15
  # exceed it; host-supplied labels cost nothing, so they are still written
14
16
  # once the cap is reached. The cap counts everything spent under the
15
- # model's current app-wide vocabulary version, kept in the
16
- # truffler_backfill_spends ledger, so reruns and overlapping jobs share
17
- # it; without that table it falls back to this run plus `spent:`. A Jev error releases the claimed rows and ends
18
- # the run with `:client_error`, so the caller keeps the spend metered so far.
17
+ # current ledger version (the asked labels' fingerprints, so supplied
18
+ # labels never reset it) in the truffler_backfill_spends ledger, so
19
+ # reruns and overlapping jobs share it. Tenant-scoped models keep one
20
+ # ledger per tenant (backfill_spend_cap_scope :tenant, the default), so
21
+ # the cap applies to each tenant: a whole-model run skips a tenant at its
22
+ # cap, keeps labeling the others, and ends with `:spend_cap_reached`; a
23
+ # tenant run stops there. Unscoped models, and :app, keep one app-wide
24
+ # ledger. Without that table the cap falls back to this run plus
25
+ # `spent:`. A Jev error releases the claimed rows and ends the run with
26
+ # `:client_error`, so the caller keeps the spend metered so far.
19
27
  #
20
28
  # A budget denial ends the run with `:budget_denied`, unless the run
21
29
  # waits: then it backs off (see .backoff) and retries from the same
@@ -94,29 +102,46 @@ module Truffler
94
102
  end
95
103
  end
96
104
 
97
- def self.status(model)
98
- new(model).status
105
+ def self.status(model, tenant_key: nil)
106
+ new(model, tenant_key: tenant_key).status
99
107
  end
100
108
 
101
- # The ledger row for the model's current vocabulary version, or nil
102
- # when nothing was spent yet or the ledger table is missing.
103
- def self.spend(model)
109
+ # The ledger row for the current ledger version (the tenant's, for a
110
+ # tenant ledger), or nil when nothing was spent yet or the ledger table
111
+ # is missing. A row still keyed by the pre-0.1.6 vocabulary version
112
+ # counts until a backfill takes it over.
113
+ def self.spend(model, tenant_key: nil)
104
114
  return unless Records::BackfillSpend.available?
105
115
 
106
- Records::BackfillSpend.for_model(model).find_by(vocabulary_version: ledger_version(model))
116
+ ledgers = Records::BackfillSpend.for_ledger(model, tenant_key)
117
+ ledgers.find_by(vocabulary_version: ledger_version(model, tenant_key)) ||
118
+ ledgers.find_by(vocabulary_version: legacy_ledger_version(model, tenant_key))
107
119
  end
108
120
 
109
121
  # Zeroes the current vocabulary version's ledger in place, so a chain
110
122
  # still running keeps its row and continues against the fresh total.
111
- def self.reset_spend!(model)
123
+ # `all_tenants: true` zeroes every tenant ledger of the model.
124
+ def self.reset_spend!(model, tenant_key: nil, all_tenants: false)
112
125
  return unless Records::BackfillSpend.available?
113
126
 
114
- Records::BackfillSpend.for_model(model).where(vocabulary_version: ledger_version(model))
115
- .update_all(spent_usd: 0.0, requests: 0, updated_at: Time.current)
127
+ ledgers = if all_tenants && Records::BackfillSpend.tenant_ledgers?
128
+ Records::BackfillSpend.for_model(model).where.not(tenant_key: nil)
129
+ else
130
+ Records::BackfillSpend.for_ledger(model, tenant_key)
131
+ .where(vocabulary_version: [ ledger_version(model, tenant_key), legacy_ledger_version(model, tenant_key) ])
132
+ end
133
+ ledgers.update_all(spent_usd: 0.0, requests: 0, updated_at: Time.current)
116
134
  end
117
135
 
118
- def self.ledger_version(model)
119
- model.truffler_definition.vocabulary.version(all_users: true)
136
+ # Spend ledgers key on the asked labels only (Vocabulary#ledger_version),
137
+ # so a supplied-label change never resets the cap.
138
+ def self.ledger_version(model, tenant_key = nil)
139
+ model.truffler_definition.vocabulary.ledger_version(tenant_key: tenant_key, all_users: true)
140
+ end
141
+
142
+ # What ledgers were keyed by before 0.1.6: the whole vocabulary version.
143
+ def self.legacy_ledger_version(model, tenant_key = nil)
144
+ model.truffler_definition.vocabulary.version(tenant_key: tenant_key, all_users: true)
120
145
  end
121
146
 
122
147
  # Seconds to wait after `denials` consecutive budget denials with no
@@ -128,9 +153,10 @@ module Truffler
128
153
 
129
154
  attr_reader :model, :batch_size, :page_size
130
155
 
131
- def initialize(model, spend_cap: Truffler.config.backfill_spend_cap, batch_size: Truffler.config.batch_size,
156
+ def initialize(model, tenant_key: nil, spend_cap: Truffler.config.backfill_spend_cap, batch_size: Truffler.config.batch_size,
132
157
  page_size: nil, cursor: nil, spent: 0.0, client: Truffler.config.client, budget: Budget.new)
133
158
  @model = model
159
+ @tenant_key = tenant_key&.to_s if model.truffler_definition.scoped?
134
160
  model.truffler_definition.validate_columns!
135
161
  @batch_size = batch_size
136
162
  @page_size = page_size || batch_size * 5
@@ -140,6 +166,9 @@ module Truffler
140
166
  @client = client
141
167
  @budget = budget
142
168
  @versions = {}
169
+ @meters = {}
170
+ @enabled = {}
171
+ @capped = Set.new
143
172
  end
144
173
 
145
174
  # `progress` is called with the result so far and the delay before each
@@ -147,17 +176,17 @@ module Truffler
147
176
  def run(max_pages: nil, wait: false, max_duration: nil, sleeper: self.class.sleeper, clock: self.class.clock,
148
177
  progress: nil)
149
178
  @labeled = 0
150
- @started_cost = meter.cost
179
+ @started_cost = spent_cost
151
180
  @pages = 0
152
181
  deadline = max_duration && clock.call + max_duration
153
182
  denials = 0
154
183
 
155
184
  loop do
156
- before = [ @labeled, meter.requests ]
185
+ before = [ @labeled, requests ]
157
186
  status = sweep(max_pages, deadline, clock)
158
187
  return result(status) unless wait && status == :budget_denied
159
188
 
160
- denials = 0 unless before == [ @labeled, meter.requests ]
189
+ denials = 0 unless before == [ @labeled, requests ]
161
190
  delay = self.class.backoff(denials, @retry_after)
162
191
  return result(:paused) if deadline && clock.call + delay > deadline
163
192
 
@@ -167,11 +196,16 @@ module Truffler
167
196
  end
168
197
  end
169
198
 
199
+ # Counts over what the backfill may touch: index_scope, enabled tenants,
200
+ # and the one tenant when `tenant_key:` is given.
170
201
  def status
171
- counts = states.group(:status).count
172
- labeled = states.where(status: "labeled").group(:tenant_key, :vocabulary_version).count
202
+ scope, tenants = status_scope
203
+ tracked = states.where(record_id: scope.select(model.arel_table[model.primary_key]))
204
+ tracked = tracked.where(tenant_key: tenants) if tenants
205
+ counts = tracked.group(:status).count
206
+ labeled = tracked.where(status: "labeled").group(:tenant_key, :vocabulary_version).count
173
207
  stale = labeled.sum { |(tenant_key, version), count| version == version_for(tenant_key) ? 0 : count }
174
- { total: model.count, missing: model.joins(state_join).where("#{STATES}.id IS NULL").count,
208
+ { total: scope.count, missing: scope.joins(state_join).where("#{STATES}.id IS NULL").count,
175
209
  pending: counts["pending"].to_i, labeling: counts["labeling"].to_i, labeled: counts["labeled"].to_i,
176
210
  failed: counts["failed"].to_i, stale: stale, current: labeled.values.sum - stale }
177
211
  end
@@ -190,30 +224,67 @@ module Truffler
190
224
  Records::RecordState.for_model(model)
191
225
  end
192
226
 
227
+ # [relation, tenant keys or nil]: the records status counts, and the
228
+ # tenants it covers when it narrows to some.
229
+ def status_scope
230
+ scope = definition.index_relation(model.all)
231
+ return [ scope, nil ] unless definition.scoped?
232
+ return [ scope.where(definition.tenant_column => @tenant_key), [ @tenant_key ] ] if @tenant_key
233
+ return [ scope, nil ] unless Truffler.config.tenant_enabled
234
+
235
+ tenants = scope.reorder(nil).distinct.pluck(definition.tenant_column).map(&:to_s).select { |tenant_key| enabled?(tenant_key) }
236
+ [ scope.where(definition.tenant_column => tenants), tenants ]
237
+ end
238
+
193
239
  def queue
194
240
  @queue ||= Queue.new(model)
195
241
  end
196
242
 
197
- # Spend carried in with `spent:` is ignored when the ledger holds it.
198
- def meter
199
- @meter ||= begin
200
- ledger = Records::BackfillSpend.ledger(model, version_for(nil)) if Records::BackfillSpend.available?
243
+ # One meter per ledger: per tenant for tenant ledgers, else one for the
244
+ # run. Spend carried in with `spent:` is ignored when the ledger holds it.
245
+ def meter(tenant_key)
246
+ ledger_tenant = ledger_available? ? definition.ledger_tenant(tenant_key) : nil
247
+ @meters[ledger_tenant] ||= begin
248
+ if ledger_available?
249
+ ledger = Records::BackfillSpend.ledger(model, self.class.ledger_version(model, ledger_tenant), tenant_key: ledger_tenant,
250
+ legacy_version: version_for(ledger_tenant))
251
+ end
201
252
  SpendMeter.new(@client, cap: @spend_cap, spent: ledger ? 0.0 : @spent, ledger: ledger)
202
253
  end
203
254
  end
204
255
 
256
+ def ledger_available?
257
+ return @ledger_available if defined?(@ledger_available)
258
+
259
+ @ledger_available = Records::BackfillSpend.available?
260
+ end
261
+
262
+ def requests
263
+ @meters.each_value.sum(&:requests)
264
+ end
265
+
266
+ def spent_cost
267
+ @meters.each_value.sum(&:cost)
268
+ end
269
+
205
270
  def version_for(tenant_key)
206
271
  @versions[tenant_key] ||= definition.vocabulary.version(tenant_key: tenant_key, all_users: true)
207
272
  end
208
273
 
274
+ def enabled?(tenant_key)
275
+ @enabled.fetch(tenant_key) { @enabled[tenant_key] = definition.tenant_enabled?(tenant_key) }
276
+ end
277
+
209
278
  def result(status)
210
- Result.new(status: status, labeled: @labeled, requests: meter.requests, cost: meter.cost - @started_cost,
279
+ Result.new(status: status, labeled: @labeled, requests: requests, cost: spent_cost - @started_cost,
211
280
  cursor: @cursor, retry_after: (@retry_after if status == :budget_denied))
212
281
  end
213
282
 
214
283
  # Walks pages below @cursor until done or stopped, returning the status.
215
284
  def sweep(max_pages, deadline, clock)
216
285
  @retry_after = nil
286
+ return complete if @tenant_key && !enabled?(@tenant_key)
287
+
217
288
  loop do
218
289
  scanned, rows = page(@cursor)
219
290
  return complete if scanned.empty?
@@ -221,6 +292,8 @@ module Truffler
221
292
 
222
293
  rows.group_by(&:last).each do |tenant_key, tenant_rows|
223
294
  tenant_rows.map(&:first).each_slice(batch_size) do |ids|
295
+ break if @capped.include?(tenant_key)
296
+
224
297
  stop = label(ids, tenant_key)
225
298
  return stop if stop
226
299
  end
@@ -234,7 +307,7 @@ module Truffler
234
307
 
235
308
  def complete
236
309
  @cursor = nil
237
- :complete
310
+ @capped.any? ? :spend_cap_reached : :complete
238
311
  end
239
312
 
240
313
  # Returns the scanned ids (for the cursor) and the [id, tenant_key] rows
@@ -242,7 +315,8 @@ module Truffler
242
315
  # lenses) compare versions here because each tenant has its own.
243
316
  def page(cursor)
244
317
  pk = model.primary_key
245
- scope = model.joins(state_join).where(needs_labeling_sql)
318
+ scope = definition.index_relation(model.joins(state_join).where(needs_labeling_sql))
319
+ scope = scope.where(definition.tenant_column => @tenant_key) if @tenant_key
246
320
  scope = scope.where(model.arel_table[pk].lt(cursor)) if cursor
247
321
  tenant = definition.scoped? ? model.arel_table[definition.tenant_column] : Arel.sql("NULL")
248
322
  plucked = scope.reorder(pk => :desc).limit(page_size)
@@ -250,7 +324,7 @@ module Truffler
250
324
 
251
325
  rows = plucked.filter_map do |id, tenant_key, status, version|
252
326
  tenant_key = tenant_key&.to_s
253
- [ id, tenant_key ] unless status == "labeled" && version == version_for(tenant_key)
327
+ [ id, tenant_key ] unless (status == "labeled" && version == version_for(tenant_key)) || !enabled?(tenant_key)
254
328
  end
255
329
  [ plucked.map(&:first), rows ]
256
330
  end
@@ -277,16 +351,20 @@ module Truffler
277
351
 
278
352
  # Labels one tenant chunk. Returns nil when done, or the status that
279
353
  # stops the run; claimed rows that were not labeled go back to pending
280
- # at backfill priority.
354
+ # at backfill priority. A tenant ledger at its cap only stops that
355
+ # tenant in a whole-model run.
281
356
  def label(ids, tenant_key)
282
357
  claimed = queue.claim_backfill(ids, tenant_key)
283
358
  return if claimed.empty?
284
359
 
285
360
  begin
286
- Labeler.new(model, client: meter, budget: @budget).label(claimed, priority: :backfill)
361
+ Labeler.new(model, client: meter(tenant_key), budget: @budget).label(claimed, priority: :backfill)
287
362
  rescue SpendCapReached
288
363
  queue.demote(claimed)
289
- return :spend_cap_reached
364
+ return :spend_cap_reached if @tenant_key || definition.ledger_tenant(tenant_key).nil? || !ledger_available?
365
+
366
+ @capped << tenant_key
367
+ return
290
368
  rescue BudgetExhausted => error
291
369
  queue.demote(claimed)
292
370
  @retry_after = error.retry_after
@@ -20,46 +20,48 @@ module Truffler
20
20
  end
21
21
 
22
22
  def label(states, priority:)
23
- return Result.new(labeled: 0, requests: 0, cost: 0.0, demoted: false) if states.empty?
24
-
25
- tenant_key = tenant_key_of(states)
26
- records = load_records(states)
27
- vocabulary = definition.vocabulary
28
- @labels = vocabulary.labels_for(tenant_key: tenant_key, all_users: true)
29
- fingerprints = vocabulary.fingerprints(tenant_key: tenant_key, all_users: true)
30
- version = vocabulary.version(tenant_key: tenant_key, all_users: true)
31
- states_by_id = states.index_by { |state| state.record_id.to_s }
32
-
33
- stored = stored_fingerprints(records)
34
- askable = askable_labels
35
- stale = records.map { |record| [ record, stale_keys(stored[record.id.to_s].to_h, fingerprints, tenant_key, askable) ] }
36
- supplied = askable.select(&:supplied?).map(&:key)
37
- supplier = Supplied.new(model)
38
- written = supplier.write(stale.map { |record, keys| [ record, keys & supplied ] }, tenant_key: tenant_key)
39
- current, pending = stale.map { |record, keys| [ record, keys - supplied ] }.partition { |_, keys| keys.empty? }
40
- settled = current.reject { |record, _| supplier.failed_ids.include?(record.id) }
41
- Records::RecordState.mark_labeled(settled.map { |record, _| states_by_id[record.id.to_s].id }, version: version)
42
- Embeddings::LabelVector.new(model).write(current.map { |record, _| record.id } - written, tenant_key: tenant_key)
43
-
44
- requests = RequestBuilder.new(definition, tenant_key: tenant_key, labels: @labels).build(pending)
45
- cost = 0.0
46
- requests.each_with_index do |request, index|
47
- decision = budget.acquire(priority: priority, tenant_key: (tenant_key if index.zero?),
48
- records: index.zero? ? pending.size : 1)
49
- if decision.demoted?
50
- retry_failed_supplied(supplier.failed_ids, states_by_id)
51
- return Result.new(labeled: current.size, requests: index, cost: cost, demoted: true)
23
+ Current.scope do
24
+ return Result.new(labeled: 0, requests: 0, cost: 0.0, demoted: false) if states.empty?
25
+
26
+ tenant_key = tenant_key_of(states)
27
+ records = load_records(states, tenant_key)
28
+ vocabulary = definition.vocabulary
29
+ @labels = vocabulary.labels_for(tenant_key: tenant_key, all_users: true)
30
+ fingerprints = vocabulary.fingerprints(tenant_key: tenant_key, all_users: true)
31
+ version = vocabulary.version(tenant_key: tenant_key, all_users: true)
32
+ states_by_id = states.index_by { |state| state.record_id.to_s }
33
+
34
+ stored = stored_fingerprints(records)
35
+ askable = askable_labels
36
+ stale = records.map { |record| [ record, stale_keys(stored[record.id.to_s].to_h, fingerprints, tenant_key, askable) ] }
37
+ supplied = askable.select(&:supplied?).map(&:key)
38
+ supplier = Supplied.new(model)
39
+ written = supplier.write(stale.map { |record, keys| [ record, keys & supplied ] }, tenant_key: tenant_key)
40
+ current, pending = stale.map { |record, keys| [ record, keys - supplied ] }.partition { |_, keys| keys.empty? }
41
+ settled = current.reject { |record, _| supplier.failed_ids.include?(record.id) }
42
+ Records::RecordState.mark_labeled(settled.map { |record, _| states_by_id[record.id.to_s].id }, version: version)
43
+ Embeddings::LabelVector.new(model).write(current.map { |record, _| record.id } - written, tenant_key: tenant_key)
44
+
45
+ requests = RequestBuilder.new(definition, tenant_key: tenant_key, labels: @labels).build(pending)
46
+ cost = 0.0
47
+ requests.each_with_index do |request, index|
48
+ decision = budget.acquire(priority: priority, tenant_key: (tenant_key if index.zero?),
49
+ records: index.zero? ? pending.size : 1)
50
+ if decision.demoted?
51
+ retry_failed_supplied(supplier.failed_ids, states_by_id)
52
+ return Result.new(labeled: current.size, requests: index, cost: cost, demoted: true)
53
+ end
54
+ raise BudgetExhausted.new("no Jev budget for #{priority} labeling", retry_after: decision.retry_after) if decision.denied?
55
+
56
+ answers = client.ask(state: request.state, questions: request.questions, priority: decision.priority)
57
+ cost += answers.usage&.cost.to_f
58
+ charge_lenses(request, answers.usage&.cost.to_f)
59
+ store(request, answers, fingerprints, tenant_key, version, states_by_id)
52
60
  end
53
- raise BudgetExhausted.new("no Jev budget for #{priority} labeling", retry_after: decision.retry_after) if decision.denied?
54
61
 
55
- answers = client.ask(state: request.state, questions: request.questions, priority: decision.priority)
56
- cost += answers.usage&.cost.to_f
57
- charge_lenses(request, answers.usage&.cost.to_f)
58
- store(request, answers, fingerprints, tenant_key, version, states_by_id)
62
+ retry_failed_supplied(supplier.failed_ids, states_by_id)
63
+ Result.new(labeled: records.size, requests: requests.size, cost: cost, demoted: false)
59
64
  end
60
-
61
- retry_failed_supplied(supplier.failed_ids, states_by_id)
62
- Result.new(labeled: records.size, requests: requests.size, cost: cost, demoted: false)
63
65
  end
64
66
 
65
67
  private
@@ -87,17 +89,31 @@ module Truffler
87
89
  tenants.first
88
90
  end
89
91
 
90
- def load_records(states)
91
- records = model.where(model.primary_key => states.map(&:record_id)).to_a
92
+ # Records deleted or outside index_scope / index_if lose their state
93
+ # rows instead of being labeled. A disabled tenant keeps its state rows:
94
+ # they go back to pending at backfill priority, so re-enabling it
95
+ # relabels only what is stale instead of every record.
96
+ def load_records(states, tenant_key)
97
+ unless definition.tenant_enabled?(tenant_key)
98
+ Records::RecordState.where(id: states.map(&:id))
99
+ .update_all(status: "pending", priority: "backfill", claimed_at: nil, updated_at: Time.current)
100
+ return []
101
+ end
102
+
103
+ records = definition.index_relation(model.where(model.primary_key => states.map(&:record_id))).to_a
104
+ .select { |record| definition.index_if.nil? || definition.index_if.call(record) }
92
105
  found = records.map { |record| record.id.to_s }
93
106
  gone = states.reject { |state| found.include?(state.record_id.to_s) }
94
107
  Records::RecordState.where(id: gone.map(&:id)).delete_all if gone.any?
95
108
  records
96
109
  end
97
110
 
111
+ # A label is current when it has stored rows and all carry its current
112
+ # fingerprint; choice labels store only some options (see sparse_choice).
98
113
  def stale_keys(stored, fingerprints, tenant_key, askable)
99
114
  askable.reject do |label|
100
- label.storage_keys(tenant_key).all? { |key| stored[key] == fingerprints[label.key] }
115
+ present = label.storage_keys(tenant_key).select { |key| stored.key?(key) }
116
+ present.any? && present.all? { |key| stored[key] == fingerprints[label.key] }
101
117
  end.map(&:key)
102
118
  end
103
119
 
@@ -154,7 +170,8 @@ module Truffler
154
170
  case label.type
155
171
  when :noul then [ [ label.key, answers.noul(id) ] ]
156
172
  when :score then [ [ label.key, answers.score(id) ] ]
157
- when :choice then label.options(tenant_key).keys.map { |option| [ "#{label.key}:#{option}", answers.probability(id, option) ] }
173
+ when :choice
174
+ LabelDefinition.sparse_choice(label.options(tenant_key).keys.to_h { |option| [ "#{label.key}:#{option}", answers.probability(id, option) ] }).to_a
158
175
  end
159
176
  end
160
177
  end
@@ -3,6 +3,8 @@ module Truffler
3
3
  # State transitions on truffler_record_states for one model, plus the
4
4
  # deduplicated scheduling of LabelFlushJob per tenant and of BackfillJob
5
5
  # for demoted rows. Jobs carry only the record type and tenant key.
6
+ # Records the definition does not index (a disabled tenant, `index_if`)
7
+ # are never enqueued.
6
8
  class Queue
7
9
  BACKFILL_WAIT = 1.minute
8
10
 
@@ -15,13 +17,15 @@ module Truffler
15
17
  end
16
18
 
17
19
  def enqueue(record, priority: :live)
20
+ return unless definition.indexable?(record)
21
+
18
22
  tenant_key = definition.tenant_key_for(record)
19
23
  now = Time.current
20
24
  Records::RecordState.upsert(
21
25
  { record_type: record_type, record_id: record.id, tenant_key: tenant_key, status: "pending",
22
26
  priority: priority.to_s, attempts: 0, created_at: now, updated_at: now },
23
27
  unique_by: %i[record_type record_id],
24
- update_only: %i[tenant_key status priority attempts updated_at]
28
+ update_only: %i[tenant_key status priority attempts]
25
29
  )
26
30
  schedule(tenant_key)
27
31
  end
@@ -41,13 +45,14 @@ module Truffler
41
45
  job.perform_later(record_type, tenant_key)
42
46
  end
43
47
 
44
- # Starts a backfill shortly after live rows are demoted over the tenant
45
- # cap. One per model per marker lifetime; ResumeJob still catches rows a
46
- # dropped job leaves behind.
47
- def schedule_backfill
48
- return unless config.cache_store.write(backfill_marker, true, unless_exist: true, expires_in: BACKFILL_WAIT + 300)
48
+ # Starts a backfill of the tenant shortly after its live rows are
49
+ # demoted over the tenant cap. One per tenant per marker lifetime;
50
+ # ResumeJob still catches rows a dropped job leaves behind.
51
+ def schedule_backfill(tenant_key = nil)
52
+ return unless definition.tenant_enabled?(tenant_key)
53
+ return unless config.cache_store.write(backfill_marker(tenant_key), true, unless_exist: true, expires_in: BACKFILL_WAIT + 300)
49
54
 
50
- Jobs::BackfillJob.set(wait: BACKFILL_WAIT).perform_later(record_type)
55
+ Jobs::BackfillJob.set(wait: BACKFILL_WAIT).perform_later(record_type, **Jobs::BackfillJob.tenant_argument(model, tenant_key))
51
56
  end
52
57
 
53
58
  def clear_marker(tenant_key)
@@ -118,8 +123,8 @@ module Truffler
118
123
  "truffler/flush/#{record_type}/#{tenant_key}"
119
124
  end
120
125
 
121
- def backfill_marker
122
- "truffler/backfill/#{record_type}"
126
+ def backfill_marker(tenant_key)
127
+ "truffler/backfill/#{record_type}/#{tenant_key}"
123
128
  end
124
129
  end
125
130
  end
@@ -4,9 +4,13 @@ module Truffler
4
4
  # of one tenant into truffler_labels, under the same storage keys as asked
5
5
  # labels, and rewrites those records' label vectors. No Jev request, no
6
6
  # budget slot, no spend. A nil answer stores nothing, so the label reads
7
- # as missing rather than 0. A `from` that raises or answers out of shape
8
- # is instrumented as `supplied_label_failed`, skipped, and listed in
9
- # `failed_ids` so the caller can retry it; its stored rows serve meanwhile.
7
+ # as missing rather than 0. Both failures are instrumented as
8
+ # `supplied_label_failed`. An answer out of shape (InvalidSuppliedAnswer)
9
+ # is permanent (`permanent: true`): it stores nothing for that label,
10
+ # like a nil answer, and the record settles, since retrying the same
11
+ # answer cannot succeed. A `from` that raises anything else is transient:
12
+ # the label is skipped and listed in `failed_ids` so the caller retries
13
+ # it (failed after max_attempts); its stored rows serve meanwhile.
10
14
  class Supplied
11
15
  attr_reader :model, :failed_ids
12
16
 
@@ -61,11 +65,18 @@ module Truffler
61
65
 
62
66
  def answer(label, record, tenant_key)
63
67
  label.supplied_values(record, tenant_key)
68
+ rescue InvalidSuppliedAnswer => error
69
+ instrument_failure(label, record, tenant_key, error, permanent: true)
70
+ nil
64
71
  rescue StandardError => error
65
- Instrumentation.instrument(:supplied_label_failed, record_type: record_type, tenant_key: tenant_key, record_id: record.id,
66
- label_key: label.key, error_class: error.class.name)
72
+ instrument_failure(label, record, tenant_key, error, permanent: false)
67
73
  :failed
68
74
  end
75
+
76
+ def instrument_failure(label, record, tenant_key, error, permanent:)
77
+ Instrumentation.instrument(:supplied_label_failed, record_type: record_type, tenant_key: tenant_key, record_id: record.id,
78
+ label_key: label.key, error_class: error.class.name, permanent: permanent)
79
+ end
69
80
  end
70
81
  end
71
82
  end
@@ -37,6 +37,8 @@ module Truffler
37
37
 
38
38
  attempted.concat(records.map(&:id))
39
39
  records.group_by { |record| definition.tenant_key_for(record) }.each do |tenant_key, slice|
40
+ next unless definition.tenant_enabled?(tenant_key)
41
+
40
42
  stop = label(slice, tenant_key)
41
43
  return result(stop) if stop
42
44
  end
@@ -70,22 +72,38 @@ module Truffler
70
72
  # lens row.
71
73
  def page(attempted)
72
74
  pk = model.primary_key
73
- scope = model.all
74
- scope = scope.where(definition.tenant_column => lens.tenant_key) if definition.scoped? && lens.tenant_key
75
+ scope = definition.index_relation(model.all)
76
+ if definition.scoped? && lens.tenant_key
77
+ return [] unless definition.tenant_enabled?(lens.tenant_key)
78
+
79
+ scope = scope.where(definition.tenant_column => lens.tenant_key)
80
+ elsif definition.scoped? && Truffler.config.tenant_enabled
81
+ scope = scope.where(definition.tenant_column => enabled_tenants)
82
+ end
75
83
  scope = scope.where.not(pk => attempted) if attempted.any?
76
84
  scope.where(Arel.sql(stale_sql)).reorder(definition.arrival_order).limit(batch_size).to_a
77
85
  end
78
86
 
87
+ # Tenants in index_scope that config.tenant_enabled allows, looked up
88
+ # once per run, so disabled tenants are never paged (and never refetched
89
+ # by the next LensBackfillJob).
90
+ def enabled_tenants
91
+ @enabled_tenants ||= definition.index_relation(model.all).reorder(nil).distinct.pluck(definition.tenant_column)
92
+ .select { |tenant_key| definition.tenant_enabled?(tenant_key.to_s) }
93
+ end
94
+
95
+ # Stale unless every lens label has a row under its current fingerprint;
96
+ # a choice label stores only some of its options.
79
97
  def stale_sql
80
- expected = lens.labels.values.flat_map do |label|
81
- print = Lenses.fingerprint(label.question)
82
- label.storage_keys.map { |key| ActiveRecord::Base.sanitize_sql_array([ "(label_key = ? AND fingerprint = ?)", key, print ]) }
83
- end
84
98
  pk = "#{model.quoted_table_name}.#{model.connection.quote_column_name(model.primary_key)}"
85
- ActiveRecord::Base.sanitize_sql_array([
86
- "(SELECT COUNT(*) FROM #{LABELS} WHERE #{LABELS}.record_type = ? AND #{LABELS}.record_id = #{pk} " \
87
- "AND (#{expected.join(' OR ')})) < ?", model.polymorphic_name, expected.size
88
- ])
99
+ current = lens.labels.values.map do |label|
100
+ ActiveRecord::Base.sanitize_sql_array([
101
+ "EXISTS (SELECT 1 FROM #{LABELS} WHERE #{LABELS}.record_type = ? AND #{LABELS}.record_id = #{pk} " \
102
+ "AND #{LABELS}.label_key IN (?) AND #{LABELS}.fingerprint = ?)",
103
+ model.polymorphic_name, label.storage_keys, Lenses.fingerprint(label.question)
104
+ ])
105
+ end
106
+ "NOT (#{current.join(' AND ')})"
89
107
  end
90
108
 
91
109
  # Labels one tenant's records through the labeler, which asks only
@@ -62,6 +62,8 @@ module Truffler
62
62
  # outside the watched columns, e.g. from the job that classified it.
63
63
  def truffler_refresh_labels!
64
64
  definition = self.class.truffler_definition
65
+ return self unless definition.indexable?(self)
66
+
65
67
  tenant_key = definition.tenant_key_for(self)
66
68
  keys = definition.supplied_labels.select { |label| label.available?(tenant_key) }.map(&:key)
67
69
  Labeling::Supplied.new(self.class).write([ [ self, keys ] ], tenant_key: tenant_key) if keys.any?
@@ -72,6 +74,8 @@ module Truffler
72
74
 
73
75
  def truffler_enqueue_labeling
74
76
  definition = self.class.truffler_definition
77
+ return unless definition.indexable?(self)
78
+
75
79
  if previously_new_record?
76
80
  Labeling::Queue.new(self.class).enqueue(self)
77
81
  return
@@ -105,6 +109,7 @@ module Truffler
105
109
  definition = self.class.truffler_definition
106
110
  return unless Embeddings.managed?(definition)
107
111
  return unless previously_new_record? || saved_changes.keys.intersect?(definition.relabel_columns)
112
+ return unless definition.indexable?(self)
108
113
 
109
114
  Jobs::EmbedJob.perform_later(self.class.polymorphic_name, id)
110
115
  end
@@ -39,7 +39,7 @@ module Truffler
39
39
  end
40
40
 
41
41
  def weak_local?
42
- return @local_result.invite_row.present? if @local_result
42
+ return @local_result.local_weak? if @local_result
43
43
  return @run.local_weak? if @run.respond_to?(:local_weak?)
44
44
  unless @run.respond_to?(:candidate_ids)
45
45
  raise ArgumentError, "Providers.start needs local_result: or a run that responds to local_weak? or candidate_ids"