textus 0.14.4 → 0.18.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/ARCHITECTURE.md +14 -14
- data/CHANGELOG.md +378 -0
- data/README.md +13 -9
- data/SPEC.md +13 -10
- data/docs/conventions.md +11 -0
- data/lib/textus/application/context.rb +25 -7
- data/lib/textus/application/reads/audit.rb +1 -1
- data/lib/textus/application/reads/blame.rb +3 -1
- data/lib/textus/application/reads/deps.rb +1 -1
- data/lib/textus/application/reads/freshness.rb +12 -3
- data/lib/textus/application/reads/get.rb +38 -33
- data/lib/textus/application/reads/get_or_refresh.rb +51 -0
- data/lib/textus/application/reads/list.rb +3 -1
- data/lib/textus/application/reads/published.rb +1 -1
- data/lib/textus/application/reads/rdeps.rb +1 -1
- data/lib/textus/application/reads/schema_envelope.rb +3 -1
- data/lib/textus/application/reads/stale.rb +1 -1
- data/lib/textus/application/reads/uid.rb +1 -1
- data/lib/textus/application/reads/validate_all.rb +6 -1
- data/lib/textus/application/reads/validator.rb +84 -0
- data/lib/textus/application/reads/where.rb +4 -1
- data/lib/textus/application/refresh/all.rb +8 -1
- data/lib/textus/application/refresh/orchestrator.rb +11 -3
- data/lib/textus/application/refresh/worker.rb +27 -20
- data/lib/textus/application/writes/accept.rb +12 -12
- data/lib/textus/application/writes/build.rb +3 -4
- data/lib/textus/application/writes/delete.rb +10 -15
- data/lib/textus/application/writes/envelope_io.rb +106 -0
- data/lib/textus/application/writes/mv.rb +25 -27
- data/lib/textus/application/writes/publish.rb +8 -9
- data/lib/textus/application/writes/put.rb +12 -16
- data/lib/textus/application/writes/reject.rb +10 -10
- data/lib/textus/builder/pipeline.rb +8 -1
- data/lib/textus/cli/group/hook.rb +1 -3
- data/lib/textus/cli/group/key.rb +1 -4
- data/lib/textus/cli/group/refresh.rb +1 -2
- data/lib/textus/cli/group/rule.rb +1 -3
- data/lib/textus/cli/group/schema.rb +1 -5
- data/lib/textus/cli/group.rb +12 -16
- data/lib/textus/cli/verb/accept.rb +3 -1
- data/lib/textus/cli/verb/audit.rb +3 -1
- data/lib/textus/cli/verb/blame.rb +3 -1
- data/lib/textus/cli/verb/build.rb +4 -2
- data/lib/textus/cli/verb/delete.rb +3 -1
- data/lib/textus/cli/verb/deps.rb +3 -1
- data/lib/textus/cli/verb/doctor.rb +2 -0
- data/lib/textus/cli/verb/freshness.rb +3 -1
- data/lib/textus/cli/verb/get.rb +3 -1
- data/lib/textus/cli/verb/hook_run.rb +3 -0
- data/lib/textus/cli/verb/hooks.rb +3 -0
- data/lib/textus/cli/verb/init.rb +2 -0
- data/lib/textus/cli/verb/intro.rb +2 -0
- data/lib/textus/cli/verb/key_normalize.rb +3 -0
- data/lib/textus/cli/verb/list.rb +3 -1
- data/lib/textus/cli/verb/mv.rb +4 -1
- data/lib/textus/cli/verb/published.rb +3 -1
- data/lib/textus/cli/verb/put.rb +3 -1
- data/lib/textus/cli/verb/rdeps.rb +3 -1
- data/lib/textus/cli/verb/refresh.rb +1 -1
- data/lib/textus/cli/verb/refresh_stale.rb +4 -1
- data/lib/textus/cli/verb/reject.rb +3 -1
- data/lib/textus/cli/verb/rule_explain.rb +4 -1
- data/lib/textus/cli/verb/rule_list.rb +3 -0
- data/lib/textus/cli/verb/schema.rb +4 -1
- data/lib/textus/cli/verb/schema_diff.rb +3 -0
- data/lib/textus/cli/verb/schema_init.rb +3 -0
- data/lib/textus/cli/verb/schema_migrate.rb +3 -0
- data/lib/textus/cli/verb/uid.rb +4 -1
- data/lib/textus/cli/verb/where.rb +3 -1
- data/lib/textus/cli/verb.rb +30 -0
- data/lib/textus/cli.rb +40 -35
- data/lib/textus/doctor/check/audit_log.rb +1 -1
- data/lib/textus/doctor/check/hooks.rb +3 -1
- data/lib/textus/doctor/check/intake_registration.rb +3 -3
- data/lib/textus/doctor/check/schema_violations.rb +1 -1
- data/lib/textus/doctor/check/sentinels.rb +2 -2
- data/lib/textus/domain/freshness/evaluator.rb +1 -1
- data/lib/textus/domain/freshness/policy.rb +1 -1
- data/lib/textus/domain/freshness/verdict.rb +1 -1
- data/lib/textus/domain/freshness.rb +40 -0
- data/lib/textus/domain/policy/predicates/schema_valid.rb +2 -2
- data/lib/textus/{store → domain}/sentinel.rb +1 -1
- data/lib/textus/{store → domain}/staleness/generator_check.rb +1 -1
- data/lib/textus/{store → domain}/staleness/intake_check.rb +1 -1
- data/lib/textus/{store → domain}/staleness.rb +1 -1
- data/lib/textus/entry/json.rb +1 -1
- data/lib/textus/entry/markdown.rb +1 -1
- data/lib/textus/entry/yaml.rb +1 -1
- data/lib/textus/envelope.rb +7 -3
- data/lib/textus/errors.rb +19 -0
- data/lib/textus/hooks/builtin.rb +6 -6
- data/lib/textus/hooks/dispatcher.rb +17 -9
- data/lib/textus/hooks/loader.rb +20 -17
- data/lib/textus/hooks/registry.rb +4 -0
- data/lib/textus/{store → infra}/audit_log.rb +1 -1
- data/lib/textus/infra/audit_subscriber.rb +43 -0
- data/lib/textus/infra/publisher.rb +3 -3
- data/lib/textus/infra/storage/file_store.rb +26 -0
- data/lib/textus/init.rb +11 -9
- data/lib/textus/manifest/resolution.rb +5 -0
- data/lib/textus/manifest.rb +4 -3
- data/lib/textus/migrate_keys.rb +1 -1
- data/lib/textus/operations.rb +84 -17
- data/lib/textus/projection.rb +16 -11
- data/lib/textus/refresh.rb +1 -1
- data/lib/textus/schema/tools.rb +5 -5
- data/lib/textus/schemas.rb +46 -0
- data/lib/textus/store.rb +12 -49
- data/lib/textus/uid.rb +18 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus.rb +17 -1
- metadata +15 -13
- data/lib/textus/hooks/dsl.rb +0 -11
- data/lib/textus/operations/reads.rb +0 -39
- data/lib/textus/operations/refresh.rb +0 -27
- data/lib/textus/operations/writes.rb +0 -21
- data/lib/textus/store/reader.rb +0 -69
- data/lib/textus/store/validator.rb +0 -82
- data/lib/textus/store/writer.rb +0 -102
|
@@ -15,7 +15,7 @@ module Textus
|
|
|
15
15
|
|
|
16
16
|
def call(prefix: nil, zone: nil)
|
|
17
17
|
rows = []
|
|
18
|
-
@ctx.
|
|
18
|
+
@ctx.manifest.entries.each do |mentry|
|
|
19
19
|
next if prefix && !mentry.key.start_with?(prefix)
|
|
20
20
|
next if zone && mentry.zone != zone
|
|
21
21
|
|
|
@@ -27,7 +27,7 @@ module Textus
|
|
|
27
27
|
private
|
|
28
28
|
|
|
29
29
|
def row_for(mentry)
|
|
30
|
-
set = @ctx.
|
|
30
|
+
set = @ctx.manifest.rules_for(mentry.key)
|
|
31
31
|
refresh = set.refresh
|
|
32
32
|
envelope = safe_get(mentry.key)
|
|
33
33
|
last = envelope&.meta&.dig("last_refreshed_at")
|
|
@@ -61,7 +61,16 @@ module Textus
|
|
|
61
61
|
# Returns the raw envelope or nil. Nested entries (mentry.key is a
|
|
62
62
|
# prefix, not a leaf) and missing files both resolve to nil.
|
|
63
63
|
def safe_get(key)
|
|
64
|
-
@ctx.
|
|
64
|
+
res = @ctx.manifest.resolve(key)
|
|
65
|
+
return nil unless @ctx.file_store.exists?(res.path)
|
|
66
|
+
|
|
67
|
+
raw = @ctx.file_store.read(res.path)
|
|
68
|
+
parsed = Entry.for_format(res.entry.format).parse(raw, path: res.path)
|
|
69
|
+
Envelope.build(
|
|
70
|
+
key: key, mentry: res.entry, path: res.path,
|
|
71
|
+
meta: parsed["_meta"], body: parsed["body"],
|
|
72
|
+
etag: Etag.for_bytes(raw), content: parsed["content"]
|
|
73
|
+
)
|
|
65
74
|
rescue Textus::Error
|
|
66
75
|
nil
|
|
67
76
|
end
|
|
@@ -1,58 +1,63 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Application
|
|
3
3
|
module Reads
|
|
4
|
+
# Pure read: returns the on-disk envelope annotated with a freshness
|
|
5
|
+
# verdict. Never triggers refresh; never invokes the orchestrator.
|
|
6
|
+
#
|
|
7
|
+
# For interactive reads that want refresh-on-stale, use
|
|
8
|
+
# `Reads::GetOrRefresh`, which composes this with the orchestrator.
|
|
4
9
|
class Get
|
|
5
|
-
def initialize(ctx:,
|
|
6
|
-
@ctx
|
|
7
|
-
@
|
|
8
|
-
@evaluator = evaluator
|
|
10
|
+
def initialize(ctx:, evaluator: Textus::Domain::Freshness::Evaluator)
|
|
11
|
+
@ctx = ctx
|
|
12
|
+
@evaluator = evaluator
|
|
9
13
|
end
|
|
10
14
|
|
|
11
15
|
def call(key)
|
|
12
|
-
envelope =
|
|
16
|
+
envelope = read_raw_envelope(key)
|
|
13
17
|
return nil if envelope.nil?
|
|
14
|
-
return annotate_fresh(envelope) if @ctx.bypass_freshness?
|
|
15
18
|
|
|
16
|
-
policy_set = @ctx.
|
|
19
|
+
policy_set = @ctx.manifest.rules_for(key)
|
|
17
20
|
refresh_policy = policy_set.refresh
|
|
18
21
|
return annotate_fresh(envelope) if refresh_policy.nil?
|
|
19
22
|
|
|
20
23
|
policy = refresh_policy.to_freshness_policy
|
|
21
24
|
verdict = @evaluator.call(policy, envelope, now: @ctx.now)
|
|
22
25
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
annotate(envelope, verdict, refreshing: true)
|
|
36
|
-
when Textus::Domain::Outcome::Failed
|
|
37
|
-
annotate(envelope, verdict, refreshing: false, refresh_error: outcome.error.message)
|
|
38
|
-
end
|
|
26
|
+
envelope.with(freshness: Textus::Domain::Freshness.build(
|
|
27
|
+
stale: verdict.stale?,
|
|
28
|
+
reason: verdict.reason,
|
|
29
|
+
refreshing: false,
|
|
30
|
+
))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Strict variant: raises UnknownKey when the entry is missing.
|
|
34
|
+
# Used by consumers (e.g. Validator) that need to distinguish absence
|
|
35
|
+
# from emptiness.
|
|
36
|
+
def get(key)
|
|
37
|
+
call(key) || raise(UnknownKey.new(key, suggestions: @ctx.manifest.suggestions_for(key)))
|
|
39
38
|
end
|
|
40
39
|
|
|
41
40
|
private
|
|
42
41
|
|
|
43
|
-
def
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
42
|
+
def read_raw_envelope(key)
|
|
43
|
+
res = @ctx.manifest.resolve(key)
|
|
44
|
+
mentry = res.entry
|
|
45
|
+
path = res.path
|
|
46
|
+
return nil unless @ctx.file_store.exists?(path)
|
|
47
|
+
|
|
48
|
+
raw = @ctx.file_store.read(path)
|
|
49
|
+
parsed = Entry.for_format(mentry.format).parse(raw, path: path)
|
|
50
|
+
Envelope.build(
|
|
51
|
+
key: key, mentry: mentry, path: path,
|
|
52
|
+
meta: parsed["_meta"], body: parsed["body"],
|
|
53
|
+
etag: Etag.for_bytes(raw), content: parsed["content"]
|
|
54
|
+
)
|
|
51
55
|
end
|
|
52
56
|
|
|
53
|
-
# No refresh policy applies to this key — treat as fresh, skip evaluation/orchestration.
|
|
54
57
|
def annotate_fresh(envelope)
|
|
55
|
-
envelope.with(freshness:
|
|
58
|
+
envelope.with(freshness: Textus::Domain::Freshness.build(
|
|
59
|
+
stale: false, reason: nil, refreshing: false,
|
|
60
|
+
))
|
|
56
61
|
end
|
|
57
62
|
end
|
|
58
63
|
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Reads
|
|
4
|
+
# Composes pure `Reads::Get` with the refresh orchestrator: runs Get
|
|
5
|
+
# to obtain the envelope and freshness verdict, then if the verdict
|
|
6
|
+
# is stale and the rule's `on_stale` policy demands action, hands
|
|
7
|
+
# off to the orchestrator. Use for interactive reads where the
|
|
8
|
+
# caller wants the freshest obtainable envelope.
|
|
9
|
+
#
|
|
10
|
+
# Pure reads (build, projection, schema tooling) should use
|
|
11
|
+
# `Reads::Get` directly; it has no orchestrator dependency.
|
|
12
|
+
class GetOrRefresh
|
|
13
|
+
def initialize(ctx:, get:, orchestrator:)
|
|
14
|
+
@ctx = ctx
|
|
15
|
+
@get = get
|
|
16
|
+
@orchestrator = orchestrator
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(key)
|
|
20
|
+
envelope = @get.call(key)
|
|
21
|
+
return nil if envelope.nil?
|
|
22
|
+
return envelope unless envelope.freshness&.stale
|
|
23
|
+
|
|
24
|
+
policy_set = @ctx.store.manifest.rules_for(key)
|
|
25
|
+
refresh_policy = policy_set.refresh
|
|
26
|
+
return envelope if refresh_policy.nil?
|
|
27
|
+
|
|
28
|
+
policy = refresh_policy.to_freshness_policy
|
|
29
|
+
verdict = Textus::Domain::Freshness::Verdict.stale(envelope.freshness.reason)
|
|
30
|
+
action = policy.decide(verdict)
|
|
31
|
+
outcome = @orchestrator.execute(action, key: key)
|
|
32
|
+
|
|
33
|
+
case outcome
|
|
34
|
+
when Textus::Domain::Outcome::Skipped
|
|
35
|
+
envelope
|
|
36
|
+
when Textus::Domain::Outcome::Refreshed
|
|
37
|
+
outcome.envelope.with(
|
|
38
|
+
freshness: Textus::Domain::Freshness.build(stale: false, reason: nil, refreshing: false),
|
|
39
|
+
)
|
|
40
|
+
when Textus::Domain::Outcome::Detached
|
|
41
|
+
envelope.with(freshness: envelope.freshness.with(refreshing: true))
|
|
42
|
+
when Textus::Domain::Outcome::Failed
|
|
43
|
+
envelope.with(
|
|
44
|
+
freshness: envelope.freshness.with(refresh_error: outcome.error.message),
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -7,7 +7,9 @@ module Textus
|
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def call(prefix: nil, zone: nil)
|
|
10
|
-
@ctx.
|
|
10
|
+
rows = @ctx.manifest.enumerate(prefix: prefix)
|
|
11
|
+
rows = rows.select { |r| r[:manifest_entry].zone == zone } if zone
|
|
12
|
+
rows.map { |row| { "key" => row[:key], "zone" => row[:manifest_entry].zone, "path" => row[:path] } }
|
|
11
13
|
end
|
|
12
14
|
end
|
|
13
15
|
end
|
|
@@ -7,7 +7,9 @@ module Textus
|
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def call(key)
|
|
10
|
-
@ctx.
|
|
10
|
+
mentry = @ctx.manifest.resolve(key).entry
|
|
11
|
+
schema = @ctx.schemas.fetch_or_nil(mentry.schema)
|
|
12
|
+
{ "protocol" => PROTOCOL, "key" => key, "schema_ref" => mentry.schema, "schema" => schema&.to_h }
|
|
11
13
|
end
|
|
12
14
|
end
|
|
13
15
|
end
|
|
@@ -7,7 +7,12 @@ module Textus
|
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def call
|
|
10
|
-
|
|
10
|
+
Validator.new(
|
|
11
|
+
reader: Get.new(ctx: @ctx),
|
|
12
|
+
manifest: @ctx.manifest,
|
|
13
|
+
audit_log: @ctx.audit_log,
|
|
14
|
+
schema_for: ->(name) { @ctx.schemas.fetch_or_nil(name) },
|
|
15
|
+
).call
|
|
11
16
|
end
|
|
12
17
|
end
|
|
13
18
|
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Reads
|
|
4
|
+
class Validator
|
|
5
|
+
def initialize(reader:, manifest:, audit_log:, schema_for:)
|
|
6
|
+
@reader = reader
|
|
7
|
+
@manifest = manifest
|
|
8
|
+
@audit_log = audit_log
|
|
9
|
+
@schema_for = schema_for
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
violations = []
|
|
14
|
+
check_content_violations(violations)
|
|
15
|
+
check_role_authority_violations(violations)
|
|
16
|
+
{ "protocol" => PROTOCOL, "ok" => violations.empty?, "violations" => violations }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def check_content_violations(violations)
|
|
22
|
+
@manifest.enumerate.each do |row|
|
|
23
|
+
key = row[:key]
|
|
24
|
+
mentry = row[:manifest_entry]
|
|
25
|
+
env = fetch_envelope(key, violations) or next
|
|
26
|
+
schema = mentry.schema && @schema_for.call(mentry.schema)
|
|
27
|
+
next unless schema
|
|
28
|
+
|
|
29
|
+
begin
|
|
30
|
+
validate_schema!(schema, env, mentry.format)
|
|
31
|
+
rescue Textus::Error => e
|
|
32
|
+
violations << { "key" => key, "code" => e.code, "message" => e.message }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def check_role_authority_violations(violations)
|
|
38
|
+
@manifest.enumerate.each do |row|
|
|
39
|
+
mentry = row[:manifest_entry]
|
|
40
|
+
next unless mentry.schema
|
|
41
|
+
|
|
42
|
+
schema = @schema_for.call(mentry.schema)
|
|
43
|
+
next unless schema
|
|
44
|
+
|
|
45
|
+
env = begin
|
|
46
|
+
@reader.get(row[:key])
|
|
47
|
+
rescue StandardError
|
|
48
|
+
next
|
|
49
|
+
end
|
|
50
|
+
append_authority_violations(violations, row[:key], env, schema)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def append_authority_violations(violations, key, env, schema)
|
|
55
|
+
last_writer = @audit_log.last_writer_for(key)
|
|
56
|
+
return if last_writer.nil?
|
|
57
|
+
|
|
58
|
+
env.meta.each_key do |field|
|
|
59
|
+
owner = schema.maintained_by(field)
|
|
60
|
+
next if owner.nil? || last_writer == owner || last_writer == "human"
|
|
61
|
+
|
|
62
|
+
violations << { "key" => key, "code" => "role_authority",
|
|
63
|
+
"field" => field, "expected" => owner, "last_writer" => last_writer }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def fetch_envelope(key, violations)
|
|
68
|
+
@reader.get(key)
|
|
69
|
+
rescue Textus::Error => e
|
|
70
|
+
violations << { "key" => key, "code" => e.code, "message" => e.message }
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def validate_schema!(schema, envelope, format)
|
|
75
|
+
payload = case format
|
|
76
|
+
when "json", "yaml" then envelope.content || {}
|
|
77
|
+
else envelope.meta || {}
|
|
78
|
+
end
|
|
79
|
+
schema.validate!(payload)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -7,7 +7,10 @@ module Textus
|
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def call(key)
|
|
10
|
-
@ctx.
|
|
10
|
+
res = @ctx.manifest.resolve(key)
|
|
11
|
+
mentry = res.entry
|
|
12
|
+
path = res.path
|
|
13
|
+
{ "protocol" => PROTOCOL, "key" => key, "zone" => mentry.zone, "owner" => mentry.owner, "path" => path }
|
|
11
14
|
end
|
|
12
15
|
end
|
|
13
16
|
end
|
|
@@ -5,7 +5,14 @@ module Textus
|
|
|
5
5
|
module_function
|
|
6
6
|
|
|
7
7
|
def call(ctx, prefix: nil, zone: nil)
|
|
8
|
-
|
|
8
|
+
envelope_io = Textus::Application::Writes::EnvelopeIO.new(
|
|
9
|
+
file_store: ctx.file_store,
|
|
10
|
+
manifest: ctx.manifest,
|
|
11
|
+
schemas: ctx.schemas,
|
|
12
|
+
audit_log: ctx.audit_log,
|
|
13
|
+
ctx: ctx,
|
|
14
|
+
)
|
|
15
|
+
worker = Textus::Application::Refresh::Worker.new(ctx: ctx, envelope_io: envelope_io)
|
|
9
16
|
|
|
10
17
|
stale_rows = Textus::Application::Reads::Stale.new(ctx: ctx).call(prefix: prefix, zone: zone)
|
|
11
18
|
refreshed = []
|
|
@@ -2,9 +2,8 @@ module Textus
|
|
|
2
2
|
module Application
|
|
3
3
|
module Refresh
|
|
4
4
|
class Orchestrator
|
|
5
|
-
def initialize(worker:,
|
|
5
|
+
def initialize(worker:, store_root:, store: nil, role: "human", detached_spawner: nil)
|
|
6
6
|
@worker = worker
|
|
7
|
-
@bus = bus
|
|
8
7
|
@store_root = store_root
|
|
9
8
|
@store = store
|
|
10
9
|
@role = role
|
|
@@ -47,10 +46,19 @@ module Textus
|
|
|
47
46
|
|
|
48
47
|
if thread.alive?
|
|
49
48
|
thread.kill
|
|
49
|
+
|
|
50
|
+
# Single-flight: if a sibling process / earlier fork holds the
|
|
51
|
+
# per-leaf lock, don't fork another worker — they're already
|
|
52
|
+
# doing this work.
|
|
53
|
+
probe = Textus::Infra::Refresh::Lock.new(root: @store_root, key: key)
|
|
54
|
+
return Textus::Domain::Outcome::Detached.new unless probe.try_acquire
|
|
55
|
+
|
|
56
|
+
probe.release
|
|
57
|
+
|
|
50
58
|
store_view = @store ? Textus::Application::Context.new(store: @store, role: @role) : nil
|
|
51
59
|
payload = { key: key, started_at: Time.now.utc.iso8601, budget_ms: budget_ms }
|
|
52
60
|
payload[:store] = store_view if store_view
|
|
53
|
-
@bus
|
|
61
|
+
@store&.bus&.publish(:refresh_backgrounded, **payload)
|
|
54
62
|
@detached_spawner.call(store_root: @store_root, key: key)
|
|
55
63
|
Textus::Domain::Outcome::Detached.new
|
|
56
64
|
elsif result.is_a?(Textus::Error)
|
|
@@ -6,17 +6,20 @@ module Textus
|
|
|
6
6
|
class Worker
|
|
7
7
|
FETCH_TIMEOUT_SECONDS = 30
|
|
8
8
|
|
|
9
|
-
def initialize(ctx:,
|
|
9
|
+
def initialize(ctx:, envelope_io:)
|
|
10
10
|
@ctx = ctx
|
|
11
|
-
@
|
|
11
|
+
@envelope_io = envelope_io
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def run(key)
|
|
15
|
-
|
|
15
|
+
res = @ctx.store.manifest.resolve(key)
|
|
16
|
+
mentry = res.entry
|
|
17
|
+
path = res.path
|
|
18
|
+
remaining = res.remaining
|
|
16
19
|
raise UsageError.new("no intake declared for '#{key}'") unless mentry.intake_handler
|
|
17
20
|
|
|
18
21
|
before_etag = File.exist?(path) ? Etag.for_file(path) : nil
|
|
19
|
-
result = fetch_with_bus(key, mentry)
|
|
22
|
+
result = fetch_with_bus(key, mentry, remaining)
|
|
20
23
|
persist_and_notify(key, mentry, result, before_etag)
|
|
21
24
|
end
|
|
22
25
|
|
|
@@ -31,44 +34,48 @@ module Textus
|
|
|
31
34
|
rule&.refresh&.fetch_timeout_seconds || FETCH_TIMEOUT_SECONDS
|
|
32
35
|
end
|
|
33
36
|
|
|
34
|
-
def fetch_with_bus(key, mentry)
|
|
37
|
+
def fetch_with_bus(key, mentry, remaining)
|
|
35
38
|
callable = @ctx.store.registry.rpc_callable(:resolve_intake, mentry.intake_handler)
|
|
36
|
-
@bus.publish(:refresh_started, store: read_view, key: key, mode: :sync,
|
|
37
|
-
|
|
38
|
-
call_intake(key, mentry, callable)
|
|
39
|
+
@ctx.bus.publish(:refresh_started, store: read_view, key: key, mode: :sync,
|
|
40
|
+
correlation_id: @ctx.correlation_id)
|
|
41
|
+
call_intake(key, mentry, callable, remaining)
|
|
39
42
|
end
|
|
40
43
|
|
|
41
|
-
def call_intake(key, mentry, callable)
|
|
44
|
+
def call_intake(key, mentry, callable, remaining)
|
|
42
45
|
timeout = fetch_timeout_for(key)
|
|
43
46
|
Timeout.timeout(timeout) do
|
|
44
|
-
callable.call(
|
|
47
|
+
callable.call(
|
|
48
|
+
store: @ctx,
|
|
49
|
+
config: mentry.intake_config,
|
|
50
|
+
args: { trigger_key: key, leaf_segments: remaining || [] },
|
|
51
|
+
)
|
|
45
52
|
end
|
|
46
53
|
rescue Timeout::Error
|
|
47
|
-
@bus.publish(:refresh_failed, store: read_view, key: key, error_class: "Timeout::Error",
|
|
48
|
-
|
|
49
|
-
|
|
54
|
+
@ctx.bus.publish(:refresh_failed, store: read_view, key: key, error_class: "Timeout::Error",
|
|
55
|
+
error_message: "intake '#{mentry.intake_handler}' exceeded #{timeout}s",
|
|
56
|
+
correlation_id: @ctx.correlation_id)
|
|
50
57
|
raise UsageError.new("intake '#{mentry.intake_handler}' exceeded #{timeout}s timeout")
|
|
51
58
|
rescue Textus::Error => e
|
|
52
|
-
@bus.publish(:refresh_failed, store: read_view, key: key, error_class: e.class.name,
|
|
53
|
-
|
|
59
|
+
@ctx.bus.publish(:refresh_failed, store: read_view, key: key, error_class: e.class.name,
|
|
60
|
+
error_message: e.message, correlation_id: @ctx.correlation_id)
|
|
54
61
|
raise
|
|
55
62
|
rescue StandardError => e
|
|
56
|
-
@bus.publish(:refresh_failed, store: read_view, key: key, error_class: e.class.name,
|
|
57
|
-
|
|
63
|
+
@ctx.bus.publish(:refresh_failed, store: read_view, key: key, error_class: e.class.name,
|
|
64
|
+
error_message: e.message, correlation_id: @ctx.correlation_id)
|
|
58
65
|
raise UsageError.new("intake '#{mentry.intake_handler}' raised: #{e.class}: #{e.message}")
|
|
59
66
|
end
|
|
60
67
|
|
|
61
68
|
def persist_and_notify(key, mentry, result, before_etag)
|
|
62
69
|
normalized = Textus::Refresh.normalize_action_result(result, format: mentry.format)
|
|
63
|
-
envelope = Textus::Application::Writes::Put.new(ctx: @ctx,
|
|
70
|
+
envelope = Textus::Application::Writes::Put.new(ctx: @ctx, envelope_io: @envelope_io).call(
|
|
64
71
|
key,
|
|
65
72
|
meta: normalized[:meta], body: normalized[:body], content: normalized[:content],
|
|
66
73
|
suppress_events: true
|
|
67
74
|
)
|
|
68
75
|
change = detect_change(before_etag, envelope)
|
|
69
76
|
unless change == :unchanged
|
|
70
|
-
@bus.publish(:entry_refreshed, store: read_view, key: key, envelope: envelope, change: change,
|
|
71
|
-
|
|
77
|
+
@ctx.bus.publish(:entry_refreshed, store: read_view, key: key, envelope: envelope, change: change,
|
|
78
|
+
correlation_id: @ctx.correlation_id)
|
|
72
79
|
end
|
|
73
80
|
envelope
|
|
74
81
|
end
|
|
@@ -2,15 +2,15 @@ module Textus
|
|
|
2
2
|
module Application
|
|
3
3
|
module Writes
|
|
4
4
|
class Accept
|
|
5
|
-
def initialize(ctx:,
|
|
5
|
+
def initialize(ctx:, envelope_io:)
|
|
6
6
|
@ctx = ctx
|
|
7
|
-
@
|
|
7
|
+
@envelope_io = envelope_io
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
def call(pending_key)
|
|
11
11
|
raise ProposalError.new("only human role can accept proposals; got '#{@ctx.role}'") unless @ctx.role == "human"
|
|
12
12
|
|
|
13
|
-
env = @ctx.
|
|
13
|
+
env = Textus::Application::Reads::Get.new(ctx: @ctx).call(pending_key)
|
|
14
14
|
proposal = env.meta["proposal"] or raise ProposalError.new("entry has no proposal block: #{pending_key}")
|
|
15
15
|
target = proposal["target_key"] or raise ProposalError.new("proposal missing target_key")
|
|
16
16
|
action = proposal["action"] || "put"
|
|
@@ -23,20 +23,20 @@ module Textus
|
|
|
23
23
|
# target. Not related to the removed intake-handler legacy bridge.
|
|
24
24
|
target_meta = env.meta["frontmatter"] || {}
|
|
25
25
|
target_body = env.body
|
|
26
|
-
Textus::Application::Writes::Put.new(ctx: @ctx,
|
|
26
|
+
Textus::Application::Writes::Put.new(ctx: @ctx, envelope_io: @envelope_io).call(target, meta: target_meta, body: target_body)
|
|
27
27
|
when "delete"
|
|
28
|
-
Textus::Application::Writes::Delete.new(ctx: @ctx,
|
|
28
|
+
Textus::Application::Writes::Delete.new(ctx: @ctx, envelope_io: @envelope_io).call(target)
|
|
29
29
|
else
|
|
30
30
|
raise ProposalError.new("unknown action: #{action}")
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
Textus::Application::Writes::Delete.new(ctx: @ctx,
|
|
33
|
+
Textus::Application::Writes::Delete.new(ctx: @ctx, envelope_io: @envelope_io).call(pending_key)
|
|
34
34
|
|
|
35
|
-
@bus.publish(:proposal_accepted,
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
@ctx.bus.publish(:proposal_accepted,
|
|
36
|
+
store: @ctx.with_role(@ctx.role),
|
|
37
|
+
key: pending_key,
|
|
38
|
+
target_key: target,
|
|
39
|
+
correlation_id: @ctx.correlation_id)
|
|
40
40
|
|
|
41
41
|
{ "protocol" => PROTOCOL, "accepted" => pending_key, "target_key" => target, "action" => action }
|
|
42
42
|
end
|
|
@@ -44,7 +44,7 @@ module Textus
|
|
|
44
44
|
private
|
|
45
45
|
|
|
46
46
|
def evaluate_promotion!(env, target_key)
|
|
47
|
-
rules = @ctx.
|
|
47
|
+
rules = @ctx.manifest.rules_for(target_key)
|
|
48
48
|
promote = rules.promote
|
|
49
49
|
return if promote.nil? || promote.requires.empty?
|
|
50
50
|
|
|
@@ -11,9 +11,8 @@ module Textus
|
|
|
11
11
|
# `Application::Writes::Publish`. The CLI verb `textus build` calls
|
|
12
12
|
# both classes and merges the results.
|
|
13
13
|
class Build
|
|
14
|
-
def initialize(ctx
|
|
14
|
+
def initialize(ctx:)
|
|
15
15
|
@ctx = ctx
|
|
16
|
-
@bus = bus
|
|
17
16
|
end
|
|
18
17
|
|
|
19
18
|
def call(prefix: nil)
|
|
@@ -51,7 +50,7 @@ module Textus
|
|
|
51
50
|
end
|
|
52
51
|
|
|
53
52
|
def publish_and_fire(mentry, target_path)
|
|
54
|
-
envelope =
|
|
53
|
+
envelope = Textus::Application::Reads::Get.new(ctx: @ctx).call(mentry.key)
|
|
55
54
|
repo_root = File.dirname(root)
|
|
56
55
|
|
|
57
56
|
mentry.publish_to.each do |rel|
|
|
@@ -71,7 +70,7 @@ module Textus
|
|
|
71
70
|
end
|
|
72
71
|
|
|
73
72
|
def publish_event(event, **payload)
|
|
74
|
-
@bus.publish(event, store: @ctx.with_role(@ctx.role), correlation_id: @ctx.correlation_id, **payload)
|
|
73
|
+
@ctx.bus.publish(event, store: @ctx.with_role(@ctx.role), correlation_id: @ctx.correlation_id, **payload)
|
|
75
74
|
end
|
|
76
75
|
end
|
|
77
76
|
end
|
|
@@ -2,29 +2,24 @@ module Textus
|
|
|
2
2
|
module Application
|
|
3
3
|
module Writes
|
|
4
4
|
class Delete
|
|
5
|
-
def initialize(ctx:,
|
|
5
|
+
def initialize(ctx:, envelope_io:)
|
|
6
6
|
@ctx = ctx
|
|
7
|
-
@
|
|
7
|
+
@envelope_io = envelope_io
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
def call(key, if_etag: nil, suppress_events: false)
|
|
11
|
-
@ctx.
|
|
12
|
-
mentry
|
|
11
|
+
@ctx.manifest.validate_key!(key)
|
|
12
|
+
mentry = @ctx.manifest.resolve(key).entry
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
raise WriteForbidden.new(key, mentry.zone,
|
|
16
|
-
writers: @ctx.store.manifest.zone_writers(mentry.zone))
|
|
17
|
-
end
|
|
14
|
+
@ctx.authorize_write!(mentry)
|
|
18
15
|
|
|
19
|
-
@
|
|
20
|
-
key, ctx: @ctx, if_etag: if_etag
|
|
21
|
-
)
|
|
16
|
+
@envelope_io.delete(key, mentry: mentry, if_etag: if_etag)
|
|
22
17
|
|
|
23
18
|
unless suppress_events
|
|
24
|
-
@bus.publish(:entry_deleted,
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
19
|
+
@ctx.bus.publish(:entry_deleted,
|
|
20
|
+
store: @ctx.with_role(@ctx.role),
|
|
21
|
+
key: key,
|
|
22
|
+
correlation_id: @ctx.correlation_id)
|
|
28
23
|
end
|
|
29
24
|
|
|
30
25
|
{ "protocol" => Textus::PROTOCOL, "ok" => true, "key" => key, "deleted" => true }
|