truffler 0.1.3 → 0.1.5

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +77 -10
  4. data/bench/cassettes/1037e0b63ba790c02d14d0ddfc18b68151c11d03ce88d764658044b6bab2e55b.json +1 -0
  5. data/bench/cassettes/19bba849a846d30a69dc385a337d67704fbe5194506fd3c914d3270d86495fde.json +1 -0
  6. data/bench/cassettes/3768847dc57df8bf2328771e7763c0b8bd013a167244e7849967dac144420601.json +1 -0
  7. data/lib/generators/truffler/install/templates/migration.rb.tt +6 -3
  8. data/lib/generators/truffler/upgrade/templates/backfill_spends_tenant_key_migration.rb.tt +25 -0
  9. data/lib/generators/truffler/upgrade/templates/labels_search_covering_migration.rb.tt +32 -0
  10. data/lib/generators/truffler/upgrade/upgrade_generator.rb +49 -3
  11. data/lib/tasks/truffler.rake +38 -13
  12. data/lib/truffler/benchmark/runner.rb +1 -1
  13. data/lib/truffler/clients/evaluator.rb +44 -0
  14. data/lib/truffler/configuration.rb +12 -0
  15. data/lib/truffler/definition.rb +52 -0
  16. data/lib/truffler/embeddings/backfill.rb +40 -11
  17. data/lib/truffler/embeddings/label_vector.rb +1 -1
  18. data/lib/truffler/embeddings/neighbor_store.rb +33 -7
  19. data/lib/truffler/embeddings/vector_store.rb +9 -3
  20. data/lib/truffler/jobs/backfill_job.rb +14 -5
  21. data/lib/truffler/jobs/embed_job.rb +2 -0
  22. data/lib/truffler/jobs/label_flush_job.rb +4 -2
  23. data/lib/truffler/jobs/resume_job.rb +22 -9
  24. data/lib/truffler/label_definition.rb +55 -12
  25. data/lib/truffler/labeling/backfill.rb +100 -37
  26. data/lib/truffler/labeling/labeler.rb +20 -5
  27. data/lib/truffler/labeling/queue.rb +14 -9
  28. data/lib/truffler/labeling/supplied.rb +16 -5
  29. data/lib/truffler/lenses/backfill.rb +28 -10
  30. data/lib/truffler/lenses/lens_label.rb +7 -0
  31. data/lib/truffler/model.rb +5 -0
  32. data/lib/truffler/providers/backup.rb +1 -1
  33. data/lib/truffler/query_encoding/encoder.rb +56 -39
  34. data/lib/truffler/records/backfill_spend.rb +30 -6
  35. data/lib/truffler/redaction.rb +1 -1
  36. data/lib/truffler/search/encoding.rb +50 -12
  37. data/lib/truffler/search/encoding_cache.rb +4 -3
  38. data/lib/truffler/search/filler.rb +73 -0
  39. data/lib/truffler/search/keystroke.rb +29 -15
  40. data/lib/truffler/search/result.rb +11 -1
  41. data/lib/truffler/search/sql.rb +93 -15
  42. data/lib/truffler/smart_search/dispatcher.rb +1 -1
  43. data/lib/truffler/smart_search/starter.rb +1 -1
  44. data/lib/truffler/version.rb +1 -1
  45. data/lib/truffler/vocabulary.rb +13 -3
  46. metadata +8 -1
@@ -2,20 +2,27 @@ 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 vocabulary version in the truffler_backfill_spends ledger, so
18
+ # reruns and overlapping jobs share it. Tenant-scoped models keep one
19
+ # ledger per tenant (backfill_spend_cap_scope :tenant, the default), so
20
+ # the cap applies to each tenant: a whole-model run skips a tenant at its
21
+ # cap, keeps labeling the others, and ends with `:spend_cap_reached`; a
22
+ # tenant run stops there. Unscoped models, and :app, keep one app-wide
23
+ # ledger. Without that table the cap falls back to this run plus
24
+ # `spent:`. A Jev error releases the claimed rows and ends the run with
25
+ # `:client_error`, so the caller keeps the spend metered so far.
19
26
  #
20
27
  # A budget denial ends the run with `:budget_denied`, unless the run
21
28
  # waits: then it backs off (see .backoff) and retries from the same
@@ -94,29 +101,35 @@ module Truffler
94
101
  end
95
102
  end
96
103
 
97
- def self.status(model)
98
- new(model).status
104
+ def self.status(model, tenant_key: nil)
105
+ new(model, tenant_key: tenant_key).status
99
106
  end
100
107
 
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)
108
+ # The ledger row for the current vocabulary version (the tenant's, for
109
+ # a tenant ledger), or nil when nothing was spent yet or the ledger
110
+ # table is missing.
111
+ def self.spend(model, tenant_key: nil)
104
112
  return unless Records::BackfillSpend.available?
105
113
 
106
- Records::BackfillSpend.for_model(model).find_by(vocabulary_version: ledger_version(model))
114
+ Records::BackfillSpend.for_ledger(model, tenant_key).find_by(vocabulary_version: ledger_version(model, tenant_key))
107
115
  end
108
116
 
109
117
  # Zeroes the current vocabulary version's ledger in place, so a chain
110
118
  # still running keeps its row and continues against the fresh total.
111
- def self.reset_spend!(model)
119
+ # `all_tenants: true` zeroes every tenant ledger of the model.
120
+ def self.reset_spend!(model, tenant_key: nil, all_tenants: false)
112
121
  return unless Records::BackfillSpend.available?
113
122
 
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)
123
+ ledgers = if all_tenants && Records::BackfillSpend.tenant_ledgers?
124
+ Records::BackfillSpend.for_model(model).where.not(tenant_key: nil)
125
+ else
126
+ Records::BackfillSpend.for_ledger(model, tenant_key).where(vocabulary_version: ledger_version(model, tenant_key))
127
+ end
128
+ ledgers.update_all(spent_usd: 0.0, requests: 0, updated_at: Time.current)
116
129
  end
117
130
 
118
- def self.ledger_version(model)
119
- model.truffler_definition.vocabulary.version(all_users: true)
131
+ def self.ledger_version(model, tenant_key = nil)
132
+ model.truffler_definition.vocabulary.version(tenant_key: tenant_key, all_users: true)
120
133
  end
121
134
 
122
135
  # Seconds to wait after `denials` consecutive budget denials with no
@@ -128,9 +141,10 @@ module Truffler
128
141
 
129
142
  attr_reader :model, :batch_size, :page_size
130
143
 
131
- def initialize(model, spend_cap: Truffler.config.backfill_spend_cap, batch_size: Truffler.config.batch_size,
144
+ def initialize(model, tenant_key: nil, spend_cap: Truffler.config.backfill_spend_cap, batch_size: Truffler.config.batch_size,
132
145
  page_size: nil, cursor: nil, spent: 0.0, client: Truffler.config.client, budget: Budget.new)
133
146
  @model = model
147
+ @tenant_key = tenant_key&.to_s if model.truffler_definition.scoped?
134
148
  model.truffler_definition.validate_columns!
135
149
  @batch_size = batch_size
136
150
  @page_size = page_size || batch_size * 5
@@ -140,6 +154,9 @@ module Truffler
140
154
  @client = client
141
155
  @budget = budget
142
156
  @versions = {}
157
+ @meters = {}
158
+ @enabled = {}
159
+ @capped = Set.new
143
160
  end
144
161
 
145
162
  # `progress` is called with the result so far and the delay before each
@@ -147,17 +164,17 @@ module Truffler
147
164
  def run(max_pages: nil, wait: false, max_duration: nil, sleeper: self.class.sleeper, clock: self.class.clock,
148
165
  progress: nil)
149
166
  @labeled = 0
150
- @started_cost = meter.cost
167
+ @started_cost = spent_cost
151
168
  @pages = 0
152
169
  deadline = max_duration && clock.call + max_duration
153
170
  denials = 0
154
171
 
155
172
  loop do
156
- before = [ @labeled, meter.requests ]
173
+ before = [ @labeled, requests ]
157
174
  status = sweep(max_pages, deadline, clock)
158
175
  return result(status) unless wait && status == :budget_denied
159
176
 
160
- denials = 0 unless before == [ @labeled, meter.requests ]
177
+ denials = 0 unless before == [ @labeled, requests ]
161
178
  delay = self.class.backoff(denials, @retry_after)
162
179
  return result(:paused) if deadline && clock.call + delay > deadline
163
180
 
@@ -167,11 +184,16 @@ module Truffler
167
184
  end
168
185
  end
169
186
 
187
+ # Counts over what the backfill may touch: index_scope, enabled tenants,
188
+ # and the one tenant when `tenant_key:` is given.
170
189
  def status
171
- counts = states.group(:status).count
172
- labeled = states.where(status: "labeled").group(:tenant_key, :vocabulary_version).count
190
+ scope, tenants = status_scope
191
+ tracked = states.where(record_id: scope.select(model.arel_table[model.primary_key]))
192
+ tracked = tracked.where(tenant_key: tenants) if tenants
193
+ counts = tracked.group(:status).count
194
+ labeled = tracked.where(status: "labeled").group(:tenant_key, :vocabulary_version).count
173
195
  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,
196
+ { total: scope.count, missing: scope.joins(state_join).where("#{STATES}.id IS NULL").count,
175
197
  pending: counts["pending"].to_i, labeling: counts["labeling"].to_i, labeled: counts["labeled"].to_i,
176
198
  failed: counts["failed"].to_i, stale: stale, current: labeled.values.sum - stale }
177
199
  end
@@ -190,30 +212,64 @@ module Truffler
190
212
  Records::RecordState.for_model(model)
191
213
  end
192
214
 
215
+ # [relation, tenant keys or nil]: the records status counts, and the
216
+ # tenants it covers when it narrows to some.
217
+ def status_scope
218
+ scope = definition.index_relation(model.all)
219
+ return [ scope, nil ] unless definition.scoped?
220
+ return [ scope.where(definition.tenant_column => @tenant_key), [ @tenant_key ] ] if @tenant_key
221
+ return [ scope, nil ] unless Truffler.config.tenant_enabled
222
+
223
+ tenants = scope.distinct.pluck(definition.tenant_column).map(&:to_s).select { |tenant_key| enabled?(tenant_key) }
224
+ [ scope.where(definition.tenant_column => tenants), tenants ]
225
+ end
226
+
193
227
  def queue
194
228
  @queue ||= Queue.new(model)
195
229
  end
196
230
 
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?
231
+ # One meter per ledger: per tenant for tenant ledgers, else one for the
232
+ # run. Spend carried in with `spent:` is ignored when the ledger holds it.
233
+ def meter(tenant_key)
234
+ ledger_tenant = ledger_available? ? definition.ledger_tenant(tenant_key) : nil
235
+ @meters[ledger_tenant] ||= begin
236
+ ledger = Records::BackfillSpend.ledger(model, version_for(ledger_tenant), tenant_key: ledger_tenant) if ledger_available?
201
237
  SpendMeter.new(@client, cap: @spend_cap, spent: ledger ? 0.0 : @spent, ledger: ledger)
202
238
  end
203
239
  end
204
240
 
241
+ def ledger_available?
242
+ return @ledger_available if defined?(@ledger_available)
243
+
244
+ @ledger_available = Records::BackfillSpend.available?
245
+ end
246
+
247
+ def requests
248
+ @meters.each_value.sum(&:requests)
249
+ end
250
+
251
+ def spent_cost
252
+ @meters.each_value.sum(&:cost)
253
+ end
254
+
205
255
  def version_for(tenant_key)
206
256
  @versions[tenant_key] ||= definition.vocabulary.version(tenant_key: tenant_key, all_users: true)
207
257
  end
208
258
 
259
+ def enabled?(tenant_key)
260
+ @enabled.fetch(tenant_key) { @enabled[tenant_key] = definition.tenant_enabled?(tenant_key) }
261
+ end
262
+
209
263
  def result(status)
210
- Result.new(status: status, labeled: @labeled, requests: meter.requests, cost: meter.cost - @started_cost,
264
+ Result.new(status: status, labeled: @labeled, requests: requests, cost: spent_cost - @started_cost,
211
265
  cursor: @cursor, retry_after: (@retry_after if status == :budget_denied))
212
266
  end
213
267
 
214
268
  # Walks pages below @cursor until done or stopped, returning the status.
215
269
  def sweep(max_pages, deadline, clock)
216
270
  @retry_after = nil
271
+ return complete if @tenant_key && !enabled?(@tenant_key)
272
+
217
273
  loop do
218
274
  scanned, rows = page(@cursor)
219
275
  return complete if scanned.empty?
@@ -221,6 +277,8 @@ module Truffler
221
277
 
222
278
  rows.group_by(&:last).each do |tenant_key, tenant_rows|
223
279
  tenant_rows.map(&:first).each_slice(batch_size) do |ids|
280
+ break if @capped.include?(tenant_key)
281
+
224
282
  stop = label(ids, tenant_key)
225
283
  return stop if stop
226
284
  end
@@ -234,7 +292,7 @@ module Truffler
234
292
 
235
293
  def complete
236
294
  @cursor = nil
237
- :complete
295
+ @capped.any? ? :spend_cap_reached : :complete
238
296
  end
239
297
 
240
298
  # Returns the scanned ids (for the cursor) and the [id, tenant_key] rows
@@ -242,7 +300,8 @@ module Truffler
242
300
  # lenses) compare versions here because each tenant has its own.
243
301
  def page(cursor)
244
302
  pk = model.primary_key
245
- scope = model.joins(state_join).where(needs_labeling_sql)
303
+ scope = definition.index_relation(model.joins(state_join).where(needs_labeling_sql))
304
+ scope = scope.where(definition.tenant_column => @tenant_key) if @tenant_key
246
305
  scope = scope.where(model.arel_table[pk].lt(cursor)) if cursor
247
306
  tenant = definition.scoped? ? model.arel_table[definition.tenant_column] : Arel.sql("NULL")
248
307
  plucked = scope.reorder(pk => :desc).limit(page_size)
@@ -250,7 +309,7 @@ module Truffler
250
309
 
251
310
  rows = plucked.filter_map do |id, tenant_key, status, version|
252
311
  tenant_key = tenant_key&.to_s
253
- [ id, tenant_key ] unless status == "labeled" && version == version_for(tenant_key)
312
+ [ id, tenant_key ] unless (status == "labeled" && version == version_for(tenant_key)) || !enabled?(tenant_key)
254
313
  end
255
314
  [ plucked.map(&:first), rows ]
256
315
  end
@@ -277,16 +336,20 @@ module Truffler
277
336
 
278
337
  # Labels one tenant chunk. Returns nil when done, or the status that
279
338
  # stops the run; claimed rows that were not labeled go back to pending
280
- # at backfill priority.
339
+ # at backfill priority. A tenant ledger at its cap only stops that
340
+ # tenant in a whole-model run.
281
341
  def label(ids, tenant_key)
282
342
  claimed = queue.claim_backfill(ids, tenant_key)
283
343
  return if claimed.empty?
284
344
 
285
345
  begin
286
- Labeler.new(model, client: meter, budget: @budget).label(claimed, priority: :backfill)
346
+ Labeler.new(model, client: meter(tenant_key), budget: @budget).label(claimed, priority: :backfill)
287
347
  rescue SpendCapReached
288
348
  queue.demote(claimed)
289
- return :spend_cap_reached
349
+ return :spend_cap_reached if @tenant_key || definition.ledger_tenant(tenant_key).nil? || !ledger_available?
350
+
351
+ @capped << tenant_key
352
+ return
290
353
  rescue BudgetExhausted => error
291
354
  queue.demote(claimed)
292
355
  @retry_after = error.retry_after
@@ -23,7 +23,7 @@ module Truffler
23
23
  return Result.new(labeled: 0, requests: 0, cost: 0.0, demoted: false) if states.empty?
24
24
 
25
25
  tenant_key = tenant_key_of(states)
26
- records = load_records(states)
26
+ records = load_records(states, tenant_key)
27
27
  vocabulary = definition.vocabulary
28
28
  @labels = vocabulary.labels_for(tenant_key: tenant_key, all_users: true)
29
29
  fingerprints = vocabulary.fingerprints(tenant_key: tenant_key, all_users: true)
@@ -87,17 +87,31 @@ module Truffler
87
87
  tenants.first
88
88
  end
89
89
 
90
- def load_records(states)
91
- records = model.where(model.primary_key => states.map(&:record_id)).to_a
90
+ # Records deleted or outside index_scope / index_if lose their state
91
+ # rows instead of being labeled. A disabled tenant keeps its state rows:
92
+ # they go back to pending at backfill priority, so re-enabling it
93
+ # relabels only what is stale instead of every record.
94
+ def load_records(states, tenant_key)
95
+ unless definition.tenant_enabled?(tenant_key)
96
+ Records::RecordState.where(id: states.map(&:id))
97
+ .update_all(status: "pending", priority: "backfill", claimed_at: nil, updated_at: Time.current)
98
+ return []
99
+ end
100
+
101
+ records = definition.index_relation(model.where(model.primary_key => states.map(&:record_id))).to_a
102
+ .select { |record| definition.index_if.nil? || definition.index_if.call(record) }
92
103
  found = records.map { |record| record.id.to_s }
93
104
  gone = states.reject { |state| found.include?(state.record_id.to_s) }
94
105
  Records::RecordState.where(id: gone.map(&:id)).delete_all if gone.any?
95
106
  records
96
107
  end
97
108
 
109
+ # A label is current when it has stored rows and all carry its current
110
+ # fingerprint; choice labels store only some options (see sparse_choice).
98
111
  def stale_keys(stored, fingerprints, tenant_key, askable)
99
112
  askable.reject do |label|
100
- label.storage_keys(tenant_key).all? { |key| stored[key] == fingerprints[label.key] }
113
+ present = label.storage_keys(tenant_key).select { |key| stored.key?(key) }
114
+ present.any? && present.all? { |key| stored[key] == fingerprints[label.key] }
101
115
  end.map(&:key)
102
116
  end
103
117
 
@@ -154,7 +168,8 @@ module Truffler
154
168
  case label.type
155
169
  when :noul then [ [ label.key, answers.noul(id) ] ]
156
170
  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) ] }
171
+ when :choice
172
+ LabelDefinition.sparse_choice(label.options(tenant_key).keys.to_h { |option| [ "#{label.key}:#{option}", answers.probability(id, option) ] }).to_a
158
173
  end
159
174
  end
160
175
  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
@@ -29,6 +29,13 @@ module Truffler
29
29
  @question["criteria"].to_h.transform_keys(&:to_s)
30
30
  end
31
31
 
32
+ def option_names(tenant_key = nil)
33
+ options(tenant_key).compact
34
+ end
35
+
36
+ # The lens question, wording included, is already in its fingerprint.
37
+ def encoding_wording(_tenant_key = nil) = nil
38
+
32
39
  def storage_keys(tenant_key = nil)
33
40
  type == :choice ? options(tenant_key).keys.map { |option| "#{key}:#{option}" } : [ key ]
34
41
  end
@@ -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"