textus 0.14.2 → 0.14.4
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 +57 -0
- data/lib/textus/application/context.rb +13 -7
- data/lib/textus/application/reads/get.rb +1 -0
- data/lib/textus/builder/pipeline.rb +1 -1
- data/lib/textus/cli/verb/build.rb +8 -6
- data/lib/textus/doctor/check/refresh_locks.rb +49 -0
- data/lib/textus/doctor.rb +1 -0
- data/lib/textus/errors.rb +12 -0
- data/lib/textus/infra/build_lock.rb +59 -0
- data/lib/textus/operations.rb +2 -1
- data/lib/textus/projection.rb +5 -3
- data/lib/textus/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d1e2a12b234bfc677ce44ec7b49b3b04b4ac69abbc15f13496911c82d89549c8
|
|
4
|
+
data.tar.gz: 6b83336a05cb3cee509d3e0b90ce2048ed57fdeb5fbc603f22303e10c2d5c27c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f126d898385e0d0236a1818a741923f8ee9f88f1f13cab8ef37bbb810d14145904186084f72d0fbc5a4d6bfdf53b5e7e68699f56db4f9aa02d22772019a11316
|
|
7
|
+
data.tar.gz: 054ea4fea78cf26d570319d49f2a0ffbd4023e6355b56b8830650e7d59b1b57ae2de7b158ef0611b465369136e11b292ba9f968113cd425ecd1cfed1bf96633d
|
data/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,63 @@ 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.4 — 2026-05-26
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- `textus build` no longer triggers per-leaf refresh fan-out when
|
|
17
|
+
projection reads encounter stale entries under `on_stale: timed_sync`
|
|
18
|
+
/ `on_stale: sync`. Build is a downstream materialization step over
|
|
19
|
+
current store state; freshness is an inflow concern, and they
|
|
20
|
+
shouldn't compose. Previously, building a marketplace-style output
|
|
21
|
+
whose projection selected `intake.vendor.**` against ~400 stale
|
|
22
|
+
leaves spawned ~400 concurrent detached refresh workers, each
|
|
23
|
+
re-running the full intake handler, exhausting the system. The
|
|
24
|
+
build pipeline now reads through an `Application::Context` with
|
|
25
|
+
`bypass_freshness: true`, so `Application::Reads::Get` returns the
|
|
26
|
+
on-disk envelope annotated as fresh without consulting the
|
|
27
|
+
orchestrator. Explicit freshness before build still works via
|
|
28
|
+
`textus refresh stale`. (#59)
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- `Application::Context#bypass_freshness?` — new flag for read paths
|
|
33
|
+
that must not initiate refresh. Threaded through `Operations.for`
|
|
34
|
+
and propagated across `Context#with_role`. Used by `Builder::Pipeline`
|
|
35
|
+
via a `bypass_freshness:` constructor kwarg on `Projection`.
|
|
36
|
+
- `textus doctor` now reports stale per-key refresh lock files under
|
|
37
|
+
`<root>/.locks/` whose recorded PID is no longer running, as an
|
|
38
|
+
`info`-level `refresh_lock.stale` issue. The check is purely
|
|
39
|
+
informational: `Refresh::Lock` uses `flock(2)`, which the kernel
|
|
40
|
+
releases on process death, so stale `.lock` files on disk do not
|
|
41
|
+
block subsequent refresh acquires. The check exists so users can
|
|
42
|
+
clean up forensic clutter and notice unexpected accumulation. No
|
|
43
|
+
read-path changes — adding a PID probe + unlink there would
|
|
44
|
+
reintroduce the TOCTOU and PID-reuse hazards explicitly rejected
|
|
45
|
+
in 0.14.3 / PR #57. (#58)
|
|
46
|
+
|
|
47
|
+
### Tested
|
|
48
|
+
|
|
49
|
+
- Added a regression spec that forks a child, takes a per-key
|
|
50
|
+
`Refresh::Lock`, SIGKILLs the child, and asserts a fresh acquire
|
|
51
|
+
on the same key succeeds without manual cleanup. Pins the
|
|
52
|
+
flock-survives-SIGKILL contract.
|
|
53
|
+
|
|
54
|
+
## 0.14.3 — 2026-05-26
|
|
55
|
+
|
|
56
|
+
### Added
|
|
57
|
+
|
|
58
|
+
- Top-level `flock(2)` mutex at `<root>/.build.lock` prevents concurrent
|
|
59
|
+
`textus build` invocations against the same store. A second build
|
|
60
|
+
while one is already running exits with code `75` (`EX_TEMPFAIL`) and
|
|
61
|
+
emits a `build_in_progress` error envelope, so wrappers like `rake
|
|
62
|
+
update` and CI can distinguish "another build is busy" from "this
|
|
63
|
+
build is broken". The lock is FD-bound and released by the kernel on
|
|
64
|
+
process death (including SIGKILL/OOM), so no stale-lock takeover
|
|
65
|
+
logic is needed. `close_on_exec` prevents the lock from leaking into
|
|
66
|
+
`bundle exec` and lefthook child processes. Per-key locks under
|
|
67
|
+
`.locks/` are unchanged. (#56)
|
|
68
|
+
|
|
12
69
|
## 0.14.2 — 2026-05-26
|
|
13
70
|
|
|
14
71
|
### Added
|
|
@@ -9,13 +9,14 @@ module Textus
|
|
|
9
9
|
new(store: store, role: "human")
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
def initialize(store:, role:, correlation_id: nil, clock: Time, dry_run: false)
|
|
13
|
-
@store
|
|
14
|
-
@role
|
|
15
|
-
@correlation_id
|
|
16
|
-
@clock
|
|
17
|
-
@dry_run
|
|
18
|
-
@
|
|
12
|
+
def initialize(store:, role:, correlation_id: nil, clock: Time, dry_run: false, bypass_freshness: false)
|
|
13
|
+
@store = store
|
|
14
|
+
@role = role.to_s
|
|
15
|
+
@correlation_id = correlation_id || SecureRandom.uuid
|
|
16
|
+
@clock = clock
|
|
17
|
+
@dry_run = dry_run
|
|
18
|
+
@bypass_freshness = bypass_freshness
|
|
19
|
+
@now = nil
|
|
19
20
|
end
|
|
20
21
|
|
|
21
22
|
def now
|
|
@@ -26,6 +27,10 @@ module Textus
|
|
|
26
27
|
@dry_run
|
|
27
28
|
end
|
|
28
29
|
|
|
30
|
+
def bypass_freshness?
|
|
31
|
+
@bypass_freshness
|
|
32
|
+
end
|
|
33
|
+
|
|
29
34
|
def can_write?(zone)
|
|
30
35
|
store.manifest.permission_for(zone.to_s).allows_write?(role)
|
|
31
36
|
end
|
|
@@ -41,6 +46,7 @@ module Textus
|
|
|
41
46
|
correlation_id: @correlation_id,
|
|
42
47
|
clock: @clock,
|
|
43
48
|
dry_run: @dry_run,
|
|
49
|
+
bypass_freshness: @bypass_freshness,
|
|
44
50
|
)
|
|
45
51
|
end
|
|
46
52
|
end
|
|
@@ -11,6 +11,7 @@ module Textus
|
|
|
11
11
|
def call(key)
|
|
12
12
|
envelope = @ctx.store.reader.read_raw_envelope(key)
|
|
13
13
|
return nil if envelope.nil?
|
|
14
|
+
return annotate_fresh(envelope) if @ctx.bypass_freshness?
|
|
14
15
|
|
|
15
16
|
policy_set = @ctx.store.manifest.rules_for(key)
|
|
16
17
|
refresh_policy = policy_set.refresh
|
|
@@ -62,7 +62,7 @@ module Textus
|
|
|
62
62
|
# 1. Load sources + project + reduce
|
|
63
63
|
data =
|
|
64
64
|
if mentry.projection
|
|
65
|
-
Projection.new(store, mentry.projection).run
|
|
65
|
+
Projection.new(store, mentry.projection, bypass_freshness: true).run
|
|
66
66
|
else
|
|
67
67
|
{ "entries" => [], "count" => 0, "generated_at" => Time.now.utc.iso8601 }
|
|
68
68
|
end
|
|
@@ -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
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Doctor
|
|
3
|
+
class Check
|
|
4
|
+
# Lists per-key refresh lock files under <store.root>/.locks/ whose
|
|
5
|
+
# recorded PID is no longer running. These are forensic artifacts only:
|
|
6
|
+
# Refresh::Lock uses flock(2), which the kernel releases on process
|
|
7
|
+
# death, so stale files do not block subsequent acquires. The check
|
|
8
|
+
# exists to let users clean up clutter and notice unexpected accumulation
|
|
9
|
+
# (e.g. a refresh path that crashes repeatedly).
|
|
10
|
+
class RefreshLocks < Check
|
|
11
|
+
def call
|
|
12
|
+
dir = File.join(store.root, ".locks")
|
|
13
|
+
return [] unless File.directory?(dir)
|
|
14
|
+
|
|
15
|
+
Dir.glob(File.join(dir, "*.lock")).filter_map { |path| inspect_lock(path) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def inspect_lock(path)
|
|
21
|
+
pid = File.read(path).strip.to_i
|
|
22
|
+
return nil if pid.zero?
|
|
23
|
+
return nil if pid_alive?(pid)
|
|
24
|
+
|
|
25
|
+
{
|
|
26
|
+
"code" => "refresh_lock.stale",
|
|
27
|
+
"level" => "info",
|
|
28
|
+
"subject" => path,
|
|
29
|
+
"message" => "refresh lock file at #{path} records dead PID #{pid} " \
|
|
30
|
+
"(does not block refresh; flock is kernel-released on exit)",
|
|
31
|
+
"fix" => "safe to delete: rm #{path}",
|
|
32
|
+
}
|
|
33
|
+
rescue Errno::ENOENT
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def pid_alive?(pid)
|
|
38
|
+
Process.kill(0, pid)
|
|
39
|
+
true
|
|
40
|
+
rescue Errno::ESRCH
|
|
41
|
+
false
|
|
42
|
+
rescue Errno::EPERM
|
|
43
|
+
# Process exists but owned by another user — treat as alive.
|
|
44
|
+
true
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
data/lib/textus/doctor.rb
CHANGED
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
|
data/lib/textus/operations.rb
CHANGED
|
@@ -9,12 +9,13 @@ module Textus
|
|
|
9
9
|
#
|
|
10
10
|
# Replaces the prior `Textus::Composition` module (deleted in v0.12.2).
|
|
11
11
|
class Operations
|
|
12
|
-
def self.for(store, role: Role::DEFAULT, correlation_id: nil, dry_run: false)
|
|
12
|
+
def self.for(store, role: Role::DEFAULT, correlation_id: nil, dry_run: false, bypass_freshness: false)
|
|
13
13
|
ctx = Application::Context.new(
|
|
14
14
|
store: store,
|
|
15
15
|
role: role,
|
|
16
16
|
correlation_id: correlation_id,
|
|
17
17
|
dry_run: dry_run,
|
|
18
|
+
bypass_freshness: bypass_freshness,
|
|
18
19
|
)
|
|
19
20
|
new(ctx)
|
|
20
21
|
end
|
data/lib/textus/projection.rb
CHANGED
|
@@ -6,9 +6,10 @@ module Textus
|
|
|
6
6
|
MAX_LIMIT = 1000
|
|
7
7
|
REDUCER_TIMEOUT_SECONDS = 2
|
|
8
8
|
|
|
9
|
-
def initialize(store, spec)
|
|
9
|
+
def initialize(store, spec, bypass_freshness: false)
|
|
10
10
|
@store = store
|
|
11
11
|
@spec = spec || {}
|
|
12
|
+
@bypass_freshness = bypass_freshness
|
|
12
13
|
@limit = (@spec["limit"] || MAX_LIMIT).to_i
|
|
13
14
|
raise InvalidProjection.new("limit #{@limit} exceeds max #{MAX_LIMIT}") if @limit > MAX_LIMIT
|
|
14
15
|
end
|
|
@@ -16,8 +17,9 @@ module Textus
|
|
|
16
17
|
def run
|
|
17
18
|
keys = collect_keys
|
|
18
19
|
explicit_pluck = !@spec["pluck"].nil? && @spec["pluck"] != "*"
|
|
20
|
+
ops = Operations.for(@store, bypass_freshness: @bypass_freshness)
|
|
19
21
|
rows = keys.map do |key|
|
|
20
|
-
env =
|
|
22
|
+
env = ops.reads.get.call(key)
|
|
21
23
|
row = pluck(env.meta, env.body)
|
|
22
24
|
explicit_pluck ? row : row.merge("_key" => key)
|
|
23
25
|
end
|
|
@@ -50,7 +52,7 @@ module Textus
|
|
|
50
52
|
|
|
51
53
|
def collect_keys
|
|
52
54
|
prefixes = Array(@spec["select"])
|
|
53
|
-
ops = Operations.for(@store)
|
|
55
|
+
ops = Operations.for(@store, bypass_freshness: @bypass_freshness)
|
|
54
56
|
prefixes.flat_map { |p| ops.reads.list.call(prefix: p).map { |row| row["key"] } }.uniq
|
|
55
57
|
end
|
|
56
58
|
|
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.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Patrick
|
|
@@ -188,6 +188,7 @@ files:
|
|
|
188
188
|
- lib/textus/doctor/check/intake_registration.rb
|
|
189
189
|
- lib/textus/doctor/check/manifest_files.rb
|
|
190
190
|
- lib/textus/doctor/check/protocol_version.rb
|
|
191
|
+
- lib/textus/doctor/check/refresh_locks.rb
|
|
191
192
|
- lib/textus/doctor/check/rule_ambiguity.rb
|
|
192
193
|
- lib/textus/doctor/check/schema_parse_error.rb
|
|
193
194
|
- lib/textus/doctor/check/schema_violations.rb
|
|
@@ -223,6 +224,7 @@ files:
|
|
|
223
224
|
- lib/textus/hooks/dsl.rb
|
|
224
225
|
- lib/textus/hooks/loader.rb
|
|
225
226
|
- lib/textus/hooks/registry.rb
|
|
227
|
+
- lib/textus/infra/build_lock.rb
|
|
226
228
|
- lib/textus/infra/clock.rb
|
|
227
229
|
- lib/textus/infra/event_bus.rb
|
|
228
230
|
- lib/textus/infra/publisher.rb
|