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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +71 -8
- data/lib/generators/truffler/install/templates/migration.rb.tt +6 -3
- data/lib/generators/truffler/upgrade/templates/backfill_spends_tenant_key_migration.rb.tt +25 -0
- data/lib/generators/truffler/upgrade/templates/labels_search_covering_migration.rb.tt +32 -0
- data/lib/generators/truffler/upgrade/upgrade_generator.rb +49 -3
- data/lib/tasks/truffler.rake +28 -13
- data/lib/truffler/benchmark/runner.rb +1 -1
- data/lib/truffler/clients/evaluator.rb +44 -0
- data/lib/truffler/configuration.rb +9 -0
- data/lib/truffler/definition.rb +52 -0
- data/lib/truffler/embeddings/backfill.rb +40 -11
- data/lib/truffler/embeddings/label_vector.rb +1 -1
- data/lib/truffler/embeddings/neighbor_store.rb +33 -7
- data/lib/truffler/embeddings/vector_store.rb +9 -3
- data/lib/truffler/jobs/backfill_job.rb +14 -5
- data/lib/truffler/jobs/embed_job.rb +2 -0
- data/lib/truffler/jobs/label_flush_job.rb +4 -2
- data/lib/truffler/jobs/resume_job.rb +22 -9
- data/lib/truffler/label_definition.rb +13 -2
- data/lib/truffler/labeling/backfill.rb +100 -37
- data/lib/truffler/labeling/labeler.rb +20 -5
- data/lib/truffler/labeling/queue.rb +14 -9
- data/lib/truffler/labeling/supplied.rb +16 -5
- data/lib/truffler/lenses/backfill.rb +28 -10
- data/lib/truffler/model.rb +5 -0
- data/lib/truffler/providers/backup.rb +1 -1
- data/lib/truffler/query_encoding/encoder.rb +10 -6
- data/lib/truffler/records/backfill_spend.rb +30 -6
- data/lib/truffler/redaction.rb +1 -1
- data/lib/truffler/search/encoding.rb +12 -6
- data/lib/truffler/search/filler.rb +31 -6
- data/lib/truffler/search/keystroke.rb +29 -15
- data/lib/truffler/search/result.rb +11 -1
- data/lib/truffler/search/sql.rb +70 -10
- data/lib/truffler/smart_search/dispatcher.rb +1 -1
- data/lib/truffler/smart_search/starter.rb +1 -1
- data/lib/truffler/version.rb +1 -1
- 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
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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).
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
|
-
|
|
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:
|
|
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
|
|
83
|
+
def dialect(model)
|
|
61
84
|
@dialect ||= self.class.dialect(model.connection)
|
|
62
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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)
|
|
24
|
-
|
|
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,
|
|
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
|
|
7
|
-
# rows waiting at backfill priority.
|
|
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
|
|
10
|
-
# embedder outage cannot pile duplicate
|
|
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.
|
|
40
|
-
|
|
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)
|
|
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
|
|
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
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# and
|
|
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
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
# the
|
|
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
|
|
102
|
-
# when nothing was spent yet or the ledger
|
|
103
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
115
|
-
.
|
|
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 =
|
|
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,
|
|
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,
|
|
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
|
-
|
|
172
|
-
|
|
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:
|
|
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
|
-
#
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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:
|
|
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
|