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 +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +29 -6
- data/lib/generators/truffler/install/templates/migration.rb.tt +10 -0
- data/lib/generators/truffler/upgrade/templates/backfill_spends_migration.rb.tt +19 -0
- data/lib/generators/truffler/upgrade/upgrade_generator.rb +24 -0
- data/lib/tasks/truffler.rake +33 -3
- data/lib/truffler/budget.rb +23 -8
- data/lib/truffler/errors.rb +11 -1
- data/lib/truffler/jobs/backfill_job.rb +13 -6
- data/lib/truffler/labeling/backfill.rb +147 -33
- data/lib/truffler/labeling/labeler.rb +1 -1
- data/lib/truffler/query_encoding/encoder.rb +26 -10
- data/lib/truffler/records/backfill_spend.rb +51 -0
- data/lib/truffler/search/time_phrase.rb +19 -2
- data/lib/truffler/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 99d7f61714bf653e012b5cea1f9d57cfbde9d390aae048ba0d52387292a37159
|
|
4
|
+
data.tar.gz: b9826342d2552dae2b1cd9d671ac4d4d448f5fcbe3822b21e5788b7d272365b0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 `
|
|
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,
|
|
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
|
|
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]"` (
|
|
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
|
data/lib/tasks/truffler.rake
CHANGED
|
@@ -17,12 +17,34 @@ namespace :truffler do
|
|
|
17
17
|
end
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
data/lib/truffler/budget.rb
CHANGED
|
@@ -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
|
|
12
|
-
#
|
|
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)
|
data/lib/truffler/errors.rb
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
|
|
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.
|
|
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 :
|
|
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
|
|
37
|
-
|
|
55
|
+
def spent
|
|
56
|
+
@ledger ? @ledger.total : @spent
|
|
57
|
+
end
|
|
38
58
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
@
|
|
138
|
+
@spend_cap = spend_cap
|
|
139
|
+
@spent = spent
|
|
140
|
+
@client = client
|
|
66
141
|
@budget = budget
|
|
67
142
|
@versions = {}
|
|
68
143
|
end
|
|
69
144
|
|
|
70
|
-
|
|
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
|
-
@
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
return result(
|
|
156
|
+
before = [ @labeled, meter.requests ]
|
|
157
|
+
status = sweep(max_pages, deadline, clock)
|
|
158
|
+
return result(status) unless wait && status == :budget_denied
|
|
80
159
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
126
|
-
Result.new(status: status, labeled: @labeled, requests:
|
|
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:
|
|
177
|
-
rescue
|
|
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
|
-
|
|
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
|
|
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
|
|
12
|
-
# tell a word that names a
|
|
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,
|
|
16
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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),
|
|
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 =
|
|
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 ] },
|
data/lib/truffler/version.rb
CHANGED
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.
|
|
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
|