textus 0.54.2 → 0.55.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 +38 -1
- data/README.md +17 -10
- data/SPEC.md +39 -11
- data/docs/architecture/README.md +23 -11
- data/docs/reference/conventions.md +5 -2
- data/exe/textus +1 -1
- data/lib/textus/boot.rb +64 -93
- data/lib/textus/dispatch/binder.rb +48 -0
- data/lib/textus/dispatch/contracts.rb +63 -0
- data/lib/textus/dispatch/handler_registry.rb +21 -0
- data/lib/textus/dispatch/middleware/audit_index.rb +51 -0
- data/lib/textus/dispatch/middleware/auth.rb +40 -0
- data/lib/textus/dispatch/middleware/base.rb +26 -0
- data/lib/textus/dispatch/middleware/binder.rb +20 -0
- data/lib/textus/dispatch/middleware/cascade.rb +53 -0
- data/lib/textus/dispatch/pipeline.rb +35 -0
- data/lib/textus/doctor/check/audit_log.rb +2 -2
- data/lib/textus/doctor/check/generator_drift.rb +4 -4
- data/lib/textus/doctor/check/orphaned_publish_targets.rb +3 -3
- data/lib/textus/doctor/check/protocol_version.rb +2 -2
- data/lib/textus/doctor/check/raw_asset_paths.rb +1 -1
- data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
- data/lib/textus/doctor/check/schema_violations.rb +2 -2
- data/lib/textus/doctor/check/schemas.rb +1 -1
- data/lib/textus/doctor/check/{notebook_sources.rb → scratchpad_sources.rb} +10 -5
- data/lib/textus/doctor/check/sentinels.rb +4 -4
- data/lib/textus/doctor/check/templates.rb +1 -1
- data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
- data/lib/textus/doctor/check.rb +9 -10
- data/lib/textus/doctor.rb +2 -2
- data/lib/textus/errors.rb +8 -0
- data/lib/textus/format/base.rb +34 -10
- data/lib/textus/format/json.rb +5 -27
- data/lib/textus/format/markdown.rb +5 -27
- data/lib/textus/format/text.rb +5 -4
- data/lib/textus/format/yaml.rb +30 -27
- data/lib/textus/format.rb +22 -1
- data/lib/textus/handlers/maintenance/boot_store.rb +15 -0
- data/lib/textus/handlers/maintenance/doctor_store.rb +15 -0
- data/lib/textus/handlers/maintenance/drain_store.rb +21 -0
- data/lib/textus/handlers/maintenance/ingest_entry.rb +159 -0
- data/lib/textus/handlers/maintenance/jobs_action.rb +21 -0
- data/lib/textus/handlers/maintenance/published_entries.rb +17 -0
- data/lib/textus/handlers/maintenance/rule_explain.rb +77 -0
- data/lib/textus/handlers/maintenance/rule_lint.rb +54 -0
- data/lib/textus/handlers/maintenance/rule_list.rb +32 -0
- data/lib/textus/handlers/maintenance/schema_envelope.rb +19 -0
- data/lib/textus/handlers/read/audit_entries.rb +48 -0
- data/lib/textus/handlers/read/blame_entry.rb +71 -0
- data/lib/textus/handlers/read/deps_entry.rb +17 -0
- data/lib/textus/handlers/read/get_entry.rb +68 -0
- data/lib/textus/handlers/read/list_keys.rb +36 -0
- data/lib/textus/handlers/read/pulse_entries.rb +66 -0
- data/lib/textus/handlers/read/rdeps_entry.rb +21 -0
- data/lib/textus/handlers/read/uid_entry.rb +18 -0
- data/lib/textus/handlers/read/where_entry.rb +18 -0
- data/lib/textus/handlers/write/accept_proposal.rb +39 -0
- data/lib/textus/handlers/write/data_mv.rb +55 -0
- data/lib/textus/handlers/write/delete_key.rb +17 -0
- data/lib/textus/handlers/write/enqueue_job.rb +27 -0
- data/lib/textus/handlers/write/key_delete_prefix.rb +32 -0
- data/lib/textus/handlers/write/key_mv_prefix.rb +45 -0
- data/lib/textus/handlers/write/move_key.rb +80 -0
- data/lib/textus/handlers/write/propose_entry.rb +29 -0
- data/lib/textus/handlers/write/put_entry.rb +29 -0
- data/lib/textus/handlers/write/reject_proposal.rb +29 -0
- data/lib/textus/init.rb +5 -7
- data/lib/textus/jobs.rb +3 -9
- data/lib/textus/manifest/capabilities.rb +4 -4
- data/lib/textus/manifest/entry/base.rb +3 -3
- data/lib/textus/manifest/entry/publish/subtree_mirror.rb +2 -2
- data/lib/textus/manifest/entry/publish/to_paths.rb +2 -2
- data/lib/textus/manifest/policy/predicates/author_held.rb +22 -0
- data/lib/textus/manifest/policy/predicates/etag_match.rb +18 -0
- data/lib/textus/manifest/policy/predicates/fresh_within.rb +13 -0
- data/lib/textus/manifest/policy/predicates/lane_deletable_by.rb +31 -0
- data/lib/textus/manifest/policy/predicates/lane_writable_by.rb +23 -0
- data/lib/textus/manifest/policy/predicates/raw_lane_ingest_only.rb +25 -0
- data/lib/textus/manifest/policy/predicates/raw_write_once.rb +24 -0
- data/lib/textus/manifest/policy/predicates/schema_valid.rb +41 -0
- data/lib/textus/manifest/policy/predicates/target_is_canon.rb +20 -0
- data/lib/textus/manifest/policy/predicates.rb +54 -0
- data/lib/textus/manifest/policy/retention.rb +1 -1
- data/lib/textus/manifest/schema/semantics/cross_field.rb +53 -0
- data/lib/textus/manifest/schema/semantics/invariants.rb +125 -0
- data/lib/textus/manifest/schema/semantics/migration.rb +83 -0
- data/lib/textus/manifest/schema/semantics.rb +11 -216
- data/lib/textus/orchestration.rb +55 -0
- data/lib/textus/{ports → port}/audit_log.rb +46 -6
- data/lib/textus/{ports → port}/build_lock.rb +2 -2
- data/lib/textus/{ports → port}/clock.rb +1 -1
- data/lib/textus/{ports → port}/publisher.rb +5 -5
- data/lib/textus/{core → port}/sentinel.rb +1 -6
- data/lib/textus/{ports → port}/sentinel_store.rb +4 -4
- data/lib/textus/{ports → port}/storage/file_stat.rb +1 -1
- data/lib/textus/port/storage/file_store.rb +49 -0
- data/lib/textus/port/storage/interface.rb +17 -0
- data/lib/textus/port/store.rb +149 -0
- data/lib/textus/{ports → port}/watcher_lock.rb +3 -3
- data/lib/textus/produce/engine.rb +1 -11
- data/lib/textus/produce/publisher.rb +21 -0
- data/lib/textus/schema/registry.rb +42 -0
- data/lib/textus/schema/tools.rb +7 -10
- data/lib/textus/store/container.rb +173 -0
- data/lib/textus/store/cursor.rb +26 -0
- data/lib/textus/store/entry/reader.rb +47 -0
- data/lib/textus/store/entry/writer.rb +219 -0
- data/lib/textus/store/envelope/meta.rb +61 -0
- data/lib/textus/store/freshness/drift_detector.rb +93 -0
- data/lib/textus/store/freshness/evaluator.rb +20 -0
- data/lib/textus/store/freshness/ttl_evaluator.rb +57 -0
- data/lib/textus/{core → store}/freshness/verdict.rb +1 -11
- data/lib/textus/store/freshness.rb +8 -0
- data/lib/textus/store/index/builder.rb +76 -0
- data/lib/textus/store/index/lookup.rb +60 -0
- data/lib/textus/store/jobs/base.rb +13 -0
- data/lib/textus/store/jobs/index.rb +15 -0
- data/lib/textus/store/jobs/materialize.rb +15 -0
- data/lib/textus/store/jobs/plan.rb +11 -0
- data/lib/textus/store/jobs/planner.rb +124 -0
- data/lib/textus/store/jobs/queue.rb +162 -0
- data/lib/textus/store/jobs/registry.rb +19 -0
- data/lib/textus/store/jobs/retention/base.rb +52 -0
- data/lib/textus/store/jobs/retention/sweep.rb +55 -0
- data/lib/textus/store/jobs/retention.rb +8 -0
- data/lib/textus/store/jobs/sweep.rb +21 -0
- data/lib/textus/store/jobs/worker.rb +64 -0
- data/lib/textus/store/layout.rb +97 -0
- data/lib/textus/store.rb +63 -32
- data/lib/textus/{surfaces → surface}/cli/group/data.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group/key.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group/mcp.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group/rule.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group/schema.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group.rb +2 -2
- data/lib/textus/{surfaces → surface}/cli/runner.rb +14 -68
- data/lib/textus/surface/cli/sources.rb +41 -0
- data/lib/textus/{surfaces → surface}/cli/verb/doctor.rb +5 -6
- data/lib/textus/{surfaces → surface}/cli/verb/get.rb +7 -5
- data/lib/textus/{surfaces → surface}/cli/verb/init.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/verb/mcp_serve.rb +3 -3
- data/lib/textus/surface/cli/verb/put.rb +27 -0
- data/lib/textus/{surfaces → surface}/cli/verb/schema_diff.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/verb/schema_init.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/verb/schema_migrate.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/verb/watch.rb +2 -2
- data/lib/textus/{surfaces → surface}/cli/verb.rb +3 -8
- data/lib/textus/{surfaces → surface}/cli.rb +1 -1
- data/lib/textus/surface/mcp/catalog.rb +58 -0
- data/lib/textus/{surfaces → surface}/mcp/errors.rb +1 -11
- data/lib/textus/surface/mcp/projector.rb +20 -0
- data/lib/textus/{surfaces → surface}/mcp/server.rb +24 -35
- data/lib/textus/{surfaces → surface}/mcp.rb +2 -2
- data/lib/textus/{surfaces → surface}/watcher.rb +8 -8
- data/lib/textus/value/call.rb +30 -0
- data/lib/textus/value/command.rb +16 -0
- data/lib/textus/{core → value}/duration.rb +1 -4
- data/lib/textus/value/envelope.rb +90 -0
- data/lib/textus/value/etag.rb +39 -0
- data/lib/textus/value/payload.rb +7 -0
- data/lib/textus/value/result.rb +46 -0
- data/lib/textus/value/role.rb +38 -0
- data/lib/textus/value/types.rb +13 -0
- data/lib/textus/{uid.rb → value/uid.rb} +9 -7
- data/lib/textus/verb_registry.rb +417 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus/workflow/loader.rb +4 -4
- data/lib/textus/workflow/runner.rb +11 -33
- data/lib/textus.rb +4 -69
- metadata +142 -105
- data/lib/textus/action/accept.rb +0 -44
- data/lib/textus/action/audit.rb +0 -131
- data/lib/textus/action/base.rb +0 -42
- data/lib/textus/action/blame.rb +0 -97
- data/lib/textus/action/boot.rb +0 -17
- data/lib/textus/action/data_mv.rb +0 -69
- data/lib/textus/action/deps.rb +0 -34
- data/lib/textus/action/doctor.rb +0 -24
- data/lib/textus/action/drain.rb +0 -39
- data/lib/textus/action/enqueue.rb +0 -53
- data/lib/textus/action/get.rb +0 -78
- data/lib/textus/action/ingest.rb +0 -203
- data/lib/textus/action/jobs.rb +0 -36
- data/lib/textus/action/key_delete.rb +0 -36
- data/lib/textus/action/key_delete_prefix.rb +0 -44
- data/lib/textus/action/key_mv.rb +0 -129
- data/lib/textus/action/key_mv_prefix.rb +0 -57
- data/lib/textus/action/list.rb +0 -42
- data/lib/textus/action/propose.rb +0 -52
- data/lib/textus/action/published.rb +0 -24
- data/lib/textus/action/pulse.rb +0 -56
- data/lib/textus/action/put.rb +0 -52
- data/lib/textus/action/rdeps.rb +0 -39
- data/lib/textus/action/reject.rb +0 -37
- data/lib/textus/action/rule_explain.rb +0 -91
- data/lib/textus/action/rule_lint.rb +0 -68
- data/lib/textus/action/rule_list.rb +0 -42
- data/lib/textus/action/schema_envelope.rb +0 -29
- data/lib/textus/action/uid.rb +0 -33
- data/lib/textus/action/where.rb +0 -36
- data/lib/textus/action/write_verb.rb +0 -44
- data/lib/textus/call.rb +0 -28
- data/lib/textus/command.rb +0 -41
- data/lib/textus/container.rb +0 -26
- data/lib/textus/contract/around.rb +0 -29
- data/lib/textus/contract/binder.rb +0 -88
- data/lib/textus/contract/resources/build_lock.rb +0 -17
- data/lib/textus/contract/resources/cursor.rb +0 -26
- data/lib/textus/contract/sources.rb +0 -39
- data/lib/textus/contract/view.rb +0 -15
- data/lib/textus/contract.rb +0 -174
- data/lib/textus/core/freshness/evaluator.rb +0 -150
- data/lib/textus/core/freshness.rb +0 -11
- data/lib/textus/core/retention/sweep.rb +0 -57
- data/lib/textus/core/retention.rb +0 -11
- data/lib/textus/cursor_store.rb +0 -24
- data/lib/textus/envelope/reader.rb +0 -46
- data/lib/textus/envelope/writer.rb +0 -209
- data/lib/textus/envelope.rb +0 -79
- data/lib/textus/etag.rb +0 -36
- data/lib/textus/gate/auth.rb +0 -227
- data/lib/textus/gate.rb +0 -116
- data/lib/textus/jobs/base.rb +0 -23
- data/lib/textus/jobs/materialize.rb +0 -20
- data/lib/textus/jobs/plan.rb +0 -9
- data/lib/textus/jobs/planner.rb +0 -101
- data/lib/textus/jobs/retention.rb +0 -48
- data/lib/textus/jobs/sweep.rb +0 -27
- data/lib/textus/jobs/worker.rb +0 -67
- data/lib/textus/layout.rb +0 -91
- data/lib/textus/ports/job_store/job.rb +0 -65
- data/lib/textus/ports/job_store.rb +0 -123
- data/lib/textus/ports/raw_index.rb +0 -61
- data/lib/textus/ports/storage/file_store.rb +0 -26
- data/lib/textus/role.rb +0 -36
- data/lib/textus/schemas.rb +0 -54
- data/lib/textus/session.rb +0 -35
- data/lib/textus/surfaces/cli/verb/put.rb +0 -30
- data/lib/textus/surfaces/mcp/catalog.rb +0 -111
- data/lib/textus/surfaces/role_scope.rb +0 -34
- data/lib/textus/types.rb +0 -15
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
class Store
|
|
5
|
+
module Freshness
|
|
6
|
+
class TtlEvaluator
|
|
7
|
+
def initialize(manifest:, file_stat:, clock:)
|
|
8
|
+
@manifest = manifest
|
|
9
|
+
@file_stat = file_stat
|
|
10
|
+
@clock = clock
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def verdict(mentry)
|
|
14
|
+
ttl = @manifest.rules.for(mentry.key).retention&.ttl_seconds
|
|
15
|
+
return fresh if ttl.nil?
|
|
16
|
+
|
|
17
|
+
stale = age_stale?(file_basis(mentry), ttl)
|
|
18
|
+
Verdict.build(stale: stale, reason: stale ? "ttl exceeded" : nil, fetching: false)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def stale_keys(prefix: nil, lane: nil)
|
|
22
|
+
@manifest.data.entries.select { |m| due?(m, prefix: prefix, lane: lane) }.map(&:key)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def fresh = Verdict.build(stale: false, reason: nil, fetching: false)
|
|
28
|
+
|
|
29
|
+
def file_basis(mentry)
|
|
30
|
+
path = @manifest.resolver.resolve(mentry.key).path
|
|
31
|
+
return nil unless @file_stat.exists?(path)
|
|
32
|
+
|
|
33
|
+
@file_stat.mtime(path)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def due?(mentry, prefix:, lane:)
|
|
37
|
+
return false if lane && mentry.lane != lane
|
|
38
|
+
return false if prefix && !mentry.key.start_with?(prefix)
|
|
39
|
+
|
|
40
|
+
ttl = @manifest.rules.for(mentry.key).retention&.ttl_seconds
|
|
41
|
+
return false if ttl.nil?
|
|
42
|
+
|
|
43
|
+
path = @manifest.resolver.resolve(mentry.key).path
|
|
44
|
+
return true unless @file_stat.exists?(path)
|
|
45
|
+
|
|
46
|
+
age_stale?(file_basis(mentry), ttl)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def age_stale?(basis, ttl)
|
|
50
|
+
return true if basis.nil?
|
|
51
|
+
|
|
52
|
+
(@clock.now - basis).to_i > ttl
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -1,16 +1,6 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
1
|
module Textus
|
|
4
|
-
|
|
2
|
+
class Store
|
|
5
3
|
module Freshness
|
|
6
|
-
# Value object describing the freshness annotation attached to an Envelope
|
|
7
|
-
# after a currency evaluation (ADR 0099 — was Core::Freshness).
|
|
8
|
-
#
|
|
9
|
-
# Note on wire format: `#to_h_for_wire` is intentionally narrower than the
|
|
10
|
-
# full field set. It emits the legacy keys ("stale", "stale_reason",
|
|
11
|
-
# "fetching", and "fetch_error" when present) so the CLI JSON wire stays
|
|
12
|
-
# byte-identical with textus/4. The gem-side fields `checked_at` and
|
|
13
|
-
# `ttl_remaining_ms` are NOT emitted on the wire.
|
|
14
4
|
Verdict = Data.define(
|
|
15
5
|
:stale, :fetching, :reason, :fetch_error, :checked_at, :ttl_remaining_ms
|
|
16
6
|
) do
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Textus
|
|
7
|
+
class Store
|
|
8
|
+
module Index
|
|
9
|
+
class Builder
|
|
10
|
+
def initialize(store:)
|
|
11
|
+
@store = store
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def rebuild!(resolver:)
|
|
15
|
+
rows = resolver.enumerate.filter_map { |row| build_row(row) }
|
|
16
|
+
now_iso = Time.now.utc.iso8601
|
|
17
|
+
|
|
18
|
+
@store.transaction do
|
|
19
|
+
@store.execute("DELETE FROM entries")
|
|
20
|
+
rows.each do |data|
|
|
21
|
+
@store.execute(
|
|
22
|
+
"INSERT INTO entries (key, lane, format, etag, content, extra, indexed_at, schema_ref)
|
|
23
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
24
|
+
[data[:key], data[:lane], data[:format], data[:etag],
|
|
25
|
+
data[:content], data[:extra], now_iso, data[:schema_ref]],
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
@store.execute("INSERT INTO entries_fts(entries_fts) VALUES('rebuild')")
|
|
29
|
+
end
|
|
30
|
+
{ indexed: rows.size }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def build_row(row)
|
|
36
|
+
key = row.fetch(:key)
|
|
37
|
+
path = row.fetch(:path)
|
|
38
|
+
entry = row.fetch(:manifest_entry)
|
|
39
|
+
return nil unless path && File.file?(path)
|
|
40
|
+
|
|
41
|
+
raw = File.read(path)
|
|
42
|
+
parsed = Textus::Format.for(entry.format).parse(raw, path: path)
|
|
43
|
+
{
|
|
44
|
+
key: key,
|
|
45
|
+
lane: entry.lane,
|
|
46
|
+
format: entry.format.to_s,
|
|
47
|
+
etag: Textus::Value::Etag.for_bytes(raw),
|
|
48
|
+
content: content_text(parsed),
|
|
49
|
+
extra: extra_json(parsed),
|
|
50
|
+
schema_ref: entry.schema,
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def content_text(parsed)
|
|
55
|
+
content = parsed["content"]
|
|
56
|
+
body = parsed["body"]
|
|
57
|
+
parts = []
|
|
58
|
+
parts << body if body
|
|
59
|
+
parts << JSON.dump(content) if content
|
|
60
|
+
parts.join("\n")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def extra_json(parsed)
|
|
64
|
+
content = parsed["content"]
|
|
65
|
+
extra = {}
|
|
66
|
+
if content.is_a?(Hash)
|
|
67
|
+
extra["content_hash"] = content["content_hash"] if content["content_hash"]
|
|
68
|
+
url = content.dig("source", "url")
|
|
69
|
+
extra["url"] = url if url
|
|
70
|
+
end
|
|
71
|
+
JSON.dump(extra)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Textus
|
|
6
|
+
class Store
|
|
7
|
+
module Index
|
|
8
|
+
class Lookup
|
|
9
|
+
def initialize(store:)
|
|
10
|
+
@store = store
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def search(query, lane: nil)
|
|
14
|
+
return [] if query.to_s.strip.empty?
|
|
15
|
+
|
|
16
|
+
clauses = ["entries_fts MATCH ?"]
|
|
17
|
+
params = [query]
|
|
18
|
+
if lane
|
|
19
|
+
clauses << "entries.lane = ?"
|
|
20
|
+
params << lane
|
|
21
|
+
end
|
|
22
|
+
conditions = "WHERE #{clauses.join(" AND ")}"
|
|
23
|
+
@store.execute(
|
|
24
|
+
"SELECT entries.key, entries.lane, entries.format, entries.etag, bm25(entries_fts) AS rank
|
|
25
|
+
FROM entries_fts JOIN entries ON entries_fts.rowid = entries.rowid
|
|
26
|
+
#{conditions}
|
|
27
|
+
ORDER BY rank",
|
|
28
|
+
params,
|
|
29
|
+
)
|
|
30
|
+
rescue SQLite3::SQLException
|
|
31
|
+
[]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def find_by_hash(content_hash)
|
|
35
|
+
return nil if content_hash.to_s.empty?
|
|
36
|
+
|
|
37
|
+
find_extra("content_hash", content_hash)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def find_by_url(url)
|
|
41
|
+
return nil if url.to_s.empty?
|
|
42
|
+
|
|
43
|
+
find_extra("url", url)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def find_extra(name, value)
|
|
49
|
+
@store.execute("SELECT key, extra FROM entries ORDER BY indexed_at DESC").each do |row|
|
|
50
|
+
extra = JSON.parse(row["extra"] || "{}")
|
|
51
|
+
return row["key"] if extra[name] == value
|
|
52
|
+
end
|
|
53
|
+
nil
|
|
54
|
+
rescue SQLite3::SQLException
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
class Store
|
|
5
|
+
module Jobs
|
|
6
|
+
class Index < Base
|
|
7
|
+
TYPE = "index"
|
|
8
|
+
|
|
9
|
+
def self.call(container:, call:) # rubocop:disable Lint/UnusedMethodArgument
|
|
10
|
+
Textus::Store::Index::Builder.new(store: container.job_store).rebuild!(resolver: container.manifest.resolver)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
class Store
|
|
5
|
+
module Jobs
|
|
6
|
+
class Materialize < Base
|
|
7
|
+
TYPE = "materialize"
|
|
8
|
+
|
|
9
|
+
def self.call(container:, call:, key:)
|
|
10
|
+
Textus::Produce::Engine.converge(container: container, call: call, keys: [key])
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
class Store
|
|
5
|
+
module Jobs
|
|
6
|
+
class Planner
|
|
7
|
+
ACTIONS_BY_TRIGGER = {
|
|
8
|
+
"convergence" => %w[materialize sweep index],
|
|
9
|
+
"entry.written" => %w[materialize],
|
|
10
|
+
"entry.deleted" => %w[materialize],
|
|
11
|
+
"entry.moved" => %w[materialize],
|
|
12
|
+
"proposal.accepted" => %w[materialize],
|
|
13
|
+
"proposal.rejected" => %w[materialize],
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
ENTRY_LEVEL_TRIGGERS = %w[
|
|
17
|
+
entry.written entry.deleted entry.moved
|
|
18
|
+
proposal.accepted proposal.rejected
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
SCOPE_RESOLVERS = {
|
|
22
|
+
"materialize" => :producible_keys,
|
|
23
|
+
"sweep" => :lane_keys,
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
GLOBAL_ACTIONS = {
|
|
27
|
+
"index" => {},
|
|
28
|
+
"sweep" => { "scope" => {} },
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
def self.seed(container:, queue:, role:)
|
|
32
|
+
jobs = new(container: container).plan(
|
|
33
|
+
trigger: { "type" => "convergence" },
|
|
34
|
+
role: role,
|
|
35
|
+
)
|
|
36
|
+
jobs.each { |j| queue.enqueue(j) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(container:)
|
|
40
|
+
@container = container
|
|
41
|
+
@manifest = container.manifest
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def plan(trigger:, role:)
|
|
45
|
+
type = trigger["type"] || trigger[:type]
|
|
46
|
+
target = trigger["target"] || trigger[:target]
|
|
47
|
+
return [] if type.nil?
|
|
48
|
+
|
|
49
|
+
blocks_with_react = @manifest.rules.blocks.select(&:react)
|
|
50
|
+
if blocks_with_react.any?
|
|
51
|
+
plan_from_rules(blocks_with_react, type, role, target: target)
|
|
52
|
+
else
|
|
53
|
+
plan_from_defaults(type, role, target: target)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def plan_from_rules(blocks, type, role, target: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
60
|
+
jobs = []
|
|
61
|
+
blocks
|
|
62
|
+
.select { |b| matches_trigger?(b.react, type) }
|
|
63
|
+
.each do |block|
|
|
64
|
+
do_action = block.react.raw["do"]
|
|
65
|
+
Array(do_action).each do |action|
|
|
66
|
+
if (global_args = GLOBAL_ACTIONS[action])
|
|
67
|
+
jobs << Textus::Store::Jobs::Queue::Job.new(type: action, args: global_args, role: role)
|
|
68
|
+
else
|
|
69
|
+
resolver = SCOPE_RESOLVERS.fetch(action, :producible_keys)
|
|
70
|
+
keys = send(resolver, nil)
|
|
71
|
+
keys.each { |key| jobs << job(action, key, role) }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
jobs
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def plan_from_defaults(type, role, target: nil)
|
|
79
|
+
actions = ACTIONS_BY_TRIGGER.fetch(type, [])
|
|
80
|
+
jobs = []
|
|
81
|
+
if actions.include?("materialize")
|
|
82
|
+
keys = target && ENTRY_LEVEL_TRIGGERS.include?(type) ? dependent_keys(target) : producible_keys(nil)
|
|
83
|
+
keys.each { |k| jobs << job("materialize", k, role) }
|
|
84
|
+
end
|
|
85
|
+
GLOBAL_ACTIONS.each do |action, args|
|
|
86
|
+
jobs << Textus::Store::Jobs::Queue::Job.new(type: action, args: args, role: role) if actions.include?(action)
|
|
87
|
+
end
|
|
88
|
+
jobs
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def matches_trigger?(react, type)
|
|
92
|
+
on = react.raw["on"]
|
|
93
|
+
Array(on).include?(type)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def job(type, key, role)
|
|
97
|
+
Textus::Store::Jobs::Queue::Job.new(type: type, args: { "key" => key }, role: role)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def producible_keys(_target)
|
|
101
|
+
@manifest.data.entries
|
|
102
|
+
.select { |e| !e.publish_tree.nil? || !e.publish_to.empty? }
|
|
103
|
+
.map(&:key)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def lane_keys(_target)
|
|
107
|
+
@manifest.data.entries.map(&:key)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def dependent_keys(target_key)
|
|
111
|
+
@manifest.data.entries
|
|
112
|
+
.select(&:external?)
|
|
113
|
+
.select do |e|
|
|
114
|
+
Array(e.source&.sources).compact.any? do |s|
|
|
115
|
+
target_key == s || target_key.start_with?("#{s}.")
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
.select { |e| !e.publish_tree.nil? || !e.publish_to.empty? }
|
|
119
|
+
.map(&:key)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
module Textus
|
|
9
|
+
class Store
|
|
10
|
+
module Jobs
|
|
11
|
+
class Queue
|
|
12
|
+
VALID_STATES = %w[ready leased done failed].freeze
|
|
13
|
+
|
|
14
|
+
Leased = Data.define(:job)
|
|
15
|
+
|
|
16
|
+
class Job
|
|
17
|
+
DIGEST_BYTES = 16
|
|
18
|
+
|
|
19
|
+
attr_reader :type, :args, :role, :attempts, :max_attempts, :errors
|
|
20
|
+
|
|
21
|
+
def initialize(type:, args:, role:, attempts: 0, max_attempts: 3, errors: [])
|
|
22
|
+
@type = type.to_s
|
|
23
|
+
@args = stringify(args)
|
|
24
|
+
@role = role.to_s
|
|
25
|
+
@attempts = attempts.to_i
|
|
26
|
+
@max_attempts = max_attempts.to_i
|
|
27
|
+
@errors = Array(errors)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def id
|
|
31
|
+
"#{type}:#{Digest::SHA256.hexdigest(JSON.dump(args.sort.to_h))[0, DIGEST_BYTES]}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def stringify(hash)
|
|
37
|
+
hash.to_h.transform_keys(&:to_s)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize(store:)
|
|
42
|
+
@store = store
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def enqueue(job)
|
|
46
|
+
now = iso_now
|
|
47
|
+
inserted = @store.execute(
|
|
48
|
+
"INSERT OR IGNORE INTO jobs (id, type, args, state, role, attempts, max_attempts, errors, lease, created_at, updated_at)
|
|
49
|
+
VALUES (?, ?, ?, 'ready', ?, ?, ?, ?, NULL, ?, ?)",
|
|
50
|
+
[job.id, job.type, JSON.dump(job.args), job.role, job.attempts, job.max_attempts, JSON.dump(job.errors), now, now],
|
|
51
|
+
)
|
|
52
|
+
return inserted if @store.query_value("SELECT changes()")&.to_i&.positive?
|
|
53
|
+
|
|
54
|
+
@store.execute(
|
|
55
|
+
"UPDATE jobs
|
|
56
|
+
SET state = 'ready', role = ?, args = ?, attempts = 0, errors = ?, lease = NULL, max_attempts = ?, updated_at = ?
|
|
57
|
+
WHERE id = ? AND state IN ('done', 'failed')",
|
|
58
|
+
[job.role, JSON.dump(job.args), JSON.dump([]), job.max_attempts, now, job.id],
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def ready_ids
|
|
63
|
+
list(:ready)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def lease(worker_id:, lease_ttl:)
|
|
67
|
+
now = Time.now.utc
|
|
68
|
+
expires_at = now + lease_ttl
|
|
69
|
+
token = SecureRandom.hex(8)
|
|
70
|
+
marked_lease = JSON.dump({ "worker_id" => worker_id, "expires_at" => expires_at.iso8601, "token" => token })
|
|
71
|
+
|
|
72
|
+
@store.execute(
|
|
73
|
+
"UPDATE jobs
|
|
74
|
+
SET state = 'leased', lease = ?, updated_at = ?
|
|
75
|
+
WHERE id = (
|
|
76
|
+
SELECT id FROM jobs WHERE state = 'ready' ORDER BY created_at, id LIMIT 1
|
|
77
|
+
)",
|
|
78
|
+
[marked_lease, now.iso8601],
|
|
79
|
+
)
|
|
80
|
+
row = @store.execute("SELECT * FROM jobs WHERE state = 'leased' AND lease = ? LIMIT 1", [marked_lease]).first
|
|
81
|
+
return nil unless row
|
|
82
|
+
|
|
83
|
+
Leased.new(job_from_row(row))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def ack(leased)
|
|
87
|
+
@store.execute(
|
|
88
|
+
"UPDATE jobs SET state = 'done', lease = NULL, updated_at = ? WHERE id = ? AND state = 'leased'",
|
|
89
|
+
[iso_now, leased.job.id],
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def fail(leased, error:)
|
|
94
|
+
job = leased.job
|
|
95
|
+
attempts = job.attempts + 1
|
|
96
|
+
errors = job.errors + [{ "attempt" => attempts, "error" => error, "at" => iso_now }]
|
|
97
|
+
dead = attempts >= job.max_attempts
|
|
98
|
+
state = dead ? "failed" : "ready"
|
|
99
|
+
@store.execute(
|
|
100
|
+
"UPDATE jobs SET state = ?, attempts = ?, errors = ?, lease = NULL, updated_at = ? WHERE id = ?",
|
|
101
|
+
[state, attempts, JSON.dump(errors), iso_now, job.id],
|
|
102
|
+
)
|
|
103
|
+
dead ? :dead_lettered : :requeued
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def reclaim(now:)
|
|
107
|
+
rows = @store.execute("SELECT id, lease FROM jobs WHERE state = 'leased'")
|
|
108
|
+
expired = rows.select do |row|
|
|
109
|
+
lease = JSON.parse(row["lease"] || "{}")
|
|
110
|
+
expires_at = lease["expires_at"]
|
|
111
|
+
expires_at.nil? || Time.parse(expires_at) <= now
|
|
112
|
+
end
|
|
113
|
+
expired.each do |row|
|
|
114
|
+
@store.execute(
|
|
115
|
+
"UPDATE jobs SET state = 'ready', lease = NULL, updated_at = ? WHERE id = ?",
|
|
116
|
+
[now.utc.iso8601, row["id"]],
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
expired.size
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def list(state)
|
|
123
|
+
state = state.to_s
|
|
124
|
+
raise Textus::UsageError.new("unknown job state: #{state}") unless VALID_STATES.include?(state)
|
|
125
|
+
|
|
126
|
+
@store.execute("SELECT id FROM jobs WHERE state = ? ORDER BY created_at, id", [state]).map { |row| row["id"] }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def retry_failed(job_id)
|
|
130
|
+
@store.execute(
|
|
131
|
+
"UPDATE jobs SET state = 'ready', attempts = 0, errors = ?, lease = NULL, updated_at = ? WHERE id = ? AND state = 'failed'",
|
|
132
|
+
[JSON.dump([]), iso_now, job_id],
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def purge(state)
|
|
137
|
+
state = state.to_s
|
|
138
|
+
raise Textus::UsageError.new("unknown job state: #{state}") unless VALID_STATES.include?(state)
|
|
139
|
+
|
|
140
|
+
@store.execute("DELETE FROM jobs WHERE state = ?", [state])
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
def job_from_row(row)
|
|
146
|
+
Job.new(
|
|
147
|
+
type: row["type"],
|
|
148
|
+
args: JSON.parse(row["args"] || "{}"),
|
|
149
|
+
role: row["role"],
|
|
150
|
+
attempts: row["attempts"],
|
|
151
|
+
max_attempts: row["max_attempts"],
|
|
152
|
+
errors: JSON.parse(row["errors"] || "[]"),
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def iso_now
|
|
157
|
+
Time.now.utc.iso8601
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Store
|
|
3
|
+
module Jobs
|
|
4
|
+
module Registry
|
|
5
|
+
class UnknownJob < KeyError; end
|
|
6
|
+
|
|
7
|
+
JOBS = {
|
|
8
|
+
"index" => Store::Jobs::Index,
|
|
9
|
+
"materialize" => Store::Jobs::Materialize,
|
|
10
|
+
"sweep" => Store::Jobs::Sweep,
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
def self.fetch(type)
|
|
14
|
+
JOBS.fetch(type.to_s) { raise UnknownJob.new("Unknown job type: #{type}") }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
class Store
|
|
5
|
+
module Jobs
|
|
6
|
+
module Retention
|
|
7
|
+
class Base
|
|
8
|
+
def initialize(container:, call:)
|
|
9
|
+
@container = container
|
|
10
|
+
@call = call
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(rows)
|
|
14
|
+
out = { dropped: [], archived: [], failed: [] }
|
|
15
|
+
rows.each do |row|
|
|
16
|
+
key = row["key"]
|
|
17
|
+
begin
|
|
18
|
+
case row["action"]
|
|
19
|
+
when "drop"
|
|
20
|
+
delete(key)
|
|
21
|
+
out[:dropped] << key
|
|
22
|
+
when "archive"
|
|
23
|
+
archive_leaf(row)
|
|
24
|
+
delete(key)
|
|
25
|
+
out[:archived] << key
|
|
26
|
+
end
|
|
27
|
+
rescue Textus::Error => e
|
|
28
|
+
out[:failed] << { "key" => key, "error" => e.message }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
out
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def archive_leaf(row)
|
|
37
|
+
src = row["path"]
|
|
38
|
+
dest = @container.layout.archive_path(src)
|
|
39
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
40
|
+
FileUtils.cp(src, dest)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def delete(key)
|
|
44
|
+
mentry = @container.manifest.resolver.resolve(key).entry
|
|
45
|
+
writer = Textus::Store::Entry::Writer.from(container: @container, call: @call)
|
|
46
|
+
writer.delete(key, mentry: mentry)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
class Store
|
|
5
|
+
module Jobs
|
|
6
|
+
module Retention
|
|
7
|
+
class Sweep
|
|
8
|
+
def self.expired?(ttl_seconds:, mtime:, now:)
|
|
9
|
+
return false if ttl_seconds.nil? || mtime.nil?
|
|
10
|
+
|
|
11
|
+
(now - mtime).to_i > ttl_seconds
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(manifest:, file_stat:, clock:)
|
|
15
|
+
@manifest = manifest
|
|
16
|
+
@file_stat = file_stat
|
|
17
|
+
@clock = clock
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(prefix: nil, lane: nil)
|
|
21
|
+
@manifest.data.entries
|
|
22
|
+
.select { |m| matches?(m, prefix: prefix, lane: lane) }
|
|
23
|
+
.flat_map { |m| rows_for(m) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def matches?(mentry, prefix:, lane:)
|
|
29
|
+
return false if lane && mentry.lane != lane
|
|
30
|
+
return false if prefix && !Textus::Key::Matching.matches_prefix?(
|
|
31
|
+
mentry.key, prefix, nested: mentry.is_a?(Textus::Manifest::Entry::Nested)
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
true
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def rows_for(mentry)
|
|
38
|
+
policy = @manifest.rules.for(mentry.key).retention
|
|
39
|
+
return [] if policy.nil?
|
|
40
|
+
|
|
41
|
+
@manifest.resolver.enumerate(prefix: mentry.key).filter_map do |row|
|
|
42
|
+
path = row[:path]
|
|
43
|
+
next unless @file_stat.exists?(path)
|
|
44
|
+
next unless self.class.expired?(
|
|
45
|
+
ttl_seconds: policy.ttl_seconds, mtime: @file_stat.mtime(path), now: @clock.now,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
{ "key" => row[:key], "path" => path, "action" => policy.action.to_s }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|