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 +4 -4
- data/CHANGELOG.md +40 -0
- data/lib/textus/application/refresh/worker.rb +9 -3
- data/lib/textus/cli/verb/build.rb +8 -6
- data/lib/textus/cli/verb/rule_list.rb +1 -0
- data/lib/textus/domain/policy/refresh.rb +6 -5
- data/lib/textus/errors.rb +12 -0
- data/lib/textus/infra/build_lock.rb +59 -0
- data/lib/textus/manifest/rules.rb +1 -0
- data/lib/textus/manifest/schema.rb +15 -2
- data/lib/textus/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 65cfe5f73a64dda44280d1dd58e418aecdc94b79e1abb68340e973d364da3465
|
|
4
|
+
data.tar.gz: c35f1428a0965c11c946395a8fb7dfeb60202e2fac2514b1bb04dfae1e426ed2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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 #{
|
|
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 #{
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
16
|
-
@on_stale
|
|
17
|
-
@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
|
|
@@ -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
|
-
|
|
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
|
|
data/lib/textus/version.rb
CHANGED
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.
|
|
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
|