textus 0.14.1 → 0.14.3

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: 65cfe5f73a64dda44280d1dd58e418aecdc94b79e1abb68340e973d364da3465
4
+ data.tar.gz: c35f1428a0965c11c946395a8fb7dfeb60202e2fac2514b1bb04dfae1e426ed2
5
5
  SHA512:
6
- metadata.gz: e21ff9e7d9a9cfe93ab3865646b53bbea4647f0c7ace9d329e7ccec13a2ce7343568df836bd25fccfa19c39d4384a2d522d827e5d01b7ebbdd0b2ab3098978be
7
- data.tar.gz: 700e831af5ba5c13a9e66984e59ee4774def27e1c098b7dfd2d6e942b6cd064b7e3e3d1480faa110417ede67aa229d0b7bfea063f8f5f59e91bcaa0dce282355
6
+ metadata.gz: edeefd203bb0f4af807457cb96353affd3a21de846331481bb3d6e69e344fc1ea8054f8066ed278e7a7ced366704a65df00269d38b58a5b729d408477bc4f5a5
7
+ data.tar.gz: 4c30fa46409c381bfa8ef381c38314ce988786b9f2cf81c8b1c76f7508301587b9e5562ce1ced415f85484fe794fe3aeff5186203f733cedea34443d43a4eb57
data/CHANGELOG.md CHANGED
@@ -9,6 +9,46 @@ 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.3 — 2026-05-26
13
+
14
+ ### Added
15
+
16
+ - Top-level `flock(2)` mutex at `<root>/.build.lock` prevents concurrent
17
+ `textus build` invocations against the same store. A second build
18
+ while one is already running exits with code `75` (`EX_TEMPFAIL`) and
19
+ emits a `build_in_progress` error envelope, so wrappers like `rake
20
+ update` and CI can distinguish "another build is busy" from "this
21
+ build is broken". The lock is FD-bound and released by the kernel on
22
+ process death (including SIGKILL/OOM), so no stale-lock takeover
23
+ logic is needed. `close_on_exec` prevents the lock from leaking into
24
+ `bundle exec` and lefthook child processes. Per-key locks under
25
+ `.locks/` are unchanged. (#56)
26
+
27
+ ## 0.14.2 — 2026-05-26
28
+
29
+ ### Added
30
+
31
+ - Per-rule `fetch_timeout_seconds:` under `rules[].refresh` overrides the
32
+ hardcoded 30s worker timeout that applies to intake handlers invoked
33
+ via the refresh pipeline (`textus refresh <key>`, `textus refresh
34
+ stale`, and read-path `on_stale: sync` / `timed_sync`). Default stays
35
+ at 30s — opt-in only. Schema validates a positive integer ≤ 3600.
36
+ Mirrors `sync_budget_ms` plumbing: schema → rules → policy → worker
37
+ (#54).
38
+
39
+ ### Notes
40
+
41
+ - `fetch_timeout_seconds` is the worker-side hard cap on the intake
42
+ call; `sync_budget_ms` is the caller-side wait budget for `timed_sync`
43
+ on the read path. Two separate concerns, two separate keys.
44
+ - The CLI verbs `textus put --fetch` and `textus hook run` still use the
45
+ 30s constant — only the refresh worker honors the per-rule override in
46
+ this release.
47
+ - Long timeouts pair with `Timeout.timeout`, which raises in the Ruby
48
+ thread but does not kill spawned subprocesses. Intake handlers that
49
+ shell out (e.g. `git clone`) should write to a temp dir and atomically
50
+ rename so mid-flight aborts leave no observable partial state.
51
+
12
52
  ## 0.14.1 — 2026-05-26
13
53
 
14
54
  ### 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)
@@ -5,12 +5,14 @@ module Textus
5
5
  option :prefix, "--prefix=K"
6
6
 
7
7
  def call(store)
8
- ops = Textus::Operations.for(store, role: "builder")
9
- build_res = ops.writes.build.call(prefix: prefix)
10
- publish_res = ops.writes.publish.call(prefix: prefix)
11
- emit({ "protocol" => Textus::PROTOCOL,
12
- "built" => build_res["built"],
13
- "published_leaves" => publish_res["published_leaves"] })
8
+ Textus::Infra::BuildLock.with(root: store.root) do
9
+ ops = Textus::Operations.for(store, role: "builder")
10
+ build_res = ops.writes.build.call(prefix: prefix)
11
+ publish_res = ops.writes.publish.call(prefix: prefix)
12
+ emit({ "protocol" => Textus::PROTOCOL,
13
+ "built" => build_res["built"],
14
+ "published_leaves" => publish_res["published_leaves"] })
15
+ end
14
16
  end
15
17
  end
16
18
  end
@@ -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
data/lib/textus/errors.rb CHANGED
@@ -122,6 +122,18 @@ module Textus
122
122
  def initialize(m) = super("io_error", m, exit_code: 64)
123
123
  end
124
124
 
125
+ class BuildInProgress < Error
126
+ def initialize(holder)
127
+ super(
128
+ "build_in_progress",
129
+ "textus build already running (#{holder})",
130
+ details: { "holder" => holder },
131
+ exit_code: 75,
132
+ hint: "wait for the running build to finish, or check for a recursive hook trigger"
133
+ )
134
+ end
135
+ end
136
+
125
137
  class UsageError < Error
126
138
  def initialize(m, hint: nil) = super("usage", m, exit_code: 2, hint: hint)
127
139
  end
@@ -0,0 +1,59 @@
1
+ require "fileutils"
2
+ require "socket"
3
+ require "time"
4
+
5
+ module Textus
6
+ module Infra
7
+ class BuildLock
8
+ LOCK_FILENAME = ".build.lock"
9
+ MAX_HOLDER_BYTES = 512
10
+
11
+ def self.with(root:, &)
12
+ new(root: root).acquire_or_raise(&)
13
+ end
14
+
15
+ def initialize(root:)
16
+ @path = File.join(root, LOCK_FILENAME)
17
+ @file = nil
18
+ end
19
+
20
+ def acquire_or_raise
21
+ FileUtils.mkdir_p(File.dirname(@path))
22
+ @file = File.open(@path, File::RDWR | File::CREAT, 0o644)
23
+ @file.close_on_exec = true
24
+
25
+ unless @file.flock(File::LOCK_EX | File::LOCK_NB)
26
+ holder = read_holder_safely
27
+ @file.close
28
+ @file = nil
29
+ raise Textus::BuildInProgress.new(holder)
30
+ end
31
+
32
+ @file.truncate(0)
33
+ @file.write("pid=#{Process.pid} started=#{Time.now.utc.iso8601} host=#{Socket.gethostname}\n")
34
+ @file.flush
35
+
36
+ yield
37
+ ensure
38
+ release
39
+ end
40
+
41
+ private
42
+
43
+ def release
44
+ return unless @file
45
+
46
+ @file.flock(File::LOCK_UN)
47
+ @file.close
48
+ @file = nil
49
+ end
50
+
51
+ def read_holder_safely
52
+ content = File.read(@path, MAX_HOLDER_BYTES)
53
+ content.gsub(/[^[:print:]\t ]/, "").strip
54
+ rescue StandardError
55
+ "unknown"
56
+ end
57
+ end
58
+ end
59
+ end
@@ -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.3"
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.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
@@ -223,6 +223,7 @@ files:
223
223
  - lib/textus/hooks/dsl.rb
224
224
  - lib/textus/hooks/loader.rb
225
225
  - lib/textus/hooks/registry.rb
226
+ - lib/textus/infra/build_lock.rb
226
227
  - lib/textus/infra/clock.rb
227
228
  - lib/textus/infra/event_bus.rb
228
229
  - lib/textus/infra/publisher.rb