truffler 0.1.4 → 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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/README.md +71 -8
  4. data/lib/generators/truffler/install/templates/migration.rb.tt +6 -3
  5. data/lib/generators/truffler/upgrade/templates/backfill_spends_tenant_key_migration.rb.tt +25 -0
  6. data/lib/generators/truffler/upgrade/templates/labels_search_covering_migration.rb.tt +32 -0
  7. data/lib/generators/truffler/upgrade/upgrade_generator.rb +49 -3
  8. data/lib/tasks/truffler.rake +28 -13
  9. data/lib/truffler/benchmark/runner.rb +1 -1
  10. data/lib/truffler/clients/evaluator.rb +44 -0
  11. data/lib/truffler/configuration.rb +9 -0
  12. data/lib/truffler/definition.rb +52 -0
  13. data/lib/truffler/embeddings/backfill.rb +40 -11
  14. data/lib/truffler/embeddings/label_vector.rb +1 -1
  15. data/lib/truffler/embeddings/neighbor_store.rb +33 -7
  16. data/lib/truffler/embeddings/vector_store.rb +9 -3
  17. data/lib/truffler/jobs/backfill_job.rb +14 -5
  18. data/lib/truffler/jobs/embed_job.rb +2 -0
  19. data/lib/truffler/jobs/label_flush_job.rb +4 -2
  20. data/lib/truffler/jobs/resume_job.rb +22 -9
  21. data/lib/truffler/label_definition.rb +13 -2
  22. data/lib/truffler/labeling/backfill.rb +100 -37
  23. data/lib/truffler/labeling/labeler.rb +20 -5
  24. data/lib/truffler/labeling/queue.rb +14 -9
  25. data/lib/truffler/labeling/supplied.rb +16 -5
  26. data/lib/truffler/lenses/backfill.rb +28 -10
  27. data/lib/truffler/model.rb +5 -0
  28. data/lib/truffler/providers/backup.rb +1 -1
  29. data/lib/truffler/query_encoding/encoder.rb +10 -6
  30. data/lib/truffler/records/backfill_spend.rb +30 -6
  31. data/lib/truffler/redaction.rb +1 -1
  32. data/lib/truffler/search/encoding.rb +12 -6
  33. data/lib/truffler/search/filler.rb +31 -6
  34. data/lib/truffler/search/keystroke.rb +29 -15
  35. data/lib/truffler/search/result.rb +11 -1
  36. data/lib/truffler/search/sql.rb +70 -10
  37. data/lib/truffler/smart_search/dispatcher.rb +1 -1
  38. data/lib/truffler/smart_search/starter.rb +1 -1
  39. data/lib/truffler/version.rb +1 -1
  40. metadata +4 -1
@@ -3,11 +3,15 @@ module Truffler
3
3
  # Finds records whose embedding is missing or was made under another
4
4
  # fingerprint (model, width, or fields changed) and enqueues EmbedJob for
5
5
  # them, newest first, `batch_size` jobs at a time behind an id cursor.
6
- # ResumeJob runs a bounded pass on every sweep; hosts call `enqueue` with
7
- # no limit after enabling embeddings or changing the model, width, or
8
- # fields.
6
+ # It pages over the definition's index_scope, one tenant at a time when
7
+ # given `tenant_key:`, skipping disabled tenants, with a NOT EXISTS
8
+ # anti-join against truffler_record_states so each page stops at its
9
+ # limit instead of materializing every current id. ResumeJob runs a
10
+ # bounded pass on every sweep; hosts call `enqueue` with no limit after
11
+ # enabling embeddings or changing the model, width, or fields.
9
12
  class Backfill
10
13
  BATCH_SIZE = 1_000
14
+ STATES = Records::RecordState.table_name
11
15
 
12
16
  attr_reader :model
13
17
 
@@ -15,25 +19,30 @@ module Truffler
15
19
  @model = model
16
20
  end
17
21
 
18
- def stale_ids(limit: nil, before: nil)
22
+ def stale_ids(limit: nil, before: nil, tenant_key: nil)
19
23
  definition = model.truffler_definition
20
24
  return [] unless Embeddings.managed?(definition)
25
+ return [] if tenant_key && !definition.tenant_enabled?(tenant_key)
21
26
 
22
- current = Records::RecordState.for_model(model).where(embedding_fingerprint: Embeddings.fingerprint(definition))
23
- .where.not(embedded_at: nil).select(:record_id)
24
- scope = model.where.not(model.primary_key => current)
25
- scope = scope.where(model.primary_key => ...before) if before
26
- scope.order(model.primary_key => :desc).limit(limit).pluck(model.primary_key)
27
+ pk = model.arel_table[model.primary_key]
28
+ scope = definition.index_relation(model.all).where(current_embedding_missing_sql(definition))
29
+ if tenant_key && definition.scoped?
30
+ scope = scope.where(definition.tenant_column => tenant_key)
31
+ elsif definition.scoped? && Truffler.config.tenant_enabled
32
+ scope = scope.where(definition.tenant_column => tenant_keys)
33
+ end
34
+ scope = scope.where(pk.lt(before)) if before
35
+ scope.reorder(pk.desc).limit(limit).pluck(pk)
27
36
  end
28
37
 
29
- def enqueue(limit: nil, batch_size: BATCH_SIZE)
38
+ def enqueue(limit: nil, batch_size: BATCH_SIZE, tenant_key: nil)
30
39
  count = 0
31
40
  cursor = nil
32
41
  loop do
33
42
  take = limit ? [ batch_size, limit - count ].min : batch_size
34
43
  break unless take.positive?
35
44
 
36
- ids = stale_ids(limit: take, before: cursor)
45
+ ids = stale_ids(limit: take, before: cursor, tenant_key: tenant_key)
37
46
  break if ids.empty?
38
47
 
39
48
  ActiveJob.perform_all_later(ids.map { |id| Jobs::EmbedJob.new(model.polymorphic_name, id) })
@@ -43,6 +52,26 @@ module Truffler
43
52
  end
44
53
  count
45
54
  end
55
+
56
+ # The enabled tenants with records in the index scope, for per-tenant sweeps.
57
+ def tenant_keys
58
+ definition = model.truffler_definition
59
+ return [ nil ] unless definition.scoped?
60
+
61
+ definition.index_relation(model.all).reorder(nil).distinct.pluck(definition.tenant_column)
62
+ .map(&:to_s).select { |key| definition.tenant_enabled?(key) }.sort
63
+ end
64
+
65
+ private
66
+
67
+ def current_embedding_missing_sql(definition)
68
+ pk = "#{model.quoted_table_name}.#{model.connection.quote_column_name(model.primary_key)}"
69
+ ActiveRecord::Base.sanitize_sql_array([
70
+ "NOT EXISTS (SELECT 1 FROM #{STATES} WHERE #{STATES}.record_type = ? AND #{STATES}.record_id = #{pk} " \
71
+ "AND #{STATES}.embedding_fingerprint = ? AND #{STATES}.embedded_at IS NOT NULL)",
72
+ model.polymorphic_name, Embeddings.fingerprint(definition)
73
+ ])
74
+ end
46
75
  end
47
76
  end
48
77
  end
@@ -33,7 +33,7 @@ module Truffler
33
33
  label_vocabulary_version: version, created_at: now, updated_at: now }
34
34
  end
35
35
  Records::Embedding.upsert_all(rows, unique_by: %i[record_type record_id],
36
- update_only: %i[tenant_key label_vector label_vocabulary_version updated_at])
36
+ update_only: %i[tenant_key label_vector label_vocabulary_version])
37
37
  rows.size
38
38
  end
39
39
 
@@ -2,9 +2,13 @@ module Truffler
2
2
  module Embeddings
3
3
  # Cosine distance computed by the database: pgvector's `<=>` on a vector
4
4
  # column, or sqlite-vec's `vec_distance_cosine` on float32 blobs (the
5
- # host loads the extension). Because similarity is plain SQL, search can
6
- # score text inline in its one query as an exact scan of the tenant's
7
- # rows; an approximate (HNSW) index is an opt-in host migration.
5
+ # host loads the extension).
6
+ #
7
+ # On Postgres, search reads text similarity from the tenant's top `k`
8
+ # neighbors (`neighbors_sql`, one `ORDER BY embedding <=> q LIMIT k`
9
+ # that an HNSW index can serve), so records outside the top K score no
10
+ # text similarity. Pass `top_k: false` for an exact inline similarity per
11
+ # row, the sqlite-vec default; `top_k: true` uses the join there too.
8
12
  class NeighborStore < VectorStore
9
13
  TABLE = "truffler_embeddings".freeze
10
14
 
@@ -35,16 +39,31 @@ module Truffler
35
39
  end
36
40
  end
37
41
 
38
- def initialize(dialect: nil)
42
+ attr_reader :k
43
+
44
+ def initialize(dialect: nil, k: DEFAULT_K, top_k: nil)
39
45
  @dialect = dialect
46
+ @k = Integer(k)
47
+ @top_k = top_k
40
48
  end
41
49
 
42
- def nearest(model, tenant_key:, vector:, k: DEFAULT_K)
50
+ def nearest(model, tenant_key:, vector:, k: self.k)
43
51
  distance = distance_sql(model, "#{TABLE}.embedding", vector)
44
52
  embeddings(model, tenant_key, vector.size).order(Arel.sql(distance)).limit(k)
45
53
  .pluck(:record_id, Arel.sql("1 - #{distance}")).map { |id, similarity| [ id, similarity.to_f ] }
46
54
  end
47
55
 
56
+ # The tenant's `k` nearest vectors as `(record_id, similarity)` rows,
57
+ # for search to LEFT JOIN on record_id; nil when this store scores
58
+ # inline instead.
59
+ def neighbors_sql(model, tenant_key:, vector:, k: self.k)
60
+ return unless top_k?(model)
61
+
62
+ distance = distance_sql(model, "#{TABLE}.embedding", vector)
63
+ embeddings(model, tenant_key, vector.size).reorder(Arel.sql(distance)).limit(k)
64
+ .select(Arel.sql("#{TABLE}.record_id AS record_id"), Arel.sql("1 - #{distance} AS similarity")).to_sql
65
+ end
66
+
48
67
  def similarity_sql(model, tenant_key:, vector:, k: nil)
49
68
  similarity = "1 - #{distance_sql(model, "#{TABLE}.embedding", vector)}"
50
69
  subquery = embeddings(model, tenant_key, vector.size).where("#{TABLE}.record_id = #{primary_key_sql(model)}")
@@ -55,11 +74,18 @@ module Truffler
55
74
  true
56
75
  end
57
76
 
77
+ def top_k?(model)
78
+ @top_k.nil? ? dialect(model) == :postgres : @top_k
79
+ end
80
+
58
81
  private
59
82
 
60
- def distance_sql(model, column_sql, vector)
83
+ def dialect(model)
61
84
  @dialect ||= self.class.dialect(model.connection)
62
- self.class.distance_sql(@dialect, model.connection, column_sql, vector)
85
+ end
86
+
87
+ def distance_sql(model, column_sql, vector)
88
+ self.class.distance_sql(dialect(model), model.connection, column_sql, vector)
63
89
  end
64
90
  end
65
91
  end
@@ -5,7 +5,12 @@ module Truffler
5
5
  # `similarity_sql` returns a scalar SQL expression over the model's table
6
6
  # that search can put in its SELECT or ORDER BY: stores that compute
7
7
  # similarity in the database (`inline_sql?`) scan every row exactly, and
8
- # the rest fall back to a CASE over the top-K neighbors.
8
+ # the rest fall back to a CASE over the top-K neighbors. A store may also
9
+ # answer `neighbors_sql` with a `(record_id, similarity)` subquery, which
10
+ # search LEFT JOINs instead (see NeighborStore).
11
+ #
12
+ # `config.vector_store` is one of ADAPTERS or a store instance, such as
13
+ # `NeighborStore.new(k: 500)` or a subclass of this class.
9
14
  class VectorStore
10
15
  ADAPTERS = %i[auto ruby neighbor].freeze
11
16
  DEFAULT_K = 200
@@ -14,12 +19,13 @@ module Truffler
14
19
  embeddings = model.truffler_definition.embeddings
15
20
  return unless embeddings
16
21
  return ColumnStore.new(embeddings[:column]) if embeddings.key?(:column)
22
+ return config.vector_store if config.vector_store.respond_to?(:similarity_sql)
17
23
 
18
24
  case config.vector_store&.to_sym
19
25
  when :ruby then RubyStore.new
20
26
  when :neighbor then NeighborStore.new
21
27
  when :auto then NeighborStore.available?(model.connection) ? NeighborStore.new : RubyStore.new
22
- else raise Error, "config.vector_store must be one of #{ADAPTERS.join(', ')}"
28
+ else raise Error, "config.vector_store must be one of #{ADAPTERS.join(', ')} or a store instance"
23
29
  end
24
30
  end
25
31
 
@@ -58,7 +64,7 @@ module Truffler
58
64
  fingerprint: fingerprint, embedding: Records::Embedding.encode(vector), dimensions: vector.size,
59
65
  created_at: now, updated_at: now },
60
66
  unique_by: %i[record_type record_id],
61
- update_only: %i[tenant_key fingerprint embedding dimensions updated_at]
67
+ update_only: %i[tenant_key fingerprint embedding dimensions]
62
68
  )
63
69
  end
64
70
 
@@ -1,7 +1,9 @@
1
1
  module Truffler
2
2
  module Jobs
3
3
  # Backfills one model's stale, missing, failed, and demoted labels at
4
- # backfill priority. Arguments are the record type, the id cursor, the
4
+ # backfill priority, over one tenant when given `tenant_key:` (the
5
+ # tenant's own spend ledger) or else the whole model's indexed records.
6
+ # Arguments are the record type, the tenant key, the id cursor, the
5
7
  # spend so far, the cap, the retry attempt, and the count of budget
6
8
  # denials in a row, never record text. A budget denial reschedules the job
7
9
  # from its cursor after the same backoff a waiting Labeling::Backfill uses
@@ -15,16 +17,23 @@ module Truffler
15
17
 
16
18
  queue_as { Truffler.config.queue_name }
17
19
 
18
- def perform(record_type, cursor: nil, spent: 0.0, spend_cap: Truffler.config.backfill_spend_cap, max_pages: MAX_PAGES,
20
+ # The job's tenant keyword: the tenant for scoped models, none otherwise.
21
+ def self.tenant_argument(model, tenant_key)
22
+ model.truffler_definition.scoped? && !tenant_key.nil? ? { tenant_key: tenant_key.to_s } : {}
23
+ end
24
+
25
+ def perform(record_type, tenant_key: nil, cursor: nil, spent: 0.0, spend_cap: Truffler.config.backfill_spend_cap, max_pages: MAX_PAGES,
19
26
  attempt: 0, denials: 0)
20
27
  model = record_type.safe_constantize
21
28
  return unless model.respond_to?(:truffler_definition) && model.truffler_definition
22
29
 
23
- result = Labeling::Backfill.new(model, cursor: cursor, spent: spent, spend_cap: spend_cap).run(max_pages: max_pages)
24
- Instrumentation.instrument(:backfill, record_type: record_type, outcome: result.status,
30
+ result = Labeling::Backfill.new(model, tenant_key: tenant_key, cursor: cursor, spent: spent, spend_cap: spend_cap)
31
+ .run(max_pages: max_pages)
32
+ Instrumentation.instrument(:backfill, record_type: record_type, tenant_key: tenant_key, outcome: result.status,
25
33
  labeled_count: result.labeled, request_count: result.requests, cost: result.cost)
26
34
 
27
- follow_up = { cursor: result.cursor, spent: spent + result.cost, spend_cap: spend_cap, max_pages: max_pages }
35
+ follow_up = { **self.class.tenant_argument(model, tenant_key), cursor: result.cursor, spent: spent + result.cost,
36
+ spend_cap: spend_cap, max_pages: max_pages }
28
37
  case result.status
29
38
  when :budget_denied then retry_after_denial(record_type, follow_up, result, denials)
30
39
  when :paused then self.class.perform_later(record_type, **follow_up)
@@ -3,6 +3,7 @@ module Truffler
3
3
  # Embeds one record's declared fields and stores the vector. Arguments are
4
4
  # the record type and id only. On an embedder failure nothing is written,
5
5
  # `embedded_at` stays as it was, and the job retries; labels are untouched.
6
+ # A record the definition no longer indexes is skipped.
6
7
  class EmbedJob < ActiveJob::Base
7
8
  queue_as { Truffler.config.queue_name }
8
9
 
@@ -17,6 +18,7 @@ module Truffler
17
18
 
18
19
  record = model.find_by(model.primary_key => record_id)
19
20
  return Records::Embedding.where(record_type: record_type, record_id: record_id).delete_all unless record
21
+ return unless definition.indexable?(record)
20
22
 
21
23
  settings = definition.embeddings
22
24
  fingerprint = Embeddings.fingerprint(definition)
@@ -4,7 +4,7 @@ module Truffler
4
4
  # and tenant key only. On a Jev or budget failure the claimed rows go back
5
5
  # to pending (or failed after max_attempts) and the job retries. Rows over
6
6
  # the tenant's live cap drop to backfill priority and a delayed backfill
7
- # is scheduled for them.
7
+ # of that tenant is scheduled for them. A disabled tenant is left alone.
8
8
  class LabelFlushJob < ActiveJob::Base
9
9
  RETRYABLE = [ ClientError, BudgetExhausted, IncompleteAnswers ].freeze
10
10
 
@@ -18,6 +18,8 @@ module Truffler
18
18
 
19
19
  queue = Labeling::Queue.new(model)
20
20
  queue.clear_marker(tenant_key)
21
+ return unless model.truffler_definition.tenant_enabled?(tenant_key)
22
+
21
23
  states = queue.claim(tenant_key, priority: :live, limit: Truffler.config.batch_size)
22
24
  return if states.empty?
23
25
 
@@ -30,7 +32,7 @@ module Truffler
30
32
 
31
33
  if result.demoted
32
34
  queue.demote(states)
33
- queue.schedule_backfill
35
+ queue.schedule_backfill(tenant_key)
34
36
  end
35
37
  queue.schedule(tenant_key) if queue.pending?(tenant_key, priority: :live)
36
38
  end
@@ -3,12 +3,13 @@ module Truffler
3
3
  # A periodic sweep hosts schedule (every few minutes) so labeling resumes
4
4
  # after a Jev outage or a crashed worker. It returns failed rows and rows
5
5
  # stuck in labeling to pending, reschedules a flush for tenants whose live
6
- # rows have waited past `resume_pending_after`, and starts a backfill for
7
- # rows waiting at backfill priority. For models with gem-managed
6
+ # rows have waited past `resume_pending_after`, and starts a BackfillJob
7
+ # per tenant with rows waiting at backfill priority. Disabled tenants
8
+ # (config.tenant_enabled) get neither. For models with gem-managed
8
9
  # embeddings it also enqueues up to `embedding_sweep_limit` missing or
9
- # stale embeddings, at most once per `embedding_sweep_interval`, so an
10
- # embedder outage cannot pile duplicate jobs onto the queue. Pass a record
11
- # type to sweep one model.
10
+ # stale embeddings, tenant by tenant for scoped models, at most once per
11
+ # `embedding_sweep_interval`, so an embedder outage cannot pile duplicate
12
+ # jobs onto the queue. Pass a record type to sweep one model.
12
13
  class ResumeJob < ActiveJob::Base
13
14
  queue_as { Truffler.config.queue_name }
14
15
 
@@ -28,16 +29,20 @@ module Truffler
28
29
  requeued = requeue(states.where(status: "failed").or(states.where(status: "labeling").where(claimed_at: ...cutoff)))
29
30
  waiting = states.where(status: "pending").where(updated_at: ...cutoff)
30
31
 
32
+ definition = model.truffler_definition
31
33
  live = (requeued.select { |_, priority| priority == "live" }.map(&:first) +
32
- waiting.where(priority: "live").distinct.pluck(:tenant_key)).uniq
34
+ waiting.where(priority: "live").distinct.pluck(:tenant_key)).uniq.select { |key| definition.tenant_enabled?(key) }
33
35
  queue = Labeling::Queue.new(model)
34
36
  live.each do |tenant_key|
35
37
  queue.clear_marker(tenant_key)
36
38
  queue.schedule(tenant_key)
37
39
  end
38
40
 
39
- backfill = requeued.any? { |_, priority| priority == "backfill" } || waiting.exists?(priority: "backfill")
40
- BackfillJob.perform_later(model.polymorphic_name) if backfill
41
+ backfill = (requeued.select { |_, priority| priority == "backfill" }.map(&:first) +
42
+ waiting.where(priority: "backfill").distinct.pluck(:tenant_key)).uniq
43
+ backfill.select { |key| definition.tenant_enabled?(key) }.each do |tenant_key|
44
+ BackfillJob.perform_later(model.polymorphic_name, **BackfillJob.tenant_argument(model, tenant_key))
45
+ end
41
46
  sweep_embeddings(model)
42
47
  end
43
48
 
@@ -47,7 +52,15 @@ module Truffler
47
52
  marker = "truffler:embedding_sweep:#{model.polymorphic_name}"
48
53
  return unless Truffler.config.cache_store.write(marker, true, unless_exist: true, expires_in: embedding_sweep_interval)
49
54
 
50
- Embeddings::Backfill.new(model).enqueue(limit: embedding_sweep_limit)
55
+ backfill = Embeddings::Backfill.new(model)
56
+ return backfill.enqueue(limit: embedding_sweep_limit) unless model.truffler_definition.scoped?
57
+
58
+ remaining = embedding_sweep_limit
59
+ backfill.tenant_keys.each do |tenant_key|
60
+ break unless remaining.positive?
61
+
62
+ remaining -= backfill.enqueue(limit: remaining, tenant_key: tenant_key)
63
+ end
51
64
  end
52
65
 
53
66
  # Returns the distinct [tenant_key, priority] pairs it moved to pending.
@@ -1,7 +1,9 @@
1
1
  module Truffler
2
2
  # One typed label question. Nouls store their probability, scores their
3
3
  # normalized position, and choices one row per option ("label:option") with
4
- # that option's probability. Choice options may be a callable of the tenant
4
+ # that option's probability, for options at or above
5
+ # config.choice_min_probability plus the most likely option; a missing
6
+ # option row reads as 0.0. Choice options may be a callable of the tenant
5
7
  # key, which makes the vocabulary per-tenant; a tenant it gives no options
6
8
  # (`{}` or nil) simply does not have the label. An option's value is its
7
9
  # description, or `{ description:, search: }` to give query encoding a
@@ -104,10 +106,19 @@ module Truffler
104
106
  case type
105
107
  when :noul then { key => probability(value) }
106
108
  when :score then { key => level(value) }
107
- when :choice then choice_values(value, options(tenant_key).keys)
109
+ when :choice then self.class.sparse_choice(choice_values(value, options(tenant_key).keys))
108
110
  end
109
111
  end
110
112
 
113
+ # Drops choice option rows ({"label:option" => probability}) below
114
+ # config.choice_min_probability, always keeping the most likely option.
115
+ def self.sparse_choice(values, min: Truffler.config.choice_min_probability)
116
+ return values if min.nil? || values.empty?
117
+
118
+ top = values.max_by { |_, probability| probability.to_f }.first
119
+ values.select { |key, probability| key == top || probability.to_f >= min }
120
+ end
121
+
111
122
  def storage_keys(tenant_key = nil)
112
123
  type == :choice ? options(tenant_key).keys.map { |option| "#{key}:#{option}" } : [ key ]
113
124
  end
@@ -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