textus 0.8.0 → 0.9.2
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 +245 -0
- data/README.md +54 -26
- data/SPEC.md +194 -63
- data/docs/architecture.md +22 -4
- data/docs/conventions.md +24 -17
- data/lib/textus/application/context.rb +68 -0
- data/lib/textus/application/reads/audit.rb +69 -0
- data/lib/textus/application/reads/blame.rb +79 -0
- data/lib/textus/application/reads/freshness.rb +77 -0
- data/lib/textus/application/reads/get.rb +62 -0
- data/lib/textus/application/reads/policy_explain.rb +39 -0
- data/lib/textus/application/refresh/all.rb +41 -0
- data/lib/textus/application/refresh/orchestrator.rb +68 -0
- data/lib/textus/application/refresh/worker.rb +79 -0
- data/lib/textus/application/writes/accept.rb +43 -0
- data/lib/textus/application/writes/build.rb +24 -0
- data/lib/textus/application/writes/delete.rb +37 -0
- data/lib/textus/application/writes/publish.rb +25 -0
- data/lib/textus/application/writes/put.rb +44 -0
- data/lib/textus/builder.rb +27 -14
- data/lib/textus/cli/group/policy.rb +11 -0
- data/lib/textus/cli/verb/accept.rb +2 -1
- data/lib/textus/cli/verb/audit.rb +31 -0
- data/lib/textus/cli/verb/blame.rb +17 -0
- data/lib/textus/cli/verb/build.rb +2 -1
- data/lib/textus/cli/verb/delete.rb +2 -1
- data/lib/textus/cli/verb/freshness.rb +17 -0
- data/lib/textus/cli/verb/get.rb +8 -1
- data/lib/textus/cli/verb/hook_run.rb +3 -3
- data/lib/textus/cli/verb/policy_explain.rb +15 -0
- data/lib/textus/cli/verb/policy_list.rb +25 -0
- data/lib/textus/cli/verb/put.rb +5 -4
- data/lib/textus/cli/verb/refresh.rb +2 -1
- data/lib/textus/cli/verb/refresh_stale.rb +19 -0
- data/lib/textus/cli/verb/reject.rb +15 -0
- data/lib/textus/cli.rb +16 -2
- data/lib/textus/composition.rb +71 -0
- data/lib/textus/doctor/check/handler_allowlist.rb +33 -0
- data/lib/textus/doctor/check/intake_registration.rb +46 -0
- data/lib/textus/doctor/check/legacy_intake_fields.rb +57 -0
- data/lib/textus/doctor/check/policy_ambiguity.rb +49 -0
- data/lib/textus/doctor.rb +5 -1
- data/lib/textus/domain/action.rb +9 -0
- data/lib/textus/domain/freshness/evaluator.rb +30 -0
- data/lib/textus/domain/freshness/policy.rb +18 -0
- data/lib/textus/domain/freshness/verdict.rb +12 -0
- data/lib/textus/domain/outcome.rb +10 -0
- data/lib/textus/domain/permission.rb +15 -0
- data/lib/textus/domain/policy/handler_allowlist.rb +17 -0
- data/lib/textus/domain/policy/matcher.rb +51 -0
- data/lib/textus/domain/policy/promote.rb +24 -0
- data/lib/textus/domain/policy/refresh.rb +48 -0
- data/lib/textus/domain/policy.rb +7 -0
- data/lib/textus/hooks/builtin.rb +5 -5
- data/lib/textus/hooks/dispatcher.rb +15 -1
- data/lib/textus/hooks/dsl.rb +18 -0
- data/lib/textus/hooks/registry.rb +12 -5
- data/lib/textus/infra/clock.rb +9 -0
- data/lib/textus/infra/event_bus.rb +27 -0
- data/lib/textus/infra/publisher.rb +73 -0
- data/lib/textus/infra/refresh/detached.rb +38 -0
- data/lib/textus/infra/refresh/lock.rb +44 -0
- data/lib/textus/init.rb +71 -28
- data/lib/textus/intro.rb +22 -14
- data/lib/textus/manifest/entry.rb +18 -9
- data/lib/textus/manifest/policies.rb +83 -0
- data/lib/textus/manifest.rb +30 -0
- data/lib/textus/proposal.rb +4 -21
- data/lib/textus/publisher.rb +4 -69
- data/lib/textus/refresh.rb +9 -44
- data/lib/textus/store/mover.rb +14 -9
- data/lib/textus/store/reader.rb +10 -8
- data/lib/textus/store/staleness.rb +4 -16
- data/lib/textus/store/validator.rb +46 -20
- data/lib/textus/store/view.rb +8 -19
- data/lib/textus/store/writer.rb +51 -14
- data/lib/textus/store.rb +32 -12
- data/lib/textus/version.rb +1 -1
- data/lib/textus.rb +1 -0
- metadata +46 -2
- data/lib/textus/cli/verb/stale.rb +0 -14
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Application
|
|
5
|
+
class Context
|
|
6
|
+
attr_reader :store, :role, :correlation_id
|
|
7
|
+
|
|
8
|
+
def initialize(store:, role:, correlation_id: nil, clock: Time, dry_run: false)
|
|
9
|
+
@store = store
|
|
10
|
+
@role = role.to_s
|
|
11
|
+
@correlation_id = correlation_id || SecureRandom.uuid
|
|
12
|
+
@clock = clock
|
|
13
|
+
@dry_run = dry_run
|
|
14
|
+
@now = nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def now
|
|
18
|
+
@now ||= @clock.now
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def dry_run?
|
|
22
|
+
@dry_run
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def can_write?(zone)
|
|
26
|
+
store.manifest.permission_for(zone.to_s).allows_write?(role)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def can_read?(zone)
|
|
30
|
+
store.manifest.permission_for(zone.to_s).allows_read?(role)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def with_role(new_role)
|
|
34
|
+
self.class.new(
|
|
35
|
+
store: @store,
|
|
36
|
+
role: new_role,
|
|
37
|
+
correlation_id: @correlation_id,
|
|
38
|
+
clock: @clock,
|
|
39
|
+
dry_run: @dry_run,
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Backward-compat for intake handlers receiving a Context (was Store::View)
|
|
44
|
+
# that call store.put/get/delete on it. Slated for removal in 0.10.0.
|
|
45
|
+
def put(key, **opts)
|
|
46
|
+
opts[:as] ||= role
|
|
47
|
+
store.put(key, **opts)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def delete(key, **opts)
|
|
51
|
+
opts[:as] ||= role
|
|
52
|
+
store.delete(key, **opts)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def get(key, **)
|
|
56
|
+
store.get(key, **)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def list(*, **)
|
|
60
|
+
store.list(*, **)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def where(*, **)
|
|
64
|
+
store.where(*, **)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "time"
|
|
3
|
+
|
|
4
|
+
module Textus
|
|
5
|
+
module Application
|
|
6
|
+
module Reads
|
|
7
|
+
# Queries .textus/audit.log. Filters: key, zone, role, verb, since,
|
|
8
|
+
# correlation_id, limit. Reads the log file as JSON-Lines (legacy TSV
|
|
9
|
+
# rows produce nil and are skipped).
|
|
10
|
+
class Audit
|
|
11
|
+
def initialize(ctx:)
|
|
12
|
+
@ctx = ctx
|
|
13
|
+
@log_path = File.join(@ctx.store.root, "audit.log")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# rubocop:disable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
17
|
+
def call(key: nil, zone: nil, role: nil, verb: nil, since: nil, correlation_id: nil, limit: nil)
|
|
18
|
+
return [] unless File.exist?(@log_path)
|
|
19
|
+
|
|
20
|
+
rows = []
|
|
21
|
+
File.foreach(@log_path) do |line|
|
|
22
|
+
parsed = parse_row(line.chomp)
|
|
23
|
+
next unless parsed
|
|
24
|
+
next if key && parsed["key"] != key
|
|
25
|
+
next if role && parsed["role"] != role
|
|
26
|
+
next if verb && parsed["verb"] != verb
|
|
27
|
+
next if zone && !key_in_zone?(parsed["key"], zone)
|
|
28
|
+
next if since && (parsed["ts"].nil? || Time.parse(parsed["ts"]) < since)
|
|
29
|
+
next if correlation_id && parsed.dig("extras", "correlation_id") != correlation_id
|
|
30
|
+
|
|
31
|
+
rows << parsed
|
|
32
|
+
break if limit && rows.length >= limit
|
|
33
|
+
end
|
|
34
|
+
rows
|
|
35
|
+
end
|
|
36
|
+
# rubocop:enable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
37
|
+
|
|
38
|
+
# Accepts ISO8601 ("2026-01-15", "2026-01-15T10:00:00Z") or a relative
|
|
39
|
+
# offset matching /\A(\d+)([smhd])\z/. Returns nil for unparseable input.
|
|
40
|
+
def self.parse_since(str, now: Time.now.utc)
|
|
41
|
+
return nil if str.nil? || str.empty?
|
|
42
|
+
return Time.parse(str) if str =~ /\A\d{4}-\d{2}-\d{2}/
|
|
43
|
+
|
|
44
|
+
m = str.match(/\A(\d+)([smhd])\z/) or return nil
|
|
45
|
+
mult = { "s" => 1, "m" => 60, "h" => 3600, "d" => 86_400 }[m[2]]
|
|
46
|
+
now - (m[1].to_i * mult)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def parse_row(line)
|
|
52
|
+
return nil if line.empty?
|
|
53
|
+
return nil unless line.start_with?("{")
|
|
54
|
+
|
|
55
|
+
JSON.parse(line)
|
|
56
|
+
rescue JSON::ParserError
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def key_in_zone?(key, zone)
|
|
61
|
+
mentry, = @ctx.store.manifest.resolve(key)
|
|
62
|
+
mentry && mentry.zone == zone
|
|
63
|
+
rescue Textus::Error
|
|
64
|
+
false
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Application
|
|
5
|
+
module Reads
|
|
6
|
+
# For one key, joins every audit-log row with the git commit (sha,
|
|
7
|
+
# author, date, subject) that introduced the file state at that audit
|
|
8
|
+
# row. Falls back to `git => nil` when not in a git repo or when the
|
|
9
|
+
# file is untracked.
|
|
10
|
+
class Blame
|
|
11
|
+
def initialize(ctx:)
|
|
12
|
+
@ctx = ctx
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(key:, limit: nil)
|
|
16
|
+
audit_rows = Textus::Composition.audit(@ctx).call(key: key, limit: limit)
|
|
17
|
+
path = resolve_path(key)
|
|
18
|
+
return audit_rows.map { |r| r.merge("git" => nil) } unless git_tracked?(path)
|
|
19
|
+
|
|
20
|
+
audit_rows.map { |r| r.merge("git" => git_commit_at(path, timestamp: r["ts"])) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def resolve_path(key)
|
|
26
|
+
mentry, path, = @ctx.store.manifest.resolve(key)
|
|
27
|
+
# Nested entries resolve to a file under the entry path; leaf entries
|
|
28
|
+
# already have a fully-resolved path. Either way `path` is what git
|
|
29
|
+
# needs to know about.
|
|
30
|
+
path || Textus::Key::Path.resolve(@ctx.store.manifest, mentry)
|
|
31
|
+
rescue Textus::Error
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def git_tracked?(path)
|
|
36
|
+
return false if path.nil?
|
|
37
|
+
return false unless File.exist?(path)
|
|
38
|
+
return false unless git_repo?
|
|
39
|
+
|
|
40
|
+
_out, _err, status = Open3.capture3(
|
|
41
|
+
"git", "ls-files", "--error-unmatch", path,
|
|
42
|
+
chdir: @ctx.store.root
|
|
43
|
+
)
|
|
44
|
+
status.success?
|
|
45
|
+
rescue Errno::ENOENT
|
|
46
|
+
false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def git_repo?
|
|
50
|
+
# Walk up from store root to find a .git directory.
|
|
51
|
+
dir = @ctx.store.root
|
|
52
|
+
loop do
|
|
53
|
+
return true if File.directory?(File.join(dir, ".git"))
|
|
54
|
+
|
|
55
|
+
parent = File.dirname(dir)
|
|
56
|
+
return false if parent == dir
|
|
57
|
+
|
|
58
|
+
dir = parent
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def git_commit_at(path, timestamp:)
|
|
63
|
+
args = ["git", "log", "-1"]
|
|
64
|
+
args << "--before=#{timestamp}" if timestamp
|
|
65
|
+
args += ["--format=%H%x09%an%x09%aI%x09%s", "--", path]
|
|
66
|
+
out, _err, status = Open3.capture3(*args, chdir: @ctx.store.root)
|
|
67
|
+
return nil unless status.success?
|
|
68
|
+
|
|
69
|
+
sha, author, date, subject = out.strip.split("\t", 4)
|
|
70
|
+
return nil if sha.nil? || sha.empty?
|
|
71
|
+
|
|
72
|
+
{ "sha" => sha, "author" => author, "date" => date, "subject" => subject }
|
|
73
|
+
rescue Errno::ENOENT
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Application
|
|
5
|
+
module Reads
|
|
6
|
+
# Per-entry freshness report. Walks every entry declared in the manifest,
|
|
7
|
+
# consults `policies_for(key)` for a refresh policy, and reports the
|
|
8
|
+
# current status. Status is one of :fresh, :stale, :never_refreshed, or
|
|
9
|
+
# :no_policy.
|
|
10
|
+
class Freshness
|
|
11
|
+
def initialize(ctx:, evaluator: Textus::Domain::Freshness::Evaluator)
|
|
12
|
+
@ctx = ctx
|
|
13
|
+
@evaluator = evaluator
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(prefix: nil, zone: nil)
|
|
17
|
+
rows = []
|
|
18
|
+
@ctx.store.manifest.entries.each do |mentry|
|
|
19
|
+
next if prefix && !mentry.key.start_with?(prefix)
|
|
20
|
+
next if zone && mentry.zone != zone
|
|
21
|
+
|
|
22
|
+
rows << row_for(mentry)
|
|
23
|
+
end
|
|
24
|
+
rows
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def row_for(mentry)
|
|
30
|
+
set = @ctx.store.manifest.policies_for(mentry.key)
|
|
31
|
+
refresh = set.refresh
|
|
32
|
+
envelope = safe_get(mentry.key)
|
|
33
|
+
last = envelope&.dig("_meta", "last_refreshed_at")
|
|
34
|
+
|
|
35
|
+
return base_row(mentry, last).merge(status: :no_policy) if refresh.nil?
|
|
36
|
+
|
|
37
|
+
fp = refresh.to_freshness_policy
|
|
38
|
+
verdict = @evaluator.call(fp, envelope || {}, now: @ctx.now)
|
|
39
|
+
status = if verdict.fresh? then :fresh
|
|
40
|
+
elsif last.nil? then :never_refreshed
|
|
41
|
+
else :stale
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
base_row(mentry, last).merge(
|
|
45
|
+
ttl_seconds: fp.ttl_seconds,
|
|
46
|
+
on_stale: fp.on_stale,
|
|
47
|
+
status: status,
|
|
48
|
+
next_due_at: next_due(last, fp.ttl_seconds),
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def base_row(mentry, last)
|
|
53
|
+
{
|
|
54
|
+
key: mentry.key,
|
|
55
|
+
zone: mentry.zone,
|
|
56
|
+
last_refreshed_at: last,
|
|
57
|
+
age_seconds: last ? (@ctx.now - Time.parse(last)).to_i : nil,
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Returns the raw envelope or nil. Nested entries (mentry.key is a
|
|
62
|
+
# prefix, not a leaf) and missing files both resolve to nil.
|
|
63
|
+
def safe_get(key)
|
|
64
|
+
@ctx.store.reader.read_raw_envelope(key)
|
|
65
|
+
rescue Textus::Error
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def next_due(last, ttl)
|
|
70
|
+
return nil if last.nil? || ttl.nil?
|
|
71
|
+
|
|
72
|
+
(Time.parse(last) + ttl).utc.iso8601
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Reads
|
|
4
|
+
class Get
|
|
5
|
+
def initialize(ctx:, orchestrator:, evaluator: Textus::Domain::Freshness::Evaluator)
|
|
6
|
+
@ctx = ctx
|
|
7
|
+
@orchestrator = orchestrator
|
|
8
|
+
@evaluator = evaluator
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(key)
|
|
12
|
+
envelope = @ctx.store.reader.read_raw_envelope(key)
|
|
13
|
+
return nil if envelope.nil?
|
|
14
|
+
|
|
15
|
+
policy_set = @ctx.store.manifest.policies_for(key)
|
|
16
|
+
refresh_policy = policy_set.refresh
|
|
17
|
+
return annotate_fresh(envelope) if refresh_policy.nil?
|
|
18
|
+
|
|
19
|
+
policy = refresh_policy.to_freshness_policy
|
|
20
|
+
verdict = @evaluator.call(policy, envelope, now: @ctx.now)
|
|
21
|
+
|
|
22
|
+
return annotate(envelope, verdict, refreshing: false) if verdict.fresh?
|
|
23
|
+
|
|
24
|
+
action = policy.decide(verdict)
|
|
25
|
+
outcome = @orchestrator.execute(action, key: key)
|
|
26
|
+
|
|
27
|
+
case outcome
|
|
28
|
+
when Textus::Domain::Outcome::Skipped
|
|
29
|
+
annotate(envelope, verdict, refreshing: false)
|
|
30
|
+
when Textus::Domain::Outcome::Refreshed
|
|
31
|
+
fresh_verdict = @evaluator.call(policy, outcome.envelope, now: @ctx.now)
|
|
32
|
+
annotate(outcome.envelope, fresh_verdict, refreshing: false)
|
|
33
|
+
when Textus::Domain::Outcome::Detached
|
|
34
|
+
annotate(envelope, verdict, refreshing: true)
|
|
35
|
+
when Textus::Domain::Outcome::Failed
|
|
36
|
+
annotate(envelope, verdict, refreshing: false, refresh_error: outcome.error.message)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def annotate(envelope, verdict, refreshing:, refresh_error: nil)
|
|
43
|
+
envelope = envelope.dup
|
|
44
|
+
envelope["stale"] = verdict.stale?
|
|
45
|
+
envelope["stale_reason"] = verdict.reason
|
|
46
|
+
envelope["refreshing"] = refreshing
|
|
47
|
+
envelope["refresh_error"] = refresh_error if refresh_error
|
|
48
|
+
envelope
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# No refresh policy applies to this key — treat as fresh, skip evaluation/orchestration.
|
|
52
|
+
def annotate_fresh(envelope)
|
|
53
|
+
envelope = envelope.dup
|
|
54
|
+
envelope["stale"] = false
|
|
55
|
+
envelope["stale_reason"] = nil
|
|
56
|
+
envelope["refreshing"] = false
|
|
57
|
+
envelope
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Reads
|
|
4
|
+
# For one key, surface every matching policy block along with the
|
|
5
|
+
# per-slot effective value (which loses ties win-by-specificity).
|
|
6
|
+
class PolicyExplain
|
|
7
|
+
def initialize(ctx:)
|
|
8
|
+
@ctx = ctx
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(key:)
|
|
12
|
+
policies = @ctx.store.manifest.policies
|
|
13
|
+
matching = policies.explain(key)
|
|
14
|
+
winners = policies.for(key)
|
|
15
|
+
|
|
16
|
+
{
|
|
17
|
+
key: key,
|
|
18
|
+
matched_blocks: matching.map do |b|
|
|
19
|
+
{
|
|
20
|
+
match: b.match,
|
|
21
|
+
refresh: !b.refresh.nil?,
|
|
22
|
+
handler_allowlist: !b.handler_allowlist.nil?,
|
|
23
|
+
promote: !b.promote.nil?,
|
|
24
|
+
}
|
|
25
|
+
end,
|
|
26
|
+
effective: {
|
|
27
|
+
refresh: winners.refresh && {
|
|
28
|
+
ttl_seconds: winners.refresh.ttl_seconds,
|
|
29
|
+
on_stale: winners.refresh.on_stale,
|
|
30
|
+
},
|
|
31
|
+
handler_allowlist: winners.handler_allowlist&.handlers,
|
|
32
|
+
promote_requires: winners.promote&.requires,
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Refresh
|
|
4
|
+
module All
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def call(ctx, prefix: nil, zone: nil)
|
|
8
|
+
worker = Textus::Composition.refresh_worker(ctx)
|
|
9
|
+
|
|
10
|
+
stale_rows = ctx.store.stale(prefix: prefix, zone: zone)
|
|
11
|
+
refreshed = []
|
|
12
|
+
failed = []
|
|
13
|
+
skipped = []
|
|
14
|
+
|
|
15
|
+
stale_rows.each do |row|
|
|
16
|
+
key = row["key"] || row[:key]
|
|
17
|
+
reason = row["reason"] || row[:reason]
|
|
18
|
+
if reason.to_s.match?(/ttl exceeded|never refreshed/)
|
|
19
|
+
begin
|
|
20
|
+
worker.run(key)
|
|
21
|
+
refreshed << key
|
|
22
|
+
rescue Textus::Error => e
|
|
23
|
+
failed << { "key" => key, "error" => e.message }
|
|
24
|
+
end
|
|
25
|
+
else
|
|
26
|
+
skipped << { "key" => key, "reason" => reason }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
{
|
|
31
|
+
"protocol" => Textus::PROTOCOL,
|
|
32
|
+
"ok" => failed.empty?,
|
|
33
|
+
"refreshed" => refreshed,
|
|
34
|
+
"failed" => failed,
|
|
35
|
+
"skipped" => skipped,
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Refresh
|
|
4
|
+
class Orchestrator
|
|
5
|
+
def initialize(worker:, bus:, store_root:, store: nil, detached_spawner: nil)
|
|
6
|
+
@worker = worker
|
|
7
|
+
@bus = bus
|
|
8
|
+
@store_root = store_root
|
|
9
|
+
@store = store
|
|
10
|
+
@detached_spawner = detached_spawner || default_spawner
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def execute(action, key:)
|
|
14
|
+
case action
|
|
15
|
+
when Textus::Domain::Action::Return then Textus::Domain::Outcome::Skipped.new
|
|
16
|
+
when Textus::Domain::Action::RefreshSync then run_sync(key)
|
|
17
|
+
when Textus::Domain::Action::RefreshTimed then run_timed(action.budget_ms, key)
|
|
18
|
+
else raise ArgumentError.new("unknown action: #{action.inspect}")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def run_sync(key)
|
|
25
|
+
envelope = @worker.run(key)
|
|
26
|
+
Textus::Domain::Outcome::Refreshed.new(envelope: envelope)
|
|
27
|
+
rescue Textus::Error => e
|
|
28
|
+
Textus::Domain::Outcome::Failed.new(error: e)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def run_timed(budget_ms, key)
|
|
32
|
+
unless Textus::Infra::Refresh::Detached.supported?
|
|
33
|
+
return Textus::Domain::Outcome::Failed.new(
|
|
34
|
+
error: Textus::UsageError.new("timed_sync requires fork (Unix only)"),
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
result = nil
|
|
39
|
+
thread = Thread.new do
|
|
40
|
+
result = @worker.run(key)
|
|
41
|
+
rescue Textus::Error => e
|
|
42
|
+
result = e
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
thread.join(budget_ms / 1000.0)
|
|
46
|
+
|
|
47
|
+
if thread.alive?
|
|
48
|
+
thread.kill
|
|
49
|
+
store_view = @store ? Textus::Store::View.new(@store) : nil
|
|
50
|
+
payload = { key: key, started_at: Time.now.utc.iso8601, budget_ms: budget_ms }
|
|
51
|
+
payload[:store] = store_view if store_view
|
|
52
|
+
@bus.publish(:refresh_detached, **payload)
|
|
53
|
+
@detached_spawner.call(store_root: @store_root, key: key)
|
|
54
|
+
Textus::Domain::Outcome::Detached.new
|
|
55
|
+
elsif result.is_a?(Textus::Error)
|
|
56
|
+
Textus::Domain::Outcome::Failed.new(error: result)
|
|
57
|
+
else
|
|
58
|
+
Textus::Domain::Outcome::Refreshed.new(envelope: result)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def default_spawner
|
|
63
|
+
Textus::Infra::Refresh::Detached.method(:spawn)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
require "timeout"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Application
|
|
5
|
+
module Refresh
|
|
6
|
+
class Worker
|
|
7
|
+
FETCH_TIMEOUT_SECONDS = 30
|
|
8
|
+
|
|
9
|
+
def initialize(ctx:, bus:)
|
|
10
|
+
@ctx = ctx
|
|
11
|
+
@bus = bus
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def run(key)
|
|
15
|
+
mentry, path, = @ctx.store.manifest.resolve(key)
|
|
16
|
+
raise UsageError.new("no intake declared for '#{key}'") unless mentry.intake_handler
|
|
17
|
+
|
|
18
|
+
before_etag = File.exist?(path) ? Etag.for_file(path) : nil
|
|
19
|
+
result = fetch_with_bus(key, mentry)
|
|
20
|
+
persist_and_notify(key, mentry, result, before_etag)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def read_view
|
|
26
|
+
Store::View.new(@ctx.store)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def fetch_with_bus(key, mentry)
|
|
30
|
+
callable = @ctx.store.registry.rpc_callable(:intake, mentry.intake_handler)
|
|
31
|
+
@bus.publish(:refresh_began, store: read_view, key: key, mode: :sync,
|
|
32
|
+
correlation_id: @ctx.correlation_id)
|
|
33
|
+
call_intake(key, mentry, callable)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def call_intake(key, mentry, callable)
|
|
37
|
+
Timeout.timeout(FETCH_TIMEOUT_SECONDS) do
|
|
38
|
+
callable.call(store: @ctx, config: mentry.intake_config, args: {})
|
|
39
|
+
end
|
|
40
|
+
rescue Timeout::Error
|
|
41
|
+
@bus.publish(:refresh_failed, store: read_view, key: key, error_class: "Timeout::Error",
|
|
42
|
+
error_message: "intake '#{mentry.intake_handler}' exceeded #{FETCH_TIMEOUT_SECONDS}s",
|
|
43
|
+
correlation_id: @ctx.correlation_id)
|
|
44
|
+
raise UsageError.new("intake '#{mentry.intake_handler}' exceeded #{FETCH_TIMEOUT_SECONDS}s timeout")
|
|
45
|
+
rescue Textus::Error => e
|
|
46
|
+
@bus.publish(:refresh_failed, store: read_view, key: key, error_class: e.class.name,
|
|
47
|
+
error_message: e.message, correlation_id: @ctx.correlation_id)
|
|
48
|
+
raise
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
@bus.publish(:refresh_failed, store: read_view, key: key, error_class: e.class.name,
|
|
51
|
+
error_message: e.message, correlation_id: @ctx.correlation_id)
|
|
52
|
+
raise UsageError.new("intake '#{mentry.intake_handler}' raised: #{e.class}: #{e.message}")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def persist_and_notify(key, mentry, result, before_etag)
|
|
56
|
+
normalized = Textus::Refresh.normalize_action_result(result, format: mentry.format)
|
|
57
|
+
envelope = @ctx.store.put(
|
|
58
|
+
key,
|
|
59
|
+
meta: normalized[:meta], body: normalized[:body], content: normalized[:content],
|
|
60
|
+
as: @ctx.role, suppress_events: true
|
|
61
|
+
)
|
|
62
|
+
change = detect_change(before_etag, envelope)
|
|
63
|
+
unless change == :unchanged
|
|
64
|
+
@bus.publish(:refreshed, store: read_view, key: key, envelope: envelope, change: change,
|
|
65
|
+
correlation_id: @ctx.correlation_id)
|
|
66
|
+
end
|
|
67
|
+
envelope
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def detect_change(before_etag, envelope)
|
|
71
|
+
if before_etag.nil? then :created
|
|
72
|
+
elsif envelope["etag"] == before_etag then :unchanged
|
|
73
|
+
else :updated
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Writes
|
|
4
|
+
class Accept
|
|
5
|
+
def initialize(ctx:, bus:)
|
|
6
|
+
@ctx = ctx
|
|
7
|
+
@bus = bus
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(pending_key)
|
|
11
|
+
raise ProposalError.new("only human role can accept proposals; got '#{@ctx.role}'") unless @ctx.role == "human"
|
|
12
|
+
|
|
13
|
+
env = @ctx.store.get(pending_key)
|
|
14
|
+
proposal = env["_meta"]["proposal"] or raise ProposalError.new("entry has no proposal block: #{pending_key}")
|
|
15
|
+
target = proposal["target_key"] or raise ProposalError.new("proposal missing target_key")
|
|
16
|
+
action = proposal["action"] || "put"
|
|
17
|
+
|
|
18
|
+
case action
|
|
19
|
+
when "put"
|
|
20
|
+
target_meta = env["_meta"]["frontmatter"] || {}
|
|
21
|
+
target_body = env["body"]
|
|
22
|
+
Composition.writes_put(@ctx).call(target, meta: target_meta, body: target_body)
|
|
23
|
+
when "delete"
|
|
24
|
+
Composition.writes_delete(@ctx).call(target)
|
|
25
|
+
else
|
|
26
|
+
raise ProposalError.new("unknown action: #{action}")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
Composition.writes_delete(@ctx).call(pending_key)
|
|
30
|
+
|
|
31
|
+
store_view = Store::View.new(@ctx.store)
|
|
32
|
+
@bus.publish(:accepted,
|
|
33
|
+
store: store_view,
|
|
34
|
+
key: pending_key,
|
|
35
|
+
target_key: target,
|
|
36
|
+
correlation_id: @ctx.correlation_id)
|
|
37
|
+
|
|
38
|
+
{ "protocol" => PROTOCOL, "accepted" => pending_key, "target_key" => target, "action" => action }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Writes
|
|
4
|
+
class Build
|
|
5
|
+
def initialize(ctx:, bus:)
|
|
6
|
+
@ctx = ctx
|
|
7
|
+
@bus = bus
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(prefix: nil)
|
|
11
|
+
# Delegate to legacy Builder for the materialization/projection logic.
|
|
12
|
+
# Builder fires its own events through @store.fire_event; we do NOT
|
|
13
|
+
# double-fire from here. Full extraction of Builder internals into
|
|
14
|
+
# Writes::Build is deferred to 0.10.0.
|
|
15
|
+
#
|
|
16
|
+
# TODO(0.10.0): propagate @ctx.correlation_id through :built/:published
|
|
17
|
+
# events once Builder internals are extracted into this use case.
|
|
18
|
+
legacy = Textus::Builder.new(@ctx.store)
|
|
19
|
+
legacy.build(prefix: prefix)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Writes
|
|
4
|
+
class Delete
|
|
5
|
+
def initialize(ctx:, bus:)
|
|
6
|
+
@ctx = ctx
|
|
7
|
+
@bus = bus
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(key, if_etag: nil, suppress_events: false)
|
|
11
|
+
@ctx.store.manifest.validate_key!(key)
|
|
12
|
+
mentry, = @ctx.store.manifest.resolve(key)
|
|
13
|
+
|
|
14
|
+
unless @ctx.can_write?(mentry.zone)
|
|
15
|
+
raise WriteForbidden.new(key, mentry.zone,
|
|
16
|
+
writers: @ctx.store.manifest.zone_writers(mentry.zone))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
@ctx.store.writer.delete_envelope_from_disk(
|
|
20
|
+
key, if_etag: if_etag, as: @ctx.role,
|
|
21
|
+
correlation_id: @ctx.correlation_id
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
unless suppress_events
|
|
25
|
+
store_view = Store::View.new(@ctx.store)
|
|
26
|
+
@bus.publish(:deleted,
|
|
27
|
+
store: store_view,
|
|
28
|
+
key: key,
|
|
29
|
+
correlation_id: @ctx.correlation_id)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
{ "protocol" => Textus::PROTOCOL, "ok" => true, "key" => key, "deleted" => true }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|