textus 0.47.1 → 0.49.0
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 +32 -0
- data/README.md +9 -7
- data/SPEC.md +40 -46
- data/docs/reference/conventions.md +11 -10
- data/lib/textus/boot.rb +2 -2
- data/lib/textus/cli/runner.rb +5 -4
- data/lib/textus/dispatcher.rb +3 -8
- data/lib/textus/doctor/check/generator_drift.rb +28 -0
- data/lib/textus/doctor/check/lifecycle_action_invalid.rb +39 -0
- data/lib/textus/doctor/check/rule_ambiguity.rb +1 -1
- data/lib/textus/doctor.rb +2 -0
- data/lib/textus/domain/lifecycle.rb +83 -0
- data/lib/textus/domain/policy/base_guards.rb +2 -2
- data/lib/textus/domain/policy/lifecycle.rb +35 -0
- data/lib/textus/domain/staleness.rb +6 -3
- data/lib/textus/envelope/io/writer.rb +2 -2
- data/lib/textus/hooks/context.rb +1 -1
- data/lib/textus/init.rb +4 -4
- data/lib/textus/maintenance/key_delete_prefix.rb +1 -1
- data/lib/textus/maintenance/key_mv_prefix.rb +2 -2
- data/lib/textus/maintenance/tend.rb +110 -0
- data/lib/textus/manifest/rules.rb +11 -23
- data/lib/textus/manifest/schema.rb +3 -18
- data/lib/textus/ports/audit_log.rb +1 -1
- data/lib/textus/ports/audit_subscriber.rb +1 -1
- data/lib/textus/ports/fetch/detached.rb +5 -1
- data/lib/textus/read/freshness.rb +29 -22
- data/lib/textus/read/get.rb +47 -32
- data/lib/textus/read/pulse.rb +1 -1
- data/lib/textus/read/rule_explain.rb +10 -16
- data/lib/textus/read/rule_list.rb +5 -7
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/accept.rb +1 -1
- data/lib/textus/write/fetch_worker.rb +8 -12
- data/lib/textus/write/{delete.rb → key_delete.rb} +3 -3
- data/lib/textus/write/{mv.rb → key_mv.rb} +4 -4
- data/lib/textus/write/reject.rb +1 -1
- metadata +8 -15
- data/lib/textus/cli/group/fetch.rb +0 -20
- data/lib/textus/cli/verb/fetch.rb +0 -14
- data/lib/textus/cli/verb/fetch_all.rb +0 -20
- data/lib/textus/domain/policy/fetch.rb +0 -37
- data/lib/textus/domain/policy/retention.rb +0 -26
- data/lib/textus/domain/retention.rb +0 -44
- data/lib/textus/domain/staleness/intake_check.rb +0 -54
- data/lib/textus/maintenance/migrate.rb +0 -65
- data/lib/textus/read/retainable.rb +0 -17
- data/lib/textus/read/stale.rb +0 -17
- data/lib/textus/write/fetch_all.rb +0 -53
- data/lib/textus/write/retention_sweep.rb +0 -64
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Domain
|
|
5
|
+
# Unified lifecycle reporter (ADR 0079): which entries are past their ttl,
|
|
6
|
+
# and the on_expire action that applies. Replaces both Staleness::IntakeCheck
|
|
7
|
+
# and Retention. Age basis: _meta.last_fetched_at (intake) when present, else
|
|
8
|
+
# file mtime (stored). `self.verdict` is the pure per-entry decision that BOTH
|
|
9
|
+
# this reporter and `Read::Get` (Plan 2) call, so the basis logic lives once.
|
|
10
|
+
class Lifecycle
|
|
11
|
+
# Pure: is the entry past its ttl? -> [expired(bool), reason(String|nil)].
|
|
12
|
+
def self.verdict(policy:, last_fetched_at:, mtime:, now:)
|
|
13
|
+
ttl = policy.ttl_seconds
|
|
14
|
+
return [false, nil] if ttl.nil?
|
|
15
|
+
|
|
16
|
+
basis = parse_time(last_fetched_at) || mtime
|
|
17
|
+
return [true, "never recorded"] if basis.nil?
|
|
18
|
+
|
|
19
|
+
age = (now - basis).to_i
|
|
20
|
+
age > ttl ? [true, "ttl exceeded (age=#{age}s, ttl=#{ttl}s)"] : [false, nil]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.parse_time(str)
|
|
24
|
+
return nil if str.nil?
|
|
25
|
+
|
|
26
|
+
Time.parse(str.to_s)
|
|
27
|
+
rescue ArgumentError, TypeError
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(manifest:, file_stat:, clock:)
|
|
32
|
+
@manifest = manifest
|
|
33
|
+
@file_stat = file_stat
|
|
34
|
+
@clock = clock
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def call(prefix: nil, zone: nil)
|
|
38
|
+
@manifest.data.entries
|
|
39
|
+
.select { |m| entry_matches?(m, prefix: prefix, zone: zone) }
|
|
40
|
+
.flat_map { |m| rows_for(m) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def entry_matches?(mentry, prefix:, zone:)
|
|
46
|
+
return false if zone && mentry.zone != zone
|
|
47
|
+
return false if prefix && !(mentry.key == prefix || mentry.key.start_with?("#{prefix}."))
|
|
48
|
+
|
|
49
|
+
true
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def rows_for(mentry)
|
|
53
|
+
policy = @manifest.rules.for(mentry.key).lifecycle
|
|
54
|
+
return [] if policy.nil?
|
|
55
|
+
|
|
56
|
+
@manifest.resolver.enumerate(prefix: mentry.key).filter_map do |row|
|
|
57
|
+
path = row[:path]
|
|
58
|
+
next unless @file_stat.exists?(path)
|
|
59
|
+
|
|
60
|
+
expired, _reason = self.class.verdict(
|
|
61
|
+
policy: policy,
|
|
62
|
+
last_fetched_at: last_fetched_at_of(mentry, path),
|
|
63
|
+
mtime: @file_stat.mtime(path),
|
|
64
|
+
now: @clock.now,
|
|
65
|
+
)
|
|
66
|
+
next unless expired
|
|
67
|
+
|
|
68
|
+
{
|
|
69
|
+
"key" => row[:key], "path" => path,
|
|
70
|
+
"action" => policy.on_expire.to_s, "expired" => true
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Reads _meta.last_fetched_at from the on-disk envelope (intake basis).
|
|
76
|
+
def last_fetched_at_of(mentry, path)
|
|
77
|
+
Entry.for_format(mentry.format).parse(@file_stat.read(path), path: path)["_meta"]["last_fetched_at"]
|
|
78
|
+
rescue StandardError
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -11,8 +11,8 @@ module Textus
|
|
|
11
11
|
# composable-only, added per-key via rules[].guard (ADR 0031).
|
|
12
12
|
BASE = {
|
|
13
13
|
put: %w[zone_writable_by],
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
key_delete: %w[zone_writable_by],
|
|
15
|
+
key_mv: %w[zone_writable_by],
|
|
16
16
|
accept: %w[author_held target_is_canon],
|
|
17
17
|
reject: %w[author_held],
|
|
18
18
|
fetch: %w[zone_writable_by],
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Domain
|
|
3
|
+
module Policy
|
|
4
|
+
# Unified per-entry lifecycle policy (ADR 0079): one ttl + one action.
|
|
5
|
+
# Replaces the separate Fetch (ttl/on_stale) and Retention
|
|
6
|
+
# (expire_after/archive_after) policies. The action's destructiveness
|
|
7
|
+
# decides WHERE it runs: lazy actions (refresh/warn) on get/list reads;
|
|
8
|
+
# destructive actions (drop/archive) only on the tend sweep.
|
|
9
|
+
class Lifecycle
|
|
10
|
+
LAZY = %i[refresh warn].freeze
|
|
11
|
+
DESTRUCTIVE = %i[drop archive].freeze
|
|
12
|
+
ALLOWED = (LAZY + DESTRUCTIVE).freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :on_expire, :budget_ms
|
|
15
|
+
|
|
16
|
+
def initialize(ttl:, on_expire:, budget_ms: nil)
|
|
17
|
+
action = on_expire.is_a?(Symbol) ? on_expire : on_expire.to_s.to_sym
|
|
18
|
+
unless ALLOWED.include?(action)
|
|
19
|
+
raise Textus::UsageError.new(
|
|
20
|
+
"lifecycle on_expire must be one of #{ALLOWED.join("|")}, got #{on_expire.inspect}",
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
@ttl = ttl
|
|
25
|
+
@on_expire = action
|
|
26
|
+
@budget_ms = budget_ms
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def ttl_seconds = Textus::Domain::Duration.seconds(@ttl)
|
|
30
|
+
def destructive? = DESTRUCTIVE.include?(@on_expire)
|
|
31
|
+
def lazy? = LAZY.include?(@on_expire)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Domain
|
|
3
3
|
class Staleness
|
|
4
|
-
|
|
4
|
+
# ADR 0079: intake (age-based) staleness moved to the unified lifecycle
|
|
5
|
+
# path (Domain::Lifecycle / freshness); only generator/build drift —
|
|
6
|
+
# dependency-based, surfaced by the doctor `generator_drift` check —
|
|
7
|
+
# remains here.
|
|
8
|
+
def initialize(manifest:, file_stat:, clock: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
5
9
|
@manifest = manifest
|
|
6
10
|
@generator_check = GeneratorCheck.new(manifest: manifest, file_stat: file_stat)
|
|
7
|
-
@intake_check = IntakeCheck.new(manifest: manifest, file_stat: file_stat, clock: clock)
|
|
8
11
|
end
|
|
9
12
|
|
|
10
13
|
def call(prefix: nil, zone: nil)
|
|
11
14
|
@manifest.data.entries
|
|
12
15
|
.select { |m| entry_matches?(m, prefix: prefix, zone: zone) }
|
|
13
|
-
.flat_map { |m| @generator_check.rows_for(m)
|
|
16
|
+
.flat_map { |m| @generator_check.rows_for(m) }
|
|
14
17
|
end
|
|
15
18
|
|
|
16
19
|
private
|
|
@@ -84,7 +84,7 @@ module Textus
|
|
|
84
84
|
@file_store.delete(path)
|
|
85
85
|
prune_empty_parents(path)
|
|
86
86
|
@audit_log.append(
|
|
87
|
-
role: @call.role, verb: "
|
|
87
|
+
role: @call.role, verb: "key_delete", key: key,
|
|
88
88
|
etag_before: etag_before, etag_after: nil,
|
|
89
89
|
extras: @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
|
|
90
90
|
)
|
|
@@ -121,7 +121,7 @@ module Textus
|
|
|
121
121
|
extras["correlation_id"] = @call.correlation_id if @call.correlation_id
|
|
122
122
|
|
|
123
123
|
@audit_log.append(
|
|
124
|
-
role: @call.role, verb: "
|
|
124
|
+
role: @call.role, verb: "key_mv", key: to_key,
|
|
125
125
|
etag_before: etag_before, etag_after: etag_after,
|
|
126
126
|
extras: extras
|
|
127
127
|
)
|
data/lib/textus/hooks/context.rb
CHANGED
|
@@ -45,7 +45,7 @@ module Textus
|
|
|
45
45
|
|
|
46
46
|
# write (authorized + audited)
|
|
47
47
|
def put(key, **) = @scope.put(key, **)
|
|
48
|
-
def delete(key, **) = @scope.
|
|
48
|
+
def delete(key, **) = @scope.key_delete(key, **)
|
|
49
49
|
|
|
50
50
|
def audit(verb, key:, **)
|
|
51
51
|
@scope.container.audit_log.append(role: @role, verb: verb, key: key, **)
|
data/lib/textus/init.rb
CHANGED
|
@@ -41,7 +41,7 @@ module Textus
|
|
|
41
41
|
local: { via: local }
|
|
42
42
|
rules:
|
|
43
43
|
- match: feeds.machines.**
|
|
44
|
-
|
|
44
|
+
lifecycle: { ttl: 1h, on_expire: warn } # meaningful on a long-running server
|
|
45
45
|
YAML
|
|
46
46
|
|
|
47
47
|
HOOKS_README = <<~MD
|
|
@@ -72,7 +72,7 @@ module Textus
|
|
|
72
72
|
```
|
|
73
73
|
|
|
74
74
|
The intake handler above is paired with a manifest entry plus a
|
|
75
|
-
top-level `rules:` block for
|
|
75
|
+
top-level `rules:` block for lifecycle (ttl/on_expire live in
|
|
76
76
|
rules, not in the entry):
|
|
77
77
|
|
|
78
78
|
```yaml
|
|
@@ -86,9 +86,9 @@ module Textus
|
|
|
86
86
|
|
|
87
87
|
rules:
|
|
88
88
|
- match: feeds.foo
|
|
89
|
-
|
|
89
|
+
lifecycle:
|
|
90
90
|
ttl: 10m
|
|
91
|
-
|
|
91
|
+
on_expire: refresh # refresh | warn (intake); drop | archive (stored)
|
|
92
92
|
```
|
|
93
93
|
|
|
94
94
|
Events: :resolve_intake, :transform_rows, :validate (rpc — return value used)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Maintenance
|
|
3
3
|
# Bulk-rename every leaf key under `from_prefix` to `to_prefix`.
|
|
4
|
-
# Calls Write::
|
|
4
|
+
# Calls Write::KeyMv directly for each entry — emits one audit row per file moved.
|
|
5
5
|
class KeyMvPrefix
|
|
6
6
|
extend Textus::Contract::DSL
|
|
7
7
|
|
|
@@ -61,7 +61,7 @@ module Textus
|
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
def mv
|
|
64
|
-
Write::
|
|
64
|
+
Write::KeyMv.new(container: @container, call: @call)
|
|
65
65
|
end
|
|
66
66
|
end
|
|
67
67
|
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Maintenance
|
|
5
|
+
# The destructive-only lifecycle sweep (ADR 0079, supersedes the composite
|
|
6
|
+
# 0078 body). Drives off the unified Domain::Lifecycle reporter: it applies
|
|
7
|
+
# destructive actions a read never performs (drop = delete via Write::KeyDelete;
|
|
8
|
+
# archive = copy to <store>/archive/ then delete) and refreshes cold expired
|
|
9
|
+
# intake entries (on_expire: refresh) via Write::FetchWorker. Non-destructive
|
|
10
|
+
# annotation (warn) is left to the lazy `get`/`freshness` path. Adds no new
|
|
11
|
+
# authority — every sub-op runs with the CALLER's own `call` (role), and is
|
|
12
|
+
# gated exactly as on its own.
|
|
13
|
+
class Tend
|
|
14
|
+
extend Textus::Contract::DSL
|
|
15
|
+
|
|
16
|
+
verb :tend
|
|
17
|
+
summary "Run the destructive lifecycle sweep: drop/archive expired entries, refresh cold intake, report health."
|
|
18
|
+
surfaces :cli, :mcp
|
|
19
|
+
cli "tend"
|
|
20
|
+
arg :prefix, String, description: "restrict the sweep to keys under this dotted prefix"
|
|
21
|
+
arg :zone, String, description: "restrict the sweep to entries in this zone"
|
|
22
|
+
arg :dry_run, :boolean, default: false,
|
|
23
|
+
description: "when true, report what the sweep WOULD do without applying; " \
|
|
24
|
+
"defaults to false, so omitting it drops/archives/refreshes immediately"
|
|
25
|
+
|
|
26
|
+
def initialize(container:, call:)
|
|
27
|
+
@container = container
|
|
28
|
+
@call = call
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call(prefix: nil, zone: nil, dry_run: false)
|
|
32
|
+
rows = Textus::Domain::Lifecycle.new(
|
|
33
|
+
manifest: @container.manifest,
|
|
34
|
+
file_stat: Textus::Ports::Storage::FileStat.new,
|
|
35
|
+
clock: Textus::Ports::Clock,
|
|
36
|
+
).call(prefix: prefix, zone: zone)
|
|
37
|
+
|
|
38
|
+
health = Read::Doctor.new(container: @container, call: @call).call
|
|
39
|
+
return dry_run_result(rows, health) if dry_run
|
|
40
|
+
|
|
41
|
+
apply_result(apply(rows), health)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def dry_run_result(rows, health)
|
|
47
|
+
{
|
|
48
|
+
"protocol" => Textus::PROTOCOL, "ok" => true, "dry_run" => true,
|
|
49
|
+
"would_drop" => action_keys(rows, "drop"),
|
|
50
|
+
"would_archive" => action_keys(rows, "archive"),
|
|
51
|
+
"would_refresh" => action_keys(rows, "refresh"),
|
|
52
|
+
"health" => health
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def apply_result(result, health)
|
|
57
|
+
{
|
|
58
|
+
"protocol" => Textus::PROTOCOL,
|
|
59
|
+
"ok" => result[:failed].empty?,
|
|
60
|
+
"dry_run" => false,
|
|
61
|
+
"dropped" => result[:dropped], "archived" => result[:archived],
|
|
62
|
+
"refreshed" => result[:refreshed], "failed" => result[:failed],
|
|
63
|
+
"health" => health
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def action_keys(rows, action)
|
|
68
|
+
rows.select { |r| r["action"] == action }.map { |r| r["key"] }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def apply(rows)
|
|
72
|
+
out = { dropped: [], archived: [], refreshed: [], failed: [] }
|
|
73
|
+
delete = Write::KeyDelete.new(container: @container, call: @call)
|
|
74
|
+
refresh = Write::FetchWorker.new(container: @container, call: @call)
|
|
75
|
+
|
|
76
|
+
rows.each do |row|
|
|
77
|
+
key = row["key"]
|
|
78
|
+
begin
|
|
79
|
+
case row["action"]
|
|
80
|
+
when "drop"
|
|
81
|
+
delete.call(key)
|
|
82
|
+
out[:dropped] << key
|
|
83
|
+
when "archive"
|
|
84
|
+
archive_leaf(row)
|
|
85
|
+
delete.call(key)
|
|
86
|
+
out[:archived] << key
|
|
87
|
+
when "refresh"
|
|
88
|
+
refresh.run(key)
|
|
89
|
+
out[:refreshed] << key
|
|
90
|
+
end
|
|
91
|
+
rescue Textus::Error => e
|
|
92
|
+
out[:failed] << { "key" => key, "error" => e.message }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
out
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Copy the leaf into <store>/archive/<relative-path> before deletion.
|
|
99
|
+
# (Lifted from the retired RetentionSweep#archive_leaf.)
|
|
100
|
+
def archive_leaf(row)
|
|
101
|
+
src = row["path"]
|
|
102
|
+
root = @container.root.to_s
|
|
103
|
+
rel = src.delete_prefix("#{root}/")
|
|
104
|
+
dest = File.join(root, "archive", rel)
|
|
105
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
106
|
+
FileUtils.cp(src, dest)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
class Manifest
|
|
3
3
|
class Rules
|
|
4
|
-
RuleSet = ::Data.define(:
|
|
5
|
-
EMPTY_SET = RuleSet.new(
|
|
4
|
+
RuleSet = ::Data.define(:handler_allowlist, :guard, :lifecycle)
|
|
5
|
+
EMPTY_SET = RuleSet.new(handler_allowlist: nil, guard: nil, lifecycle: nil)
|
|
6
6
|
|
|
7
7
|
def self.parse(raw)
|
|
8
8
|
new(Array(raw).map { |b| Block.new(b) })
|
|
@@ -15,17 +15,16 @@ module Textus
|
|
|
15
15
|
attr_reader :blocks
|
|
16
16
|
|
|
17
17
|
def for(key)
|
|
18
|
-
slots = {
|
|
18
|
+
slots = { handler_allowlist: [], guard: [], lifecycle: [] }
|
|
19
19
|
@blocks.each do |b|
|
|
20
20
|
next unless Textus::Domain::Policy::Matcher.matches?(b.match, key)
|
|
21
21
|
|
|
22
22
|
slots.each_key { |slot| slots[slot] << b if b.public_send(slot) }
|
|
23
23
|
end
|
|
24
24
|
RuleSet.new(
|
|
25
|
-
fetch: pick(slots[:fetch], :fetch, key),
|
|
26
25
|
handler_allowlist: pick(slots[:handler_allowlist], :handler_allowlist, key),
|
|
27
26
|
guard: pick(slots[:guard], :guard, key),
|
|
28
|
-
|
|
27
|
+
lifecycle: pick(slots[:lifecycle], :lifecycle, key),
|
|
29
28
|
)
|
|
30
29
|
end
|
|
31
30
|
|
|
@@ -44,29 +43,17 @@ module Textus
|
|
|
44
43
|
end
|
|
45
44
|
|
|
46
45
|
class Block
|
|
47
|
-
attr_reader :match, :
|
|
46
|
+
attr_reader :match, :handler_allowlist, :guard, :lifecycle
|
|
48
47
|
|
|
49
48
|
def initialize(raw)
|
|
50
49
|
@match = raw["match"] or raise Textus::UsageError.new("rule block missing match:")
|
|
51
|
-
@fetch = parse_fetch(raw["fetch"])
|
|
52
50
|
@handler_allowlist = parse_handler_allowlist(raw["intake_handler_allowlist"])
|
|
53
51
|
@guard = parse_guard(raw["guard"])
|
|
54
|
-
@
|
|
52
|
+
@lifecycle = parse_lifecycle(raw["lifecycle"])
|
|
55
53
|
end
|
|
56
54
|
|
|
57
55
|
private
|
|
58
56
|
|
|
59
|
-
def parse_fetch(h)
|
|
60
|
-
return nil if h.nil?
|
|
61
|
-
|
|
62
|
-
Textus::Domain::Policy::Fetch.new(
|
|
63
|
-
ttl: h["ttl"],
|
|
64
|
-
on_stale: h["on_stale"] || "warn",
|
|
65
|
-
sync_budget_ms: h["sync_budget_ms"],
|
|
66
|
-
fetch_timeout_seconds: h["fetch_timeout_seconds"],
|
|
67
|
-
)
|
|
68
|
-
end
|
|
69
|
-
|
|
70
57
|
def parse_handler_allowlist(arr)
|
|
71
58
|
return nil if arr.nil?
|
|
72
59
|
|
|
@@ -83,12 +70,13 @@ module Textus
|
|
|
83
70
|
h
|
|
84
71
|
end
|
|
85
72
|
|
|
86
|
-
def
|
|
73
|
+
def parse_lifecycle(h)
|
|
87
74
|
return nil if h.nil?
|
|
88
75
|
|
|
89
|
-
Textus::Domain::Policy::
|
|
90
|
-
|
|
91
|
-
|
|
76
|
+
Textus::Domain::Policy::Lifecycle.new(
|
|
77
|
+
ttl: h["ttl"],
|
|
78
|
+
on_expire: h["on_expire"],
|
|
79
|
+
budget_ms: h["budget_ms"],
|
|
92
80
|
)
|
|
93
81
|
end
|
|
94
82
|
end
|
|
@@ -32,10 +32,8 @@ module Textus
|
|
|
32
32
|
PUBLISH_KEYS = %w[to tree].freeze
|
|
33
33
|
COMPUTE_KEYS = %w[kind select pluck sort_by limit transform command sources].freeze
|
|
34
34
|
INTAKE_KEYS = %w[handler config].freeze
|
|
35
|
-
RULE_KEYS = %w[match
|
|
36
|
-
|
|
37
|
-
FETCH_TIMEOUT_SECONDS_CEILING = 3600
|
|
38
|
-
RETENTION_KEYS = %w[expire_after archive_after].freeze
|
|
35
|
+
RULE_KEYS = %w[match intake_handler_allowlist guard lifecycle].freeze
|
|
36
|
+
LIFECYCLE_KEYS = %w[ttl on_expire budget_ms].freeze
|
|
39
37
|
AUDIT_KEYS = %w[max_size keep].freeze
|
|
40
38
|
|
|
41
39
|
# Syntactic shape of an `owner:` subject token (the `patrick` in
|
|
@@ -136,11 +134,7 @@ module Textus
|
|
|
136
134
|
Array(rules).each_with_index do |r, i|
|
|
137
135
|
path = "$.rules[#{i}]"
|
|
138
136
|
walk(r, RULE_KEYS, path)
|
|
139
|
-
if r["
|
|
140
|
-
walk(r["fetch"], FETCH_KEYS, "#{path}.fetch")
|
|
141
|
-
validate_fetch_timeout!(r["fetch"]["fetch_timeout_seconds"], "#{path}.fetch.fetch_timeout_seconds")
|
|
142
|
-
end
|
|
143
|
-
walk(r["retention"], RETENTION_KEYS, "#{path}.retention") if r["retention"].is_a?(Hash)
|
|
137
|
+
walk(r["lifecycle"], LIFECYCLE_KEYS, "#{path}.lifecycle") if r["lifecycle"].is_a?(Hash)
|
|
144
138
|
end
|
|
145
139
|
end
|
|
146
140
|
|
|
@@ -217,15 +211,6 @@ module Textus
|
|
|
217
211
|
OWNER_SUBJECT_PATTERN.match?(subject)
|
|
218
212
|
end
|
|
219
213
|
|
|
220
|
-
def self.validate_fetch_timeout!(value, path)
|
|
221
|
-
return if value.nil?
|
|
222
|
-
return if value.is_a?(Integer) && value.positive? && value <= FETCH_TIMEOUT_SECONDS_CEILING
|
|
223
|
-
|
|
224
|
-
raise BadManifest.new(
|
|
225
|
-
"fetch_timeout_seconds at '#{path}' must be a positive integer ≤ #{FETCH_TIMEOUT_SECONDS_CEILING} (got #{value.inspect})",
|
|
226
|
-
)
|
|
227
|
-
end
|
|
228
|
-
|
|
229
214
|
def self.walk(hash, allowed, path)
|
|
230
215
|
return unless hash.is_a?(Hash)
|
|
231
216
|
|
|
@@ -23,7 +23,7 @@ module Textus
|
|
|
23
23
|
parsed = parse_row(line.chomp)
|
|
24
24
|
next unless parsed
|
|
25
25
|
next unless parsed["key"] == key
|
|
26
|
-
next unless %w[put delete].include?(parsed["verb"])
|
|
26
|
+
next unless %w[put delete key_delete].include?(parsed["verb"])
|
|
27
27
|
|
|
28
28
|
last_role = parsed["role"]
|
|
29
29
|
end
|
|
@@ -10,7 +10,7 @@ module Textus
|
|
|
10
10
|
# rescue and the failure is a bus-internal concern, not a domain
|
|
11
11
|
# event subscribers should be able to filter by key glob).
|
|
12
12
|
#
|
|
13
|
-
# Lifecycle audit rows for verb: "put" / "
|
|
13
|
+
# Lifecycle audit rows for verb: "put" / "key_delete" / "key_mv" are written
|
|
14
14
|
# by Envelope::IO::Writer directly (it owns the
|
|
15
15
|
# audit-append-as-final-step invariant); this subscriber covers the
|
|
16
16
|
# hook-failure case the writer never sees.
|
|
@@ -31,7 +31,11 @@ module Textus
|
|
|
31
31
|
# exists). Config-time detection is doctor's job (ADR 0044 Q2).
|
|
32
32
|
role = acting_role(store)
|
|
33
33
|
exit(0) unless role
|
|
34
|
-
|
|
34
|
+
# FetchWorker is the internal executor since the public `fetch`
|
|
35
|
+
# verb was collapsed (ADR 0079); drive it directly.
|
|
36
|
+
Textus::Write::FetchWorker.new(
|
|
37
|
+
container: store.container, call: Textus::Call.build(role: role),
|
|
38
|
+
).run(key)
|
|
35
39
|
rescue StandardError
|
|
36
40
|
# Already logged via :fetch_failed; exit cleanly.
|
|
37
41
|
ensure
|
|
@@ -2,10 +2,10 @@ require "time"
|
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Read
|
|
5
|
-
# Per-entry
|
|
6
|
-
# consults `
|
|
7
|
-
#
|
|
8
|
-
# :
|
|
5
|
+
# Per-entry lifecycle report (ADR 0079). Walks every entry declared in the
|
|
6
|
+
# manifest, consults `rules.for(key)` for a `lifecycle:` policy, and reports
|
|
7
|
+
# the unified verdict. Status is one of :fresh, :expired, or :no_policy; the
|
|
8
|
+
# row also carries the policy's :action (on_expire).
|
|
9
9
|
class Freshness
|
|
10
10
|
extend Textus::Contract::DSL
|
|
11
11
|
|
|
@@ -17,13 +17,11 @@ module Textus
|
|
|
17
17
|
arg :zone, String, required: false, description: "filter to entries in this zone"
|
|
18
18
|
view(:cli) { |rows| { "verb" => "freshness", "rows" => rows } }
|
|
19
19
|
|
|
20
|
-
def initialize(container:, call
|
|
20
|
+
def initialize(container:, call:)
|
|
21
21
|
@container = container
|
|
22
22
|
@call = call
|
|
23
23
|
@manifest = container.manifest
|
|
24
24
|
@file_store = container.file_store
|
|
25
|
-
@evaluator = evaluator
|
|
26
|
-
@cache = {}
|
|
27
25
|
end
|
|
28
26
|
|
|
29
27
|
# Returns the soonest `next_due_at` across all entries with a fetch
|
|
@@ -52,29 +50,38 @@ module Textus
|
|
|
52
50
|
private
|
|
53
51
|
|
|
54
52
|
def row_for(mentry)
|
|
55
|
-
|
|
56
|
-
fetch = set.fetch
|
|
53
|
+
policy = lifecycle_for(mentry.key)
|
|
57
54
|
envelope = safe_get(mentry.key)
|
|
58
55
|
last = envelope&.meta&.dig("last_fetched_at")
|
|
59
56
|
|
|
60
|
-
return base_row(mentry, last).merge(status: :no_policy) if
|
|
61
|
-
|
|
62
|
-
fp = fetch.to_freshness_policy
|
|
63
|
-
cache_key = [mentry.key, last]
|
|
64
|
-
verdict = (@cache[cache_key] ||= @evaluator.call(fp, envelope, now: @call.now))
|
|
65
|
-
status = if verdict.fresh? then :fresh
|
|
66
|
-
elsif last.nil? then :never_fetched
|
|
67
|
-
else :stale
|
|
68
|
-
end
|
|
57
|
+
return base_row(mentry, last).merge(status: :no_policy) if policy.nil?
|
|
69
58
|
|
|
59
|
+
expired, reason = Textus::Domain::Lifecycle.verdict(
|
|
60
|
+
policy: policy,
|
|
61
|
+
last_fetched_at: last,
|
|
62
|
+
mtime: mtime_for(mentry.key),
|
|
63
|
+
now: @call.now,
|
|
64
|
+
)
|
|
70
65
|
base_row(mentry, last).merge(
|
|
71
|
-
ttl_seconds:
|
|
72
|
-
|
|
73
|
-
status:
|
|
74
|
-
|
|
66
|
+
ttl_seconds: policy.ttl_seconds,
|
|
67
|
+
action: policy.on_expire,
|
|
68
|
+
status: expired ? :expired : :fresh,
|
|
69
|
+
reason: reason,
|
|
70
|
+
next_due_at: next_due(last, policy.ttl_seconds),
|
|
75
71
|
)
|
|
76
72
|
end
|
|
77
73
|
|
|
74
|
+
def lifecycle_for(key)
|
|
75
|
+
@manifest.rules.for(key).lifecycle
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def mtime_for(key)
|
|
79
|
+
path = @manifest.resolver.resolve(key).path
|
|
80
|
+
@file_store.exists?(path) ? Textus::Ports::Storage::FileStat.new.mtime(path) : nil
|
|
81
|
+
rescue Textus::Error
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
78
85
|
def base_row(mentry, last)
|
|
79
86
|
{
|
|
80
87
|
key: mentry.key,
|