textus 0.14.3 → 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: 65cfe5f73a64dda44280d1dd58e418aecdc94b79e1abb68340e973d364da3465
4
- data.tar.gz: c35f1428a0965c11c946395a8fb7dfeb60202e2fac2514b1bb04dfae1e426ed2
3
+ metadata.gz: d1e2a12b234bfc677ce44ec7b49b3b04b4ac69abbc15f13496911c82d89549c8
4
+ data.tar.gz: 6b83336a05cb3cee509d3e0b90ce2048ed57fdeb5fbc603f22303e10c2d5c27c
5
5
  SHA512:
6
- metadata.gz: edeefd203bb0f4af807457cb96353affd3a21de846331481bb3d6e69e344fc1ea8054f8066ed278e7a7ced366704a65df00269d38b58a5b729d408477bc4f5a5
7
- data.tar.gz: 4c30fa46409c381bfa8ef381c38314ce988786b9f2cf81c8b1c76f7508301587b9e5562ce1ced415f85484fe794fe3aeff5186203f733cedea34443d43a4eb57
6
+ metadata.gz: f126d898385e0d0236a1818a741923f8ee9f88f1f13cab8ef37bbb810d14145904186084f72d0fbc5a4d6bfdf53b5e7e68699f56db4f9aa02d22772019a11316
7
+ data.tar.gz: 054ea4fea78cf26d570319d49f2a0ffbd4023e6355b56b8830650e7d59b1b57ae2de7b158ef0611b465369136e11b292ba9f968113cd425ecd1cfed1bf96633d
data/CHANGELOG.md CHANGED
@@ -9,6 +9,48 @@ 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
+
12
54
  ## 0.14.3 — 2026-05-26
13
55
 
14
56
  ### 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
@@ -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
@@ -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.3"
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.3
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