truffler 0.1.1 → 0.1.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 78736a9bfd83f38c6623b52904121d19a15c33f9d53bad271c1021968749c11d
4
- data.tar.gz: 68f40b4143278a1ab48102aaa5d0146fc00c71885f332180dacdd2495e6e0a7d
3
+ metadata.gz: 99d7f61714bf653e012b5cea1f9d57cfbde9d390aae048ba0d52387292a37159
4
+ data.tar.gz: b9826342d2552dae2b1cd9d671ac4d4d448f5fcbe3822b21e5788b7d272365b0
5
5
  SHA512:
6
- metadata.gz: 2b98ccc3b180c487994c17eb6ed194e300ecace2948b549952a7b731e9bd5364c74a59656e93d8235bd6499aa41cb0926ea6ccf80acb9eed69e4946f5219b133
7
- data.tar.gz: 860b3932fa42544edd3bee2ecf65ec3e90ae0aa8d9d9c6d8c1ea073dd9e5a0b7d7e4af78ab28dcd469aced26a47aa73e91b148878b98ea999fe8cf7cee5b3e5a
6
+ metadata.gz: 414ddacdd2e195a077792a36b3c1e8050a8e086a99ccb7ffed065776826f6af2c83da47835ce36d663db166b664e2559a01f2f92dcc6c493e120bbcd60ae89f8
7
+ data.tar.gz: 272d2d762fdcd5e0f6a6d0cec08ca332619a14e6ab8b3b94a5f2670e16091bf0c75395866a5a77ede42e7d22483a95252cbf6b0cee3565cacfb91d2ae5c17569
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.2]
4
+
5
+ Fixes from happyhappy's production `churn_risk` backfill and its 0.1.1 upgrade.
6
+
7
+ - `rake truffler:backfill` waits out budget denials instead of ending on `budget_denied`. It backs off 1 s, doubling to 30 s (longer when the budget's new `Decision#retry_after` hint says so), and retries from the same cursor until the backfill completes or reaches the spend cap. `MAX_DURATION=<seconds>` stops it with `paused` and the cursor. While waiting it prints counts, spend, and the cursor, never record text. In code: `Labeling::Backfill#run(wait: true, max_duration:, sleeper:, clock:, progress:)`. `BackfillJob` reschedules denials with the same backoff (it used to wait a fixed 30 s), counting consecutive denials in its arguments.
8
+ - The backfill spend cap holds across runs. Spend is recorded per model and app-wide vocabulary version in a new `truffler_backfill_spends` table. Each Jev request reserves its estimate against the cap in SQL, so rerunning the rake task or overlapping `BackfillJob` chains can no longer spend more than `backfill_spend_cap` for one vocabulary version. Before, overlapping chains could spend up to twice the cap. A vocabulary change starts a new ledger. `truffler:status` prints the spend for the current version, and `RESET_SPEND=1` zeroes it before a backfill. Lens backfills still charge only their lens row.
9
+ - Upgrading from 0.1.1: run `bin/rails generate truffler:upgrade && bin/rails db:migrate` to add `truffler_backfill_spends`. Until you do, backfills log one warning and cap spend per run as in 0.1.1. Fresh installs get the table from `truffler:install`.
10
+ - Time phrases now include hours ("last 3 hours", "past hour", "last hour"), "past week", "past month", and "past/last N months" as rolling windows ending now; "this/last week" and "this/last month" keep their calendar meaning. Chips and suppression are unchanged.
11
+ - A query word now names a choice option by the words of its display name or description (minus stopwords), not only its key, so "spiral" names option `p_17` described as "Spiral writing tool" when the encoding applies it. The request state's label vocabulary adds `option_names` (option key to display name) next to `options`.
12
+
3
13
  ## [0.1.1]
4
14
 
5
15
  Fixes from the first host integration (happyhappy).
data/README.md CHANGED
@@ -34,12 +34,23 @@ bin/rails db:migrate
34
34
 
35
35
  The generator writes three files:
36
36
 
37
- - A migration that creates `truffler_labels`, `truffler_record_states`, `truffler_embeddings`, `truffler_query_misses`, `truffler_lenses`, and `truffler_lens_versions`.
37
+ - A migration that creates `truffler_labels`, `truffler_record_states`, `truffler_embeddings`, `truffler_query_misses`, `truffler_lenses`, `truffler_lens_versions`, and `truffler_backfill_spends`.
38
38
  - `config/initializers/truffler.rb`.
39
39
  - `app/channels/truffler_channel.rb`.
40
40
 
41
41
  It takes two options. `--record-id-type=bigint|integer|string|uuid` must match your primary keys. `--vector-dimensions=N` stores embeddings in a pgvector column of that width; it needs Postgres and the `neighbor` gem, which provides the `t.vector` column type.
42
42
 
43
+ ### Upgrading from 0.1.1
44
+
45
+ 0.1.2 adds the `truffler_backfill_spends` table, which keeps the backfill spend cap across runs. Add it with:
46
+
47
+ ```bash
48
+ bin/rails generate truffler:upgrade
49
+ bin/rails db:migrate
50
+ ```
51
+
52
+ The migration creates only that table and skips it if it already exists. Until you run it, backfills log one warning and cap spend per run, as 0.1.1 did.
53
+
43
54
  Truffler digests user keys and query misses with `secret_key_base`. Rails supplies it automatically; outside Rails, set `config.secret_key_base`.
44
55
 
45
56
  ## Declaring a model
@@ -172,9 +183,9 @@ result = Email.truffler(params[:q], tenant: Current.account.id, scope: Current.a
172
183
 
173
184
  A keystroke search makes no network call. It reads the query encoding and query vector from the cache. When the cache misses, it enqueues `EncodeQueryJob`, and the next keystroke or reload picks up the result.
174
185
 
175
- Query encoding sends Jev the label vocabulary (each label's description and a choice label's option names) next to the query, and asks for each label whether the query filters on it, prefers it, or ignores it, plus the role of each word. A choice label's option question also offers `Truffler::NO_OPTION` (`"truffler:none"`), meaning the query names none of its options, so a host option literally called `none` stays filterable. Word roles are then checked locally: a word that names a label the query applies (its key, a word of its key, or the chosen option, ignoring case and plurals, or a word of four letters or more sharing its first three letters, so "angry" names `anger`) counts as naming the label, and common stopwords are dropped. When the encoding applies a label filter, the filter decides which records match and keyword hits only rank them; without one, the remaining keywords must match.
186
+ Query encoding sends Jev the label vocabulary (each label's description and a choice label's option names) next to the query, and asks for each label whether the query filters on it, prefers it, or ignores it, plus the role of each word. A choice label's option question also offers `Truffler::NO_OPTION` (`"truffler:none"`), meaning the query names none of its options, so a host option literally called `none` stays filterable. Word roles are then checked locally: a word that names a label the query applies (its key, a word of its key, the chosen option, or a word of that option's display name or description, ignoring case and plurals, or a word of four letters or more sharing its first three letters, so "angry" names `anger`) counts as naming the label, and common stopwords are dropped. When the encoding applies a label filter, the filter decides which records match and keyword hits only rank them; without one, the remaining keywords must match.
176
187
 
177
- Time phrases are handled in Ruby and never asked of Jev: `today`, `yesterday`, `this week`, `last week`, `this month`, `last month`, `past|last N days|weeks`, and `since monday` through `since sunday`. The first one in a query limits results to records whose `arrived_at` column falls in that window, its words are not keywords, and it shows as a chip `{key: "time", label: "time", kind: :time, name: "This week"}`. Pass `"time"` in `suppressed:` to drop it. Weeks start on `Date.beginning_of_week`, and the window is computed from `Time.current` (or a `clock:` callable passed to the search, for tests).
188
+ Time phrases are handled in Ruby and never asked of Jev: `today`, `yesterday`, `this week`, `last week`, `this month`, `last month`, `past|last N hours|days|weeks|months`, `last hour`, `past hour`, `past week`, `past month`, and `since monday` through `since sunday`. "Past/last N units" is a rolling window ending now; "this/last week" and "this/last month" are calendar windows. The first one in a query limits results to records whose `arrived_at` column falls in that window, its words are not keywords, and it shows as a chip `{key: "time", label: "time", kind: :time, name: "This week"}`. Pass `"time"` in `suppressed:` to drop it. Weeks start on `Date.beginning_of_week`, and the window is computed from `Time.current` (or a `clock:` callable passed to the search, for tests).
178
189
 
179
190
  The returned `Truffler::Search::Result` exposes:
180
191
 
@@ -327,7 +338,7 @@ Set these in `Truffler.configure do |config| ... end`.
327
338
  | `max_field_chars`, `request_token_budget`, `max_questions_per_request` | 4,000, 48,000, 200 | Request packing limits. |
328
339
  | `queue_name` | `:default` | Queue for every Truffler job. |
329
340
  | `cost_per_million_tokens` | 0.042 | Jev input price, used in usage events and estimates. |
330
- | `backfill_spend_cap` | 5.0 | Dollar cap per backfill run (`BackfillJob` chains, `ResumeJob` backfills, and `truffler:backfill`). `nil` disables it; for the rake task, `SPEND_CAP=none` does. Supplied labels cost nothing and are still written once it is reached. |
341
+ | `backfill_spend_cap` | 5.0 | Dollar cap on backfill spend per model and vocabulary version, shared by `BackfillJob` chains, `ResumeJob` backfills, and `truffler:backfill` runs (see Backfill). `nil` disables it; for the rake task, `SPEND_CAP=none` does. Supplied labels cost nothing and are still written once it is reached. |
331
342
  | `resume_pending_after` | 5 minutes | How long before `ResumeJob` treats work as stuck. |
332
343
  | `embedder` | `Embeddings::RubyLLMEmbedder.new` | Any `Embeddings::Embedder` subclass. The default calls `RubyLLM.embed`, which works on ruby_llm 1.x and 2. |
333
344
  | `embedding_cost_per_million_tokens` | 0.02 | Embedding price. |
@@ -353,10 +364,10 @@ Truffler enqueues most of its own jobs. Run a worker for `config.queue_name` and
353
364
  | `Truffler::Jobs::ResumeJob` | Every few minutes. Requeues failed and stuck labeling after an outage or a crashed worker, and enqueues up to 1,000 missing or stale embeddings per model per hour. |
354
365
  | `Truffler::Jobs::PruneQueryMissesJob` | Daily. Enforces `miss_retention`. |
355
366
  | `Truffler::Jobs::ExpireLensesJob` | Daily. Expires lenses unused for `lenses.expire_after`. |
356
- | `bin/rails "truffler:backfill[Email]"` (`SPEND_CAP=20`, or `none`; default `backfill_spend_cap`) or `Truffler::Jobs::BackfillJob.perform_later("Email")` | After adopting Truffler, changing a declaration, or changing the model pin. |
367
+ | `bin/rails "truffler:backfill[Email]"` (see Backfill) or `Truffler::Jobs::BackfillJob.perform_later("Email")` | After adopting Truffler, changing a declaration, or changing the model pin. |
357
368
  | `Truffler::Embeddings::Backfill.new(Email).enqueue` | After enabling embeddings or changing the embedding model, width, or fields, to re-embed everything now instead of through the `ResumeJob` sweep. It enqueues 1,000 jobs at a time; pass `limit:` to cap the total. |
358
369
 
359
- `bin/rails "truffler:status[Email]"` prints labeling counts. `bin/rails "truffler:suggestions[Email]"` prints candidate questions drawn from logged query misses.
370
+ `bin/rails "truffler:status[Email]"` prints labeling counts and the backfill spend for the current vocabulary version. `bin/rails "truffler:suggestions[Email]"` prints candidate questions drawn from logged query misses.
360
371
 
361
372
  With Solid Queue, for example:
362
373
 
@@ -373,6 +384,18 @@ truffler_expire_lenses:
373
384
  schedule: every day at 3am
374
385
  ```
375
386
 
387
+ ### Backfill
388
+
389
+ `bin/rails "truffler:backfill[Email]"` labels missing, stale, and failed records inline, newest first, at backfill priority. Backfill has the lowest share of the request budget, so the task waits whenever the budget turns it away. It backs off 1 s, doubling to 30 s (or longer if the budget says a slot frees later), and resumes from the same cursor. It ends when every record is current (`complete`) or the spend cap is reached (`spend_cap_reached`). While it waits it prints the records labeled, the spend, and the cursor, never record text. Environment variables:
390
+
391
+ | Variable | Effect |
392
+ |---|---|
393
+ | `SPEND_CAP=20` or `none` | Overrides `backfill_spend_cap` for this run. |
394
+ | `MAX_DURATION=600` | Stops after that many seconds with `paused` and the cursor. Rerun to continue. |
395
+ | `RESET_SPEND=1` | Zeroes the spend recorded for the current vocabulary version before starting. |
396
+
397
+ The spend cap holds across runs. Truffler records backfill spend per model and app-wide vocabulary version in `truffler_backfill_spends`, and reserves each request's estimate against the cap in SQL. A rerun, a `ResumeJob` backfill, and overlapping `BackfillJob` chains all draw on the same total, so together they stop at `backfill_spend_cap`. Changing the vocabulary (a reworded question, a new label, or an activated lens) starts a new total. Lens backfills are capped separately by each lens's `spend_cap_usd`. `BackfillJob` reschedules itself after a denial with the same backoff. In code, `Truffler::Labeling::Backfill.new(Email).run(wait: true, max_duration: 600)` does what the task does.
398
+
376
399
  ## Privacy
377
400
 
378
401
  These guarantees hold for every model, and matter most for models that use Active Record encryption:
@@ -105,5 +105,15 @@ class CreateTrufflerTables < ActiveRecord::Migration<%= migration_version %>
105
105
  end
106
106
  add_index :truffler_lens_versions, [:lens_id, :number], unique: true,
107
107
  name: "index_truffler_lens_versions_on_lens_and_number"
108
+
109
+ create_table :truffler_backfill_spends do |t|
110
+ t.string :record_type, null: false
111
+ t.string :vocabulary_version, null: false
112
+ t.float :spent_usd, null: false, default: 0.0
113
+ t.integer :requests, null: false, default: 0
114
+ t.timestamps
115
+ end
116
+ add_index :truffler_backfill_spends, [:record_type, :vocabulary_version], unique: true,
117
+ name: "index_truffler_backfill_spends_on_ledger"
108
118
  end
109
119
  end
@@ -0,0 +1,19 @@
1
+ class CreateTrufflerBackfillSpends < ActiveRecord::Migration<%= migration_version %>
2
+ def up
3
+ return if table_exists?(:truffler_backfill_spends)
4
+
5
+ create_table :truffler_backfill_spends do |t|
6
+ t.string :record_type, null: false
7
+ t.string :vocabulary_version, null: false
8
+ t.float :spent_usd, null: false, default: 0.0
9
+ t.integer :requests, null: false, default: 0
10
+ t.timestamps
11
+ end
12
+ add_index :truffler_backfill_spends, [:record_type, :vocabulary_version], unique: true,
13
+ name: "index_truffler_backfill_spends_on_ledger"
14
+ end
15
+
16
+ def down
17
+ drop_table :truffler_backfill_spends, if_exists: true
18
+ end
19
+ end
@@ -0,0 +1,24 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+
4
+ module Truffler
5
+ module Generators
6
+ # Adds the tables a newer Truffler needs to an app installed with an older
7
+ # one. Each migration skips a table that already exists.
8
+ class UpgradeGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ def create_backfill_spends_migration
14
+ migration_template "backfill_spends_migration.rb.tt", File.join(db_migrate_path, "create_truffler_backfill_spends.rb")
15
+ end
16
+
17
+ private
18
+
19
+ def migration_version
20
+ "[#{ActiveRecord::Migration.current_version}]"
21
+ end
22
+ end
23
+ end
24
+ end
@@ -17,12 +17,34 @@ namespace :truffler do
17
17
  end
18
18
  end
19
19
 
20
- desc "Backfill stale, missing, and failed labels for a model (SPEND_CAP=dollars or none; default config.backfill_spend_cap)"
20
+ resolve_max_duration = lambda do |value|
21
+ next if value.to_s.strip.empty?
22
+
23
+ seconds = Float(value, exception: false)
24
+ abort "MAX_DURATION must be a number of seconds, got #{value.inspect}" unless seconds&.positive?
25
+ seconds
26
+ end
27
+
28
+ describe_cursor = ->(cursor) { cursor.nil? ? "none" : cursor }
29
+
30
+ desc "Backfill stale, missing, and failed labels for a model, waiting out budget denials " \
31
+ "(SPEND_CAP=dollars or none; default config.backfill_spend_cap; MAX_DURATION=seconds; RESET_SPEND=1 for a fresh spend ledger)"
21
32
  task :backfill, [ :model ] => :setup do |_, args|
22
33
  model = resolve_model.call(args[:model])
23
34
  spend_cap = resolve_spend_cap.call(ENV.fetch("SPEND_CAP", nil))
24
- result = Truffler::Labeling::Backfill.new(model, spend_cap: spend_cap).run
25
- puts "#{model.name}: #{result.status}, #{result.labeled} labeled in #{result.requests} requests, $#{format('%.6f', result.cost)}"
35
+ max_duration = resolve_max_duration.call(ENV.fetch("MAX_DURATION", nil))
36
+ if ENV.fetch("RESET_SPEND", nil) == "1"
37
+ Truffler::Labeling::Backfill.reset_spend!(model)
38
+ puts "#{model.name}: fresh spend ledger for the current vocabulary version"
39
+ end
40
+ progress = lambda do |so_far, delay|
41
+ puts "#{model.name}: waiting #{format('%.1f', delay)}s for backfill budget (#{so_far.labeled} labeled, " \
42
+ "$#{format('%.6f', so_far.cost)} spent, cursor #{describe_cursor.call(so_far.cursor)})"
43
+ end
44
+ result = Truffler::Labeling::Backfill.new(model, spend_cap: spend_cap).run(wait: true, max_duration: max_duration, progress: progress)
45
+ summary = "#{model.name}: #{result.status}, #{result.labeled} labeled in #{result.requests} requests, $#{format('%.6f', result.cost)}"
46
+ summary += ", cursor #{describe_cursor.call(result.cursor)}" if result.status == :paused
47
+ puts summary
26
48
  end
27
49
 
28
50
  desc "Print a model's labeling counts by status and staleness"
@@ -30,5 +52,13 @@ namespace :truffler do
30
52
  model = resolve_model.call(args[:model])
31
53
  puts model.name
32
54
  Truffler::Labeling::Backfill.status(model).each { |key, count| puts format(" %-9s %d", key, count) }
55
+ if Truffler::Records::BackfillSpend.available?
56
+ ledger = Truffler::Labeling::Backfill.spend(model)
57
+ cap = Truffler.config.backfill_spend_cap
58
+ puts format(" %-9s $%.6f in %d requests (vocabulary %s, cap %s)", "spent", ledger&.spent_usd.to_f, ledger&.requests.to_i,
59
+ Truffler::Labeling::Backfill.ledger_version(model).first(12), cap ? format("$%.2f", cap) : "none")
60
+ else
61
+ puts format(" %-9s %s", "spent", "not tracked across runs; run bin/rails g truffler:upgrade && bin/rails db:migrate")
62
+ end
33
63
  end
34
64
  end
@@ -8,12 +8,17 @@ module Truffler
8
8
  #
9
9
  # Outcomes: :granted takes a slot; :demoted means a tenant is over its live
10
10
  # cap and its records should wait at backfill priority (no slot is taken);
11
- # :denied means skip, pause, or reschedule. Only live callers wait, up to
12
- # max_wait. A cache that cannot count (the null store) never blocks.
11
+ # :denied means skip, pause, or reschedule, with `retry_after` seconds until
12
+ # the denying window rolls over. Only live callers wait, up to max_wait. A
13
+ # cache that cannot count (the null store) never blocks.
13
14
  class Budget
14
15
  PRIORITIES = %i[live encode rerank backfill].freeze
15
16
 
16
- Decision = Data.define(:outcome, :priority, :reason) do
17
+ Decision = Data.define(:outcome, :priority, :reason, :retry_after) do
18
+ def initialize(outcome:, priority:, reason:, retry_after: nil)
19
+ super
20
+ end
21
+
17
22
  def granted? = outcome == :granted
18
23
  def demoted? = outcome == :demoted
19
24
  def denied? = outcome == :denied
@@ -38,9 +43,9 @@ module Truffler
38
43
  return Decision.new(:demoted, :backfill, :tenant_cap) unless take(tenant_counter(tenant_key), records, config.tenant_live_cap, taken)
39
44
  end
40
45
  if (cap = config.user_caps[priority]) && user_key
41
- return deny(priority, :user_cap, taken) unless take(user_counter(priority, user_key), 1, cap, taken)
46
+ return deny(priority, :user_cap, taken, until_next_minute) unless take(user_counter(priority, user_key), 1, cap, taken)
42
47
  end
43
- return deny(priority, :exhausted, taken) unless take_second(priority)
48
+ return deny(priority, :exhausted, taken, until_next_second) unless take_second(priority)
44
49
 
45
50
  Decision.new(:granted, priority, nil)
46
51
  end
@@ -54,7 +59,7 @@ module Truffler
54
59
 
55
60
  cap = config.user_caps[priority]
56
61
  return Decision.new(:granted, priority, nil) unless cap && user_key
57
- return deny(priority, :user_cap, []) unless take(user_counter(priority, user_key), 1, cap)
62
+ return deny(priority, :user_cap, [], until_next_minute) unless take(user_counter(priority, user_key), 1, cap)
58
63
 
59
64
  Decision.new(:granted, priority, nil)
60
65
  end
@@ -97,10 +102,20 @@ module Truffler
97
102
  end
98
103
  end
99
104
 
100
- def deny(priority, reason, taken)
105
+ def deny(priority, reason, taken, retry_after)
101
106
  taken.each { |key, amount| @cache.decrement(key, amount, expires_in: 2.minutes) }
102
107
  Instrumentation.instrument(:budget_denied, priority: priority)
103
- Decision.new(:denied, priority, reason)
108
+ Decision.new(:denied, priority, reason, retry_after)
109
+ end
110
+
111
+ def until_next_second
112
+ now = @clock.call
113
+ now.floor + 1 - now
114
+ end
115
+
116
+ def until_next_minute
117
+ now = @clock.call
118
+ (minute + 1) * 60 - now
104
119
  end
105
120
 
106
121
  def tenant_counter(tenant_key)
@@ -7,7 +7,17 @@ module Truffler
7
7
  class LiveCallInTest < Error; end
8
8
  class IncompleteAnswers < Error; end
9
9
  class CassetteMiss < Error; end
10
- class BudgetExhausted < Error; end
10
+
11
+ # Carries the budget's hint of how many seconds until a retry can succeed.
12
+ class BudgetExhausted < Error
13
+ attr_reader :retry_after
14
+
15
+ def initialize(message = nil, retry_after: nil)
16
+ @retry_after = retry_after
17
+ super(message)
18
+ end
19
+ end
20
+
11
21
  class TenantMismatch < Error; end
12
22
  class NotAuthorized < Error; end
13
23
  class InvalidLens < Error; end
@@ -2,20 +2,21 @@ module Truffler
2
2
  module Jobs
3
3
  # Backfills one model's stale, missing, failed, and demoted labels at
4
4
  # backfill priority. Arguments are the record type, the id cursor, the
5
- # spend so far, the cap, and the retry attempt, never record text. A
6
- # budget denial or a page limit reschedules the job from its cursor; the
7
- # spend cap ends it. A Jev error reschedules it with backoff and the spend
5
+ # spend so far, the cap, the retry attempt, and the count of budget
6
+ # denials in a row, never record text. A budget denial reschedules the job
7
+ # from its cursor after the same backoff a waiting Labeling::Backfill uses
8
+ # (reset whenever a run lands work); a page limit reschedules it at once;
9
+ # the spend cap ends it. A Jev error reschedules it with backoff and the spend
8
10
  # already made, so retries cannot push past the cap; after MAX_ATTEMPTS
9
11
  # the released rows wait for the ResumeJob sweep.
10
12
  class BackfillJob < ActiveJob::Base
11
13
  MAX_PAGES = 20
12
14
  MAX_ATTEMPTS = 10
13
- BUDGET_WAIT = 30.seconds
14
15
 
15
16
  queue_as { Truffler.config.queue_name }
16
17
 
17
18
  def perform(record_type, cursor: nil, spent: 0.0, spend_cap: Truffler.config.backfill_spend_cap, max_pages: MAX_PAGES,
18
- attempt: 0)
19
+ attempt: 0, denials: 0)
19
20
  model = record_type.safe_constantize
20
21
  return unless model.respond_to?(:truffler_definition) && model.truffler_definition
21
22
 
@@ -25,7 +26,7 @@ module Truffler
25
26
 
26
27
  follow_up = { cursor: result.cursor, spent: spent + result.cost, spend_cap: spend_cap, max_pages: max_pages }
27
28
  case result.status
28
- when :budget_denied then self.class.set(wait: BUDGET_WAIT).perform_later(record_type, **follow_up)
29
+ when :budget_denied then retry_after_denial(record_type, follow_up, result, denials)
29
30
  when :paused then self.class.perform_later(record_type, **follow_up)
30
31
  when :client_error then retry_later(record_type, follow_up, attempt + 1)
31
32
  when :complete, :spend_cap_reached then nil
@@ -35,6 +36,12 @@ module Truffler
35
36
 
36
37
  private
37
38
 
39
+ def retry_after_denial(record_type, follow_up, result, denials)
40
+ denials = 0 if result.labeled.positive? || result.requests.positive?
41
+ wait = Labeling::Backfill.backoff(denials, result.retry_after)
42
+ self.class.set(wait: wait).perform_later(record_type, **follow_up, denials: denials + 1)
43
+ end
44
+
38
45
  def retry_later(record_type, follow_up, attempt)
39
46
  return if attempt >= MAX_ATTEMPTS
40
47
 
@@ -11,39 +11,84 @@ module Truffler
11
11
  # done, and records already current are skipped, so a rerun never asks
12
12
  # Jev about them again. A spend cap stops the run before a request would
13
13
  # exceed it; host-supplied labels cost nothing, so they are still written
14
- # once the cap is reached. A Jev error releases the claimed rows and ends
14
+ # 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
15
18
  # the run with `:client_error`, so the caller keeps the spend metered so far.
19
+ #
20
+ # A budget denial ends the run with `:budget_denied`, unless the run
21
+ # waits: then it backs off (see .backoff) and retries from the same
22
+ # cursor until it completes, reaches the spend cap, or runs out of
23
+ # `max_duration` seconds, which pauses it with the cursor to resume from.
16
24
  class Backfill
17
- Result = Data.define(:status, :labeled, :requests, :cost, :cursor)
25
+ Result = Data.define(:status, :labeled, :requests, :cost, :cursor, :retry_after)
26
+
27
+ INITIAL_BACKOFF = 1.0
28
+ MAX_BACKOFF = 30.0
29
+
30
+ class_attribute :sleeper, default: ->(seconds) { sleep(seconds) }
31
+ class_attribute :clock, default: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
18
32
 
19
33
  STATES = Records::RecordState.table_name
20
34
 
21
35
  class SpendCapReached < StandardError; end
22
36
 
23
37
  # Wraps the client to meter spend per request and refuse a request whose
24
- # estimated cost would push spend past the cap.
38
+ # estimated cost would push spend past the cap. With a ledger the
39
+ # estimate is reserved in SQL before the request and settled to the
40
+ # reported cost after it, so concurrent meters on one ledger never
41
+ # both take the last of the cap.
25
42
  class SpendMeter
26
- attr_reader :spent, :requests
43
+ attr_reader :requests, :cost
27
44
 
28
- def initialize(client, cap:, spent:, config: Truffler.config)
45
+ def initialize(client, cap:, spent:, ledger: nil, config: Truffler.config)
29
46
  @client = client
30
47
  @cap = cap
31
48
  @spent = spent.to_f
49
+ @ledger = ledger
32
50
  @requests = 0
51
+ @cost = 0.0
33
52
  @config = config
34
53
  end
35
54
 
36
- def ask(state:, questions:, **options)
37
- raise SpendCapReached if @cap && @spent + estimate(state, questions) > @cap
55
+ def spent
56
+ @ledger ? @ledger.total : @spent
57
+ end
38
58
 
39
- answers = @client.ask(state: state, questions: questions, **options)
40
- @requests += 1
41
- @spent += answers.usage&.cost.to_f
59
+ def ask(state:, questions:, **options)
60
+ estimate = estimate(state, questions)
61
+ reserve(estimate)
62
+ answers = nil
63
+ begin
64
+ answers = @client.ask(state: state, questions: questions, **options)
65
+ ensure
66
+ @ledger&.settle(-estimate, requests: 0) unless answers
67
+ end
68
+ record(answers.usage&.cost.to_f, estimate)
42
69
  answers
43
70
  end
44
71
 
45
72
  private
46
73
 
74
+ def record(cost, estimate)
75
+ @requests += 1
76
+ @cost += cost
77
+ if @ledger
78
+ @ledger.settle(cost - estimate)
79
+ else
80
+ @spent += cost
81
+ end
82
+ end
83
+
84
+ def reserve(estimate)
85
+ if @ledger
86
+ raise SpendCapReached unless @ledger.reserve(estimate, @cap)
87
+ elsif @cap && @spent + estimate > @cap
88
+ raise SpendCapReached
89
+ end
90
+ end
91
+
47
92
  def estimate(state, questions)
48
93
  @config.cost_for(Tokens.estimate({ state: state, questions: questions }))
49
94
  end
@@ -53,6 +98,34 @@ module Truffler
53
98
  new(model).status
54
99
  end
55
100
 
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)
104
+ return unless Records::BackfillSpend.available?
105
+
106
+ Records::BackfillSpend.for_model(model).find_by(vocabulary_version: ledger_version(model))
107
+ end
108
+
109
+ # Zeroes the current vocabulary version's ledger in place, so a chain
110
+ # still running keeps its row and continues against the fresh total.
111
+ def self.reset_spend!(model)
112
+ return unless Records::BackfillSpend.available?
113
+
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)
116
+ end
117
+
118
+ def self.ledger_version(model)
119
+ model.truffler_definition.vocabulary.version(all_users: true)
120
+ end
121
+
122
+ # Seconds to wait after `denials` consecutive budget denials with no
123
+ # work in between: 1, 2, 4, ... capped at MAX_BACKOFF, or the budget's
124
+ # retry hint when that is longer.
125
+ def self.backoff(denials, retry_after = nil)
126
+ [ [ INITIAL_BACKOFF * (2**denials), MAX_BACKOFF ].min, retry_after.to_f ].max
127
+ end
128
+
56
129
  attr_reader :model, :batch_size, :page_size
57
130
 
58
131
  def initialize(model, spend_cap: Truffler.config.backfill_spend_cap, batch_size: Truffler.config.batch_size,
@@ -62,32 +135,35 @@ module Truffler
62
135
  @batch_size = batch_size
63
136
  @page_size = page_size || batch_size * 5
64
137
  @cursor = cursor
65
- @meter = SpendMeter.new(client, cap: spend_cap, spent: spent)
138
+ @spend_cap = spend_cap
139
+ @spent = spent
140
+ @client = client
66
141
  @budget = budget
67
142
  @versions = {}
68
143
  end
69
144
 
70
- def run(max_pages: nil)
145
+ # `progress` is called with the result so far and the delay before each
146
+ # wait; it carries counts, cost, and the cursor, never record text.
147
+ def run(max_pages: nil, wait: false, max_duration: nil, sleeper: self.class.sleeper, clock: self.class.clock,
148
+ progress: nil)
71
149
  @labeled = 0
72
- @started_spent = @meter.spent
73
- cursor = @cursor
74
- pages = 0
150
+ @started_cost = meter.cost
151
+ @pages = 0
152
+ deadline = max_duration && clock.call + max_duration
153
+ denials = 0
75
154
 
76
155
  loop do
77
- scanned, rows = page(cursor)
78
- return result(:complete, nil) if scanned.empty?
79
- return result(:paused, cursor) if max_pages && pages >= max_pages
156
+ before = [ @labeled, meter.requests ]
157
+ status = sweep(max_pages, deadline, clock)
158
+ return result(status) unless wait && status == :budget_denied
80
159
 
81
- rows.group_by(&:last).each do |tenant_key, tenant_rows|
82
- tenant_rows.map(&:first).each_slice(batch_size) do |ids|
83
- stop = label(ids, tenant_key)
84
- return result(stop, cursor) if stop
85
- end
86
- end
160
+ denials = 0 unless before == [ @labeled, meter.requests ]
161
+ delay = self.class.backoff(denials, @retry_after)
162
+ return result(:paused) if deadline && clock.call + delay > deadline
87
163
 
88
- cursor = scanned.last
89
- pages += 1
90
- return result(:complete, nil) if scanned.size < page_size
164
+ progress&.call(result(status), delay)
165
+ sleeper.call(delay)
166
+ denials += 1
91
167
  end
92
168
  end
93
169
 
@@ -118,13 +194,47 @@ module Truffler
118
194
  @queue ||= Queue.new(model)
119
195
  end
120
196
 
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?
201
+ SpendMeter.new(@client, cap: @spend_cap, spent: ledger ? 0.0 : @spent, ledger: ledger)
202
+ end
203
+ end
204
+
121
205
  def version_for(tenant_key)
122
206
  @versions[tenant_key] ||= definition.vocabulary.version(tenant_key: tenant_key, all_users: true)
123
207
  end
124
208
 
125
- def result(status, cursor)
126
- Result.new(status: status, labeled: @labeled, requests: @meter.requests, cost: @meter.spent - @started_spent,
127
- cursor: cursor)
209
+ def result(status)
210
+ Result.new(status: status, labeled: @labeled, requests: meter.requests, cost: meter.cost - @started_cost,
211
+ cursor: @cursor, retry_after: (@retry_after if status == :budget_denied))
212
+ end
213
+
214
+ # Walks pages below @cursor until done or stopped, returning the status.
215
+ def sweep(max_pages, deadline, clock)
216
+ @retry_after = nil
217
+ loop do
218
+ scanned, rows = page(@cursor)
219
+ return complete if scanned.empty?
220
+ return :paused if (max_pages && @pages >= max_pages) || (deadline && clock.call >= deadline)
221
+
222
+ rows.group_by(&:last).each do |tenant_key, tenant_rows|
223
+ tenant_rows.map(&:first).each_slice(batch_size) do |ids|
224
+ stop = label(ids, tenant_key)
225
+ return stop if stop
226
+ end
227
+ end
228
+
229
+ @cursor = scanned.last
230
+ @pages += 1
231
+ return complete if scanned.size < page_size
232
+ end
233
+ end
234
+
235
+ def complete
236
+ @cursor = nil
237
+ :complete
128
238
  end
129
239
 
130
240
  # Returns the scanned ids (for the cursor) and the [id, tenant_key] rows
@@ -173,10 +283,14 @@ module Truffler
173
283
  return if claimed.empty?
174
284
 
175
285
  begin
176
- Labeler.new(model, client: @meter, budget: @budget).label(claimed, priority: :backfill)
177
- rescue BudgetExhausted, SpendCapReached => error
286
+ Labeler.new(model, client: meter, budget: @budget).label(claimed, priority: :backfill)
287
+ rescue SpendCapReached
288
+ queue.demote(claimed)
289
+ return :spend_cap_reached
290
+ rescue BudgetExhausted => error
178
291
  queue.demote(claimed)
179
- return error.is_a?(SpendCapReached) ? :spend_cap_reached : :budget_denied
292
+ @retry_after = error.retry_after
293
+ return :budget_denied
180
294
  rescue ClientError, IncompleteAnswers => error
181
295
  queue.release(claimed, error)
182
296
  return :client_error
@@ -50,7 +50,7 @@ module Truffler
50
50
  retry_failed_supplied(supplier.failed_ids, states_by_id)
51
51
  return Result.new(labeled: current.size, requests: index, cost: cost, demoted: true)
52
52
  end
53
- raise BudgetExhausted, "no Jev budget for #{priority} labeling" if decision.denied?
53
+ raise BudgetExhausted.new("no Jev budget for #{priority} labeling", retry_after: decision.retry_after) if decision.denied?
54
54
 
55
55
  answers = client.ask(state: request.state, questions: request.questions, priority: decision.priority)
56
56
  cost += answers.usage&.cost.to_f
@@ -8,12 +8,14 @@ module Truffler
8
8
  # never asked (R18). Query text travels only in `state` ("query" and
9
9
  # "tokens"); a token question names its word by position, `tokens[n]`,
10
10
  # so searcher text never lands in an instruction (R8). The label
11
- # vocabulary rides along in `state["labels"]` so a token question can
12
- # tell a word that names a label from text to match.
11
+ # vocabulary, with choice option display names, rides along in
12
+ # `state["labels"]` so a token question can tell a word that names a
13
+ # label from text to match.
13
14
  #
14
15
  # Jev's word roles are then reconciled locally: a keyword that names a
15
- # label the query applies (its key, a word of its key, or the chosen
16
- # option, ignoring case and plurals, or sharing its first three letters) becomes a label term, and a common
16
+ # label the query applies (its key, a word of its key, the chosen option,
17
+ # or a word of that option's display name, ignoring case and plurals, or
18
+ # sharing its first three letters) becomes a label term, and a common
17
19
  # stopword becomes filler.
18
20
  #
19
21
  # Answers become a `Search::Encoding` with the KTD20 intent vector: boost
@@ -131,7 +133,7 @@ module Truffler
131
133
  boosts[key] = intent[key] = label.boost || DEFAULT_BOOST
132
134
  else next
133
135
  end
134
- terms.concat(label_terms(key))
136
+ terms.concat(label_terms(key, option_name(label, key, tenant_key)))
135
137
  end
136
138
 
137
139
  query = Search::Query.new(request.state["query"])
@@ -189,7 +191,12 @@ module Truffler
189
191
  def vocabulary_state(labels, tenant_key)
190
192
  labels.transform_values do |label|
191
193
  entry = { "description" => label.description }
192
- entry["options"] = label.options(tenant_key).keys if label.type == :choice
194
+ if label.type == :choice
195
+ options = label.options(tenant_key)
196
+ entry["options"] = options.keys
197
+ names = options.compact
198
+ entry["option_names"] = names if names.any?
199
+ end
193
200
  entry
194
201
  end
195
202
  end
@@ -212,13 +219,22 @@ module Truffler
212
219
  end
213
220
 
214
221
  # "category:billing" names "category" and "billing"; "needs_action"
215
- # names "needs_action", "needs", and "action".
222
+ # names "needs_action", "needs", and "action". An option's display
223
+ # name adds its words minus stopwords: "p_17" shown as "Spiral writing
224
+ # tool" also names "spiral", "writing", and "tool".
216
225
  STEM = 3
217
226
 
218
- def label_terms(storage_key)
227
+ def label_terms(storage_key, option_name = nil)
219
228
  label, option = Search::Encoding.split_key(storage_key)
220
- [ label.split(":").last, option ].compact.flat_map { |name| [ name.downcase, *name.downcase.split(/[^\p{Alnum}]+/) ] }
221
- .reject(&:empty?).map(&:singularize)
229
+ keys = [ label.split(":").last, option ].compact.flat_map { |name| [ name.downcase, *name.downcase.split(/[^\p{Alnum}]+/) ] }
230
+ words = option_name.to_s.downcase.split(/[^\p{Alnum}]+/).reject { |word| STOPWORDS.include?(word) }
231
+ (keys + words).reject(&:empty?).map(&:singularize)
232
+ end
233
+
234
+ def option_name(label, storage_key, tenant_key)
235
+ return unless label.type == :choice
236
+
237
+ label.options(tenant_key)[Search::Encoding.split_key(storage_key).last]
222
238
  end
223
239
 
224
240
  # "angry" names "anger": the same word ignoring plurals, or two words of
@@ -0,0 +1,51 @@
1
+ module Truffler
2
+ module Records
3
+ # The backfill spend ledger: one row per model and app-wide vocabulary
4
+ # version, so a spend cap holds across runs, reruns, and overlapping
5
+ # BackfillJob chains. Spend is reserved and settled in SQL, never read,
6
+ # added to, and written back.
7
+ class BackfillSpend < ActiveRecord::Base
8
+ self.table_name = "truffler_backfill_spends"
9
+
10
+ scope :for_model, ->(model) { where(record_type: model.polymorphic_name) }
11
+
12
+ # False on apps that upgraded the gem without running
13
+ # `rails g truffler:upgrade`; warns once per process.
14
+ def self.available?
15
+ return true if connection.data_source_exists?(table_name)
16
+
17
+ warn_missing
18
+ false
19
+ end
20
+
21
+ def self.ledger(model, version)
22
+ create_or_find_by!(record_type: model.polymorphic_name, vocabulary_version: version)
23
+ end
24
+
25
+ def self.warn_missing
26
+ return if @missing_warned
27
+
28
+ @missing_warned = true
29
+ Truffler.config.logger.warn("[truffler] #{table_name} is missing, so backfill spend caps apply per run only. " \
30
+ "Run `bin/rails g truffler:upgrade && bin/rails db:migrate`.")
31
+ end
32
+ private_class_method :warn_missing
33
+
34
+ # Adds `amount` unless that would pass `cap`; true when reserved.
35
+ def reserve(amount, cap)
36
+ scope = self.class.where(id: id)
37
+ scope = scope.where("spent_usd + ? <= ?", amount, cap) if cap
38
+ scope.update_all([ "spent_usd = spent_usd + ?, updated_at = ?", amount, Time.current ]) == 1
39
+ end
40
+
41
+ def settle(amount, requests: 1)
42
+ self.class.where(id: id)
43
+ .update_all([ "spent_usd = spent_usd + ?, requests = requests + ?, updated_at = ?", amount, requests, Time.current ])
44
+ end
45
+
46
+ def total
47
+ self.class.where(id: id).pick(:spent_usd).to_f
48
+ end
49
+ end
50
+ end
51
+ end
@@ -6,8 +6,11 @@ module Truffler
6
6
  # to `[from, to]`, where `to` is nil for "until now".
7
7
  #
8
8
  # today, yesterday, this week, last week, this month, last month,
9
- # past|last N day(s)|week(s), since monday..sunday
9
+ # past|last N hour(s)|day(s)|week(s)|month(s), past hour|week|month,
10
+ # last hour, since monday..sunday
10
11
  #
12
+ # "past/last N units" and "past week" are rolling: they end now and
13
+ # start N units back. "this/last week/month" are calendar windows.
11
14
  # Weeks start on `Date.beginning_of_week` (Monday by default). Only the
12
15
  # first phrase in a query counts; a quoted phrase is exact text.
13
16
  TimePhrase = Data.define(:name, :positions, :resolver) do
@@ -36,17 +39,30 @@ module Truffler
36
39
  end
37
40
  end
38
41
 
42
+ UNITS = %i[hours days weeks months].to_h { |unit| [ unit.to_s, unit ] }
43
+ .merge(%i[hour day week month].to_h { |unit| [ unit.to_s, :"#{unit}s" ] }).freeze
44
+
39
45
  def self.rolling(tokens, start)
40
46
  return unless %w[past last].include?(tokens[start]) && tokens[start + 1].to_s.match?(/\A\d{1,3}\z/)
41
47
 
42
48
  count = Integer(tokens[start + 1], 10)
43
- unit = { "day" => :days, "days" => :days, "week" => :weeks, "weeks" => :weeks }[tokens[start + 2]]
49
+ unit = UNITS[tokens[start + 2]]
44
50
  return unless unit && count.positive?
45
51
 
46
52
  name = "Last #{count} #{count == 1 ? unit.to_s.singularize : unit}"
47
53
  new(name: name, positions: [ start, start + 1, start + 2 ], resolver: ->(now) { [ now - count.public_send(unit), nil ] })
48
54
  end
49
55
 
56
+ # "past hour", "past week", "past month", "last hour": one unit back
57
+ # from now. "last week" and "last month" stay calendar phrases.
58
+ def self.rolling_unit(tokens, start)
59
+ unit = tokens[start + 1]
60
+ return unless (tokens[start] == "past" && %w[hour week month].include?(unit)) || tokens[start, 2] == %w[last hour]
61
+
62
+ new(name: "#{tokens[start].capitalize} #{unit}", positions: [ start, start + 1 ],
63
+ resolver: ->(now) { [ now - 1.public_send(unit), nil ] })
64
+ end
65
+
50
66
  def self.since(tokens, start)
51
67
  wday = Date::DAYNAMES.map(&:downcase).index(tokens[start + 1]) if tokens[start] == "since"
52
68
  return unless wday
@@ -57,6 +73,7 @@ module Truffler
57
73
 
58
74
  PATTERNS = [
59
75
  method(:rolling),
76
+ method(:rolling_unit),
60
77
  method(:since),
61
78
  fixed(%w[today], "Today") { |now| [ now.beginning_of_day, nil ] },
62
79
  fixed(%w[yesterday], "Yesterday") { |now| [ now.yesterday.beginning_of_day, now.beginning_of_day ] },
@@ -1,3 +1,3 @@
1
1
  module Truffler
2
- VERSION = "0.1.1"
2
+ VERSION = "0.1.2"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: truffler
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kieran Klaassen
@@ -198,6 +198,8 @@ files:
198
198
  - lib/generators/truffler/install/templates/channel.rb.tt
199
199
  - lib/generators/truffler/install/templates/initializer.rb.tt
200
200
  - lib/generators/truffler/install/templates/migration.rb.tt
201
+ - lib/generators/truffler/upgrade/templates/backfill_spends_migration.rb.tt
202
+ - lib/generators/truffler/upgrade/upgrade_generator.rb
201
203
  - lib/tasks/truffler.rake
202
204
  - lib/tasks/truffler/bench.rake
203
205
  - lib/tasks/truffler/suggestions.rake
@@ -283,6 +285,7 @@ files:
283
285
  - lib/truffler/query_encoding/prefetch.rb
284
286
  - lib/truffler/questions.rb
285
287
  - lib/truffler/railtie.rb
288
+ - lib/truffler/records/backfill_spend.rb
286
289
  - lib/truffler/records/embedding.rb
287
290
  - lib/truffler/records/label.rb
288
291
  - lib/truffler/records/query_miss.rb