textus 0.14.1 → 0.14.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: 7f46590895cb1b7dccf71efbb6cc7632ac1054e545323478e831875c7b7bf621
4
- data.tar.gz: e84857018c0bf4e61e6e60a476191e04dd2fe470f476b27860deaf59611993ab
3
+ metadata.gz: 4a732844e2441328f7519ffd93571c3ff611fd0514c2b1f927b5c4ada499db16
4
+ data.tar.gz: 63a3e530439c37fbbff8c8b3864b1489cd34c88c88bd1c10f03ae980f4a66041
5
5
  SHA512:
6
- metadata.gz: e21ff9e7d9a9cfe93ab3865646b53bbea4647f0c7ace9d329e7ccec13a2ce7343568df836bd25fccfa19c39d4384a2d522d827e5d01b7ebbdd0b2ab3098978be
7
- data.tar.gz: 700e831af5ba5c13a9e66984e59ee4774def27e1c098b7dfd2d6e942b6cd064b7e3e3d1480faa110417ede67aa229d0b7bfea063f8f5f59e91bcaa0dce282355
6
+ metadata.gz: 5470a6aaf29f1b081144b2eca3b1e87e5b1d465977422c967366196d259041cdbb8e0993ce293d87d7903f9446649f80ea99e1e6367b9731f7b839bf18f9d67f
7
+ data.tar.gz: dcd3f57064d2f6753f049daf1315a937566e3b77a9ec130455a54410292f73ec55104bafe1e1b20431dc186a1c13735a7e6c6018ef8f54b77122f0eb2e267e8c
data/CHANGELOG.md CHANGED
@@ -9,6 +9,31 @@ The **gem version** (`0.x.y`) is distinct from the **protocol version**
9
9
  bump is a breaking change that requires a store migration; the gem version
10
10
  tracks both additive improvements and breaking protocol bumps independently.
11
11
 
12
+ ## 0.14.2 — 2026-05-26
13
+
14
+ ### Added
15
+
16
+ - Per-rule `fetch_timeout_seconds:` under `rules[].refresh` overrides the
17
+ hardcoded 30s worker timeout that applies to intake handlers invoked
18
+ via the refresh pipeline (`textus refresh <key>`, `textus refresh
19
+ stale`, and read-path `on_stale: sync` / `timed_sync`). Default stays
20
+ at 30s — opt-in only. Schema validates a positive integer ≤ 3600.
21
+ Mirrors `sync_budget_ms` plumbing: schema → rules → policy → worker
22
+ (#54).
23
+
24
+ ### Notes
25
+
26
+ - `fetch_timeout_seconds` is the worker-side hard cap on the intake
27
+ call; `sync_budget_ms` is the caller-side wait budget for `timed_sync`
28
+ on the read path. Two separate concerns, two separate keys.
29
+ - The CLI verbs `textus put --fetch` and `textus hook run` still use the
30
+ 30s constant — only the refresh worker honors the per-rule override in
31
+ this release.
32
+ - Long timeouts pair with `Timeout.timeout`, which raises in the Ruby
33
+ thread but does not kill spawned subprocesses. Intake handlers that
34
+ shell out (e.g. `git clone`) should write to a temp dir and atomically
35
+ rename so mid-flight aborts leave no observable partial state.
36
+
12
37
  ## 0.14.1 — 2026-05-26
13
38
 
14
39
  ### Changed
@@ -26,6 +26,11 @@ module Textus
26
26
  Application::Context.new(store: @ctx.store, role: @ctx.role)
27
27
  end
28
28
 
29
+ def fetch_timeout_for(key)
30
+ rule = @ctx.store.manifest.rules_for(key)
31
+ rule&.refresh&.fetch_timeout_seconds || FETCH_TIMEOUT_SECONDS
32
+ end
33
+
29
34
  def fetch_with_bus(key, mentry)
30
35
  callable = @ctx.store.registry.rpc_callable(:resolve_intake, mentry.intake_handler)
31
36
  @bus.publish(:refresh_started, store: read_view, key: key, mode: :sync,
@@ -34,14 +39,15 @@ module Textus
34
39
  end
35
40
 
36
41
  def call_intake(key, mentry, callable)
37
- Timeout.timeout(FETCH_TIMEOUT_SECONDS) do
42
+ timeout = fetch_timeout_for(key)
43
+ Timeout.timeout(timeout) do
38
44
  callable.call(store: @ctx, config: mentry.intake_config, args: {})
39
45
  end
40
46
  rescue Timeout::Error
41
47
  @bus.publish(:refresh_failed, store: read_view, key: key, error_class: "Timeout::Error",
42
- error_message: "intake '#{mentry.intake_handler}' exceeded #{FETCH_TIMEOUT_SECONDS}s",
48
+ error_message: "intake '#{mentry.intake_handler}' exceeded #{timeout}s",
43
49
  correlation_id: @ctx.correlation_id)
44
- raise UsageError.new("intake '#{mentry.intake_handler}' exceeded #{FETCH_TIMEOUT_SECONDS}s timeout")
50
+ raise UsageError.new("intake '#{mentry.intake_handler}' exceeded #{timeout}s timeout")
45
51
  rescue Textus::Error => e
46
52
  @bus.publish(:refresh_failed, store: read_view, key: key, error_class: e.class.name,
47
53
  error_message: e.message, correlation_id: @ctx.correlation_id)
@@ -10,6 +10,7 @@ module Textus
10
10
  "ttl_seconds" => b.refresh.ttl_seconds,
11
11
  "on_stale" => b.refresh.on_stale,
12
12
  "sync_budget_ms" => b.refresh.sync_budget_ms,
13
+ "fetch_timeout_seconds" => b.refresh.fetch_timeout_seconds,
13
14
  }
14
15
  end
15
16
  row["handler_allowlist"] = b.handler_allowlist.handlers if b.handler_allowlist
@@ -2,9 +2,9 @@ module Textus
2
2
  module Domain
3
3
  module Policy
4
4
  class Refresh
5
- attr_reader :ttl, :on_stale, :sync_budget_ms
5
+ attr_reader :ttl, :on_stale, :sync_budget_ms, :fetch_timeout_seconds
6
6
 
7
- def initialize(ttl:, on_stale:, sync_budget_ms:)
7
+ def initialize(ttl:, on_stale:, sync_budget_ms:, fetch_timeout_seconds: nil)
8
8
  on_stale_sym = on_stale.is_a?(Symbol) ? on_stale : on_stale.to_s.to_sym
9
9
  unless ALLOWED_ON_STALE.include?(on_stale_sym)
10
10
  raise Textus::UsageError.new(
@@ -12,9 +12,10 @@ module Textus
12
12
  )
13
13
  end
14
14
 
15
- @ttl = ttl
16
- @on_stale = on_stale_sym
17
- @sync_budget_ms = sync_budget_ms
15
+ @ttl = ttl
16
+ @on_stale = on_stale_sym
17
+ @sync_budget_ms = sync_budget_ms
18
+ @fetch_timeout_seconds = fetch_timeout_seconds
18
19
  end
19
20
 
20
21
  def ttl_seconds
@@ -63,6 +63,7 @@ module Textus
63
63
  ttl: h["ttl"],
64
64
  on_stale: h["on_stale"] || "warn",
65
65
  sync_budget_ms: h["sync_budget_ms"],
66
+ fetch_timeout_seconds: h["fetch_timeout_seconds"],
66
67
  )
67
68
  end
68
69
 
@@ -11,7 +11,8 @@ module Textus
11
11
  COMPUTE_KEYS = %w[kind select pluck sort_by limit transform command sources].freeze
12
12
  INTAKE_KEYS = %w[handler config].freeze
13
13
  RULE_KEYS = %w[match refresh intake_handler_allowlist promotion retention].freeze
14
- REFRESH_KEYS = %w[ttl on_stale sync_budget_ms].freeze
14
+ REFRESH_KEYS = %w[ttl on_stale sync_budget_ms fetch_timeout_seconds].freeze
15
+ FETCH_TIMEOUT_SECONDS_CEILING = 3600
15
16
  PROMOTION_KEYS = %w[requires].freeze
16
17
 
17
18
  def self.validate!(raw)
@@ -30,11 +31,23 @@ module Textus
30
31
  Array(raw["rules"]).each_with_index do |r, i|
31
32
  path = "$.rules[#{i}]"
32
33
  walk(r, RULE_KEYS, path)
33
- walk(r["refresh"], REFRESH_KEYS, "#{path}.refresh") if r["refresh"].is_a?(Hash)
34
+ if r["refresh"].is_a?(Hash)
35
+ walk(r["refresh"], REFRESH_KEYS, "#{path}.refresh")
36
+ validate_fetch_timeout!(r["refresh"]["fetch_timeout_seconds"], "#{path}.refresh.fetch_timeout_seconds")
37
+ end
34
38
  walk(r["promotion"], PROMOTION_KEYS, "#{path}.promotion") if r["promotion"].is_a?(Hash)
35
39
  end
36
40
  end
37
41
 
42
+ def self.validate_fetch_timeout!(value, path)
43
+ return if value.nil?
44
+ return if value.is_a?(Integer) && value.positive? && value <= FETCH_TIMEOUT_SECONDS_CEILING
45
+
46
+ raise BadManifest.new(
47
+ "fetch_timeout_seconds at '#{path}' must be a positive integer ≤ #{FETCH_TIMEOUT_SECONDS_CEILING} (got #{value.inspect})",
48
+ )
49
+ end
50
+
38
51
  def self.walk(hash, allowed, path)
39
52
  return unless hash.is_a?(Hash)
40
53
 
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.14.1"
2
+ VERSION = "0.14.2"
3
3
  PROTOCOL = "textus/3"
4
4
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: textus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.14.1
4
+ version: 0.14.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick