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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4a732844e2441328f7519ffd93571c3ff611fd0514c2b1f927b5c4ada499db16
4
- data.tar.gz: 63a3e530439c37fbbff8c8b3864b1489cd34c88c88bd1c10f03ae980f4a66041
3
+ metadata.gz: d1e2a12b234bfc677ce44ec7b49b3b04b4ac69abbc15f13496911c82d89549c8
4
+ data.tar.gz: 6b83336a05cb3cee509d3e0b90ce2048ed57fdeb5fbc603f22303e10c2d5c27c
5
5
  SHA512:
6
- metadata.gz: 5470a6aaf29f1b081144b2eca3b1e87e5b1d465977422c967366196d259041cdbb8e0993ce293d87d7903f9446649f80ea99e1e6367b9731f7b839bf18f9d67f
7
- data.tar.gz: dcd3f57064d2f6753f049daf1315a937566e3b77a9ec130455a54410292f73ec55104bafe1e1b20431dc186a1c13735a7e6c6018ef8f54b77122f0eb2e267e8c
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 = store
14
- @role = role.to_s
15
- @correlation_id = correlation_id || SecureRandom.uuid
16
- @clock = clock
17
- @dry_run = dry_run
18
- @now = nil
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
- 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
@@ -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
@@ -23,6 +23,7 @@ module Textus
23
23
  Check::SchemaViolations,
24
24
  Check::RuleAmbiguity,
25
25
  Check::HandlerAllowlist,
26
+ Check::RefreshLocks,
26
27
  ].freeze
27
28
 
28
29
  ALL_CHECKS = CHECKS.map(&:name_key).freeze
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
@@ -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
@@ -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 = Operations.for(@store).reads.get.call(key)
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
 
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.14.2"
2
+ VERSION = "0.14.4"
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.2
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