asgard 0.5.1 → 0.6.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 +179 -99
- data/README.md +141 -46
- data/bin/asgard +10 -0
- data/lib/asgard/base/dispatch.rb +257 -31
- data/lib/asgard/base/registry.rb +7 -3
- data/lib/asgard/base.rb +140 -9
- data/lib/asgard/declarations.rb +21 -0
- data/lib/asgard/doctor/task_sections.rb +11 -3
- data/lib/asgard/doctor.rb +12 -8
- data/lib/asgard/jobs/commands.rb +152 -0
- data/lib/asgard/jobs/declaration.rb +149 -0
- data/lib/asgard/jobs/launcher.rb +109 -0
- data/lib/asgard/jobs/state.rb +190 -0
- data/lib/asgard/jobs/stopper.rb +141 -0
- data/lib/asgard/jobs.rb +25 -0
- data/lib/asgard/kernel_methods.rb +19 -14
- data/lib/asgard/leading_flag.rb +10 -0
- data/lib/asgard/pid.rb +130 -0
- data/lib/asgard/schedule/commands.rb +39 -11
- data/lib/asgard/schedule/declaration.rb +139 -12
- data/lib/asgard/schedule/job_files.rb +24 -0
- data/lib/asgard/schedule/launchd.rb +55 -18
- data/lib/asgard/schedule/systemd.rb +69 -27
- data/lib/asgard/schedule/table.rb +34 -16
- data/lib/asgard/schedule.rb +49 -9
- data/lib/asgard/shell.rb +372 -44
- data/lib/asgard/state_dir.rb +84 -0
- data/lib/asgard/tasks.rb +8 -0
- data/lib/asgard/version.rb +1 -1
- data/lib/asgard/watch/changes.rb +26 -1
- data/lib/asgard/watch/commands.rb +52 -12
- data/lib/asgard/watch/daemon.rb +334 -43
- data/lib/asgard/watch/declaration.rb +135 -40
- data/lib/asgard/watch/dispatcher.rb +61 -10
- data/lib/asgard/watch/mailbox.rb +50 -0
- data/lib/asgard/watch/reporter.rb +50 -34
- data/lib/asgard/watch/spawn.rb +41 -16
- data/lib/asgard/watch/state.rb +93 -27
- data/lib/asgard/watch/worker.rb +44 -9
- data/lib/asgard/watch.rb +4 -11
- data/lib/asgard.rb +80 -14
- metadata +167 -186
- data/.envrc +0 -6
- data/.github/workflows/deploy-github-pages.yml +0 -52
- data/.loki +0 -39
- data/.reek.yml +0 -166
- data/.rubocop.yml +0 -163
- data/Archspec.rb +0 -24
- data/CLAUDE.md +0 -139
- data/COMMITS.md +0 -196
- data/bin/console +0 -11
- data/bin/setup +0 -8
- data/doc_tasks.loki +0 -21
- data/docs/api.md +0 -368
- data/docs/assets/css/custom.css +0 -93
- data/docs/assets/images/asgard.jpg +0 -0
- data/docs/changelog.md +0 -1
- data/docs/dependencies.md +0 -313
- data/docs/environment.md +0 -129
- data/docs/examples.md +0 -194
- data/docs/getting-started.md +0 -181
- data/docs/helpers.md +0 -262
- data/docs/index.md +0 -87
- data/docs/options.md +0 -224
- data/docs/schedule.md +0 -106
- data/docs/shell.md +0 -237
- data/docs/subcommands.md +0 -181
- data/docs/task-files.md +0 -407
- data/docs/tasks.md +0 -326
- data/docs/variables.md +0 -338
- data/docs/watch.md +0 -123
- data/examples/.env +0 -4
- data/examples/.envrc +0 -3
- data/examples/.loki +0 -24
- data/examples/bad.loki +0 -63
- data/examples/concurrent.loki +0 -58
- data/examples/confirm.loki +0 -48
- data/examples/db_subcommands.loki +0 -74
- data/examples/depends_on_block/bad/.loki +0 -51
- data/examples/depends_on_block/good/.loki +0 -83
- data/examples/do.loki +0 -45
- data/examples/env_usage.loki +0 -27
- data/examples/kitchen_sink.loki +0 -210
- data/examples/server_subcommands.loki +0 -65
- data/examples/subdir/.loki +0 -12
- data/examples/subdir/import_demo.loki +0 -14
- data/examples/subdir/import_up_demo.loki +0 -18
- data/examples/watcher_examples/.envrc +0 -3
- data/examples/watcher_examples/.loki +0 -54
- data/examples/watcher_examples/README.md +0 -112
- data/examples/watcher_examples/bin/hello +0 -3
- data/examples/watcher_examples/data/records.json +0 -1
- data/examples/watcher_examples/docs/drafts/ideas.md +0 -5
- data/examples/watcher_examples/docs/index.md +0 -4
- data/examples/watcher_examples/lib/greeter.rb +0 -7
- data/gem_tasks.loki +0 -51
- data/git.loki +0 -13
- data/mkdocs.yml +0 -166
- data/quality.loki +0 -266
- data/quality_rails.loki +0 -47
- data/xyzzy.loki +0 -12
|
@@ -11,43 +11,77 @@ module Asgard
|
|
|
11
11
|
EVENTS = %i[changed added removed].freeze
|
|
12
12
|
EVENT_ALIASES = { modified: :changed }.freeze
|
|
13
13
|
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
DEFAULT_IGNORES = %w[.git
|
|
14
|
+
# Version-control and dependency directories, ignored at any depth.
|
|
15
|
+
# Each matches the bare directory too ("app/node_modules"), which is
|
|
16
|
+
# what Listen tests while it scans, so the tree beneath is never walked.
|
|
17
|
+
DEFAULT_IGNORES = %w[.git .hg .svn node_modules].map { |dir| %r{(?:\A|/)#{Regexp.escape(dir)}(?:/|\z)} }.freeze
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
# Output and scratch directories a task or Bundler writes in the
|
|
20
|
+
# project root, ignored so a task never retriggers itself. Only the
|
|
21
|
+
# root's own: an app/log/ of yours is watched like any directory.
|
|
22
|
+
ROOT_IGNORES = %w[coverage pkg tmp log .bundle bundle vendor/bundle vendor/ruby]
|
|
23
|
+
.map { |dir| %r{\A#{Regexp.escape(dir)}(?:/|\z)} }.freeze
|
|
24
|
+
|
|
25
|
+
GLOB_CHARS = /[*?{\[]/
|
|
26
|
+
BRACE_DEPTH = Hash.new(0).update("{" => 1, "}" => -1).freeze
|
|
27
|
+
GLOB_TOKENS = %r~\*\*/|\*\*|\*|\?|(?<brace>\{(?:[^{}]|\g<brace>)*\})|\[!?[^\]/]+\]|\{|\[|[^*?{\[]+~
|
|
21
28
|
|
|
22
29
|
module_function
|
|
23
30
|
|
|
24
31
|
# Validates a declaration and returns it as a plain Hash. paths: entries
|
|
25
32
|
# are directories, files or globs relative to the project root; the
|
|
26
|
-
# Hash carries the directories Listen watches and
|
|
27
|
-
# nil)
|
|
28
|
-
# change trigger the task; as: names the
|
|
29
|
-
# from the helpers below comes back
|
|
33
|
+
# Hash carries the directories Listen watches, and per directory the
|
|
34
|
+
# only: Regexp (or nil) for its own patterns and its ignore: list.
|
|
35
|
+
# events: picks which kinds of change trigger the task; as: names the
|
|
36
|
+
# entry. Every ArgumentError from the helpers below comes back
|
|
37
|
+
# prefixed with the task.
|
|
30
38
|
def normalize(task, paths:, only: nil, ignore: nil, events: :all, debounce: 0.5, env: {}, as: nil)
|
|
31
39
|
task = task.to_s
|
|
32
40
|
raise ArgumentError, "task name #{task.inspect} must be a single word" unless task.match?(/\A[\w:-]+\z/)
|
|
33
41
|
|
|
34
|
-
paths
|
|
35
|
-
|
|
42
|
+
paths = relative_paths(paths)
|
|
43
|
+
globs = watched(paths)
|
|
44
|
+
filters = regexps(:only, only)
|
|
45
|
+
ignores = regexps(:ignore, ignore)
|
|
36
46
|
{
|
|
37
47
|
name: entry_name(as || task),
|
|
38
48
|
task:,
|
|
39
49
|
paths:,
|
|
40
|
-
dirs
|
|
41
|
-
only: combine_only(
|
|
42
|
-
ignore: [
|
|
50
|
+
dirs: globs.keys,
|
|
51
|
+
only: globs.to_h { |dir, patterns| [dir, combine_only(patterns.map { |pattern| glob_regexp(pattern) }, filters)] },
|
|
52
|
+
ignore: globs.keys.to_h { |dir| [dir, ignores_for(dir, ignores)] },
|
|
43
53
|
events: normalize_events(events),
|
|
44
54
|
debounce: non_negative_debounce(debounce),
|
|
45
|
-
env: env
|
|
55
|
+
env: environment(env)
|
|
46
56
|
}
|
|
47
57
|
rescue ArgumentError => e
|
|
48
58
|
raise ArgumentError, "watch :#{task} #{e.message}"
|
|
49
59
|
end
|
|
50
60
|
|
|
61
|
+
# The ignore: list Listen gets for one watched directory: the
|
|
62
|
+
# declared ones, DEFAULT_IGNORES, and ROOT_IGNORES when the directory
|
|
63
|
+
# is the project root.
|
|
64
|
+
def ignores_for(dir, declared) = [*declared, *DEFAULT_IGNORES, *(dir == "." ? ROOT_IGNORES : [])]
|
|
65
|
+
|
|
66
|
+
# env: as a Hash of String names to String values, refused here when
|
|
67
|
+
# Process.spawn would refuse it later: a name that is empty, not a
|
|
68
|
+
# String or Symbol, or holds = or a NUL byte; a value holding a NUL.
|
|
69
|
+
def environment(env)
|
|
70
|
+
raise ArgumentError, "env: expects a Hash (got #{env.inspect})" unless env.is_a?(Hash)
|
|
71
|
+
|
|
72
|
+
env.to_h { |key, value| env_pair(key, value) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def env_pair(key, value)
|
|
76
|
+
name = key.to_s if key.is_a?(String) || key.is_a?(Symbol)
|
|
77
|
+
raise ArgumentError, "env: #{key.inspect} is not a variable name" if name.nil? || name.empty? || name.match?(/[=\0]/)
|
|
78
|
+
|
|
79
|
+
value = value.to_s
|
|
80
|
+
raise ArgumentError, "env: the value of #{name} holds a NUL byte" if value.include?("\0")
|
|
81
|
+
|
|
82
|
+
[name, value]
|
|
83
|
+
end
|
|
84
|
+
|
|
51
85
|
# The entry name (as:, or the task), checked for the characters a file
|
|
52
86
|
# or label can carry.
|
|
53
87
|
def entry_name(name)
|
|
@@ -68,50 +102,92 @@ module Asgard
|
|
|
68
102
|
paths
|
|
69
103
|
end
|
|
70
104
|
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
# it
|
|
105
|
+
# { directory => its glob patterns } for a declaration's paths, each
|
|
106
|
+
# directory listened to on its own so its patterns never filter
|
|
107
|
+
# another's. A bare directory takes everything beneath it (no
|
|
108
|
+
# patterns), so it cannot share a declaration with a pattern or a
|
|
109
|
+
# file: the pattern would silently filter the directory too.
|
|
75
110
|
def watched(paths)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
111
|
+
root = declaration_root
|
|
112
|
+
splits = paths.map { |path| split_path(path, root:) }
|
|
113
|
+
globs = splits.filter_map(&:last)
|
|
114
|
+
if !globs.empty? && globs.size < splits.size
|
|
79
115
|
raise ArgumentError,
|
|
80
116
|
"mixes a bare directory with patterns or files, which would filter the directory too; " \
|
|
81
117
|
"declare them separately or write the directory as dir/**/*"
|
|
82
118
|
end
|
|
83
119
|
|
|
84
|
-
|
|
120
|
+
fold(splits)
|
|
85
121
|
end
|
|
86
122
|
|
|
87
|
-
# [directory,
|
|
123
|
+
# Groups [directory, pattern or nil] pairs by directory. A directory
|
|
124
|
+
# inside another of the same declaration folds into the outer one,
|
|
125
|
+
# its pattern prefixed with the path between them, so a change is
|
|
126
|
+
# reported once rather than by two listeners.
|
|
127
|
+
def fold(splits)
|
|
128
|
+
dirs = splits.map(&:first).uniq
|
|
129
|
+
splits.each_with_object({}) do |(dir, pattern), grouped|
|
|
130
|
+
outer = dirs.select { |other| inside?(dir, other) }.min_by(&:size)
|
|
131
|
+
list = grouped[outer] ||= []
|
|
132
|
+
list << rebase(pattern, dir, outer) if pattern
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Whether +dir+ is +other+ or lies beneath it.
|
|
137
|
+
def inside?(dir, other) = other == dir || other == "." || dir.start_with?("#{other}/")
|
|
138
|
+
|
|
139
|
+
# +pattern+ (relative to +dir+) made relative to +outer+, which holds dir.
|
|
140
|
+
def rebase(pattern, dir, outer)
|
|
141
|
+
return pattern if dir == outer
|
|
142
|
+
|
|
143
|
+
"#{outer == '.' ? dir : dir.delete_prefix("#{outer}/")}/#{pattern}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# [directory, Regexp or nil] for one paths: entry (see split_path).
|
|
88
147
|
# "lib/**/*.rb" => ["lib", /\A(?:.*\/)?[^\/]*\.rb\z/]
|
|
89
148
|
# "lib/asgard.rb" => ["lib", /\Aasgard\.rb\z/]
|
|
90
149
|
# "docs" => ["docs", nil]
|
|
91
150
|
# The Regexp is matched against the path relative to the directory,
|
|
92
|
-
# which is how Listen applies only:.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
151
|
+
# which is how Listen applies only:.
|
|
152
|
+
def split_glob(path, stat: File, root: Dir.pwd)
|
|
153
|
+
dir, pattern = split_path(path, stat:, root:)
|
|
154
|
+
[dir, pattern && glob_regexp(pattern)]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# [directory, glob pattern relative to it, or nil] for one paths:
|
|
158
|
+
# entry; "." and empty segments are dropped. A plain path is looked
|
|
159
|
+
# up under +root+ (the project root, wherever asgard runs from) with
|
|
160
|
+
# stat (File, or a stand-in in tests): a directory there is a
|
|
161
|
+
# directory, dots and all; otherwise it names a file when one is
|
|
162
|
+
# there, when it has an extension, or when it is a dotfile (.env), so
|
|
163
|
+
# a file that does not exist yet can be watched.
|
|
164
|
+
def split_path(path, stat: File, root: Dir.pwd)
|
|
165
|
+
segments = path.split("/").reject { |segment| segment.empty? || segment == "." }
|
|
97
166
|
first = segments.index { |segment| segment.match?(GLOB_CHARS) }
|
|
98
|
-
return [directory(segments[0...first]),
|
|
167
|
+
return [directory(segments[0...first]), segments[first..].join("/")] if first
|
|
99
168
|
|
|
100
169
|
*parents, last = segments
|
|
101
|
-
|
|
102
|
-
return [directory(segments), nil]
|
|
170
|
+
full = File.expand_path(path, root)
|
|
171
|
+
return [directory(segments), nil] if last.nil? || stat.directory?(full) || !file_name?(last, full, stat)
|
|
103
172
|
|
|
104
|
-
[directory(parents),
|
|
173
|
+
[directory(parents), last]
|
|
105
174
|
end
|
|
106
175
|
|
|
176
|
+
def file_name?(name, full, stat) = stat.file?(full) || !File.extname(name).empty? || name.start_with?(".")
|
|
177
|
+
|
|
178
|
+
# Where plain paths are looked up: the directory of the nearest .loki,
|
|
179
|
+
# or the current directory without one.
|
|
180
|
+
def declaration_root = (loki_up&.dirname || Dir.pwd).to_s
|
|
181
|
+
|
|
107
182
|
def directory(segments) = segments.empty? ? "." : segments.join("/")
|
|
108
183
|
|
|
109
184
|
# The Regexp a glob matches, shell-style: ** spans directories, * and ?
|
|
110
|
-
# stay within one, {a,b} is an alternative
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
185
|
+
# stay within one, {a,b} is an alternative (nested braces too), [ab]
|
|
186
|
+
# and [!ab] are character classes. Anything else, an unclosed { or [
|
|
187
|
+
# included, is literal.
|
|
188
|
+
def glob_regexp(pattern) = /\A#{glob_body(pattern)}\z/
|
|
189
|
+
|
|
190
|
+
def glob_body(pattern) = pattern.gsub(GLOB_TOKENS) { |token| glob_token(token) }
|
|
115
191
|
|
|
116
192
|
def glob_token(token)
|
|
117
193
|
case token
|
|
@@ -119,11 +195,30 @@ module Asgard
|
|
|
119
195
|
when "**" then ".*"
|
|
120
196
|
when "*" then "[^/]*"
|
|
121
197
|
when "?" then "[^/]"
|
|
122
|
-
when /\A\{(.*)\}\z/ then "(?:#{Regexp.last_match(1)
|
|
198
|
+
when /\A\{(.*)\}\z/m then "(?:#{brace_alternatives(Regexp.last_match(1)).map { |alt| glob_body(alt) }.join('|')})"
|
|
199
|
+
when /\A\[(.+)\]\z/m then char_class(Regexp.last_match(1))
|
|
123
200
|
else Regexp.escape(token)
|
|
124
201
|
end
|
|
125
202
|
end
|
|
126
203
|
|
|
204
|
+
# The comma-separated alternatives of a brace's body, splitting only
|
|
205
|
+
# at commas outside any nested brace: "a,{b,c}" => ["a", "{b,c}"].
|
|
206
|
+
def brace_alternatives(body)
|
|
207
|
+
depth = 0
|
|
208
|
+
body.each_char.with_object([+""]) do |char, parts|
|
|
209
|
+
depth += BRACE_DEPTH[char]
|
|
210
|
+
char == "," && depth.zero? ? parts << +"" : parts.last << char
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# A glob character class body ("ab", "!a", "a-c") as a Regexp class
|
|
215
|
+
# that never matches the directory separator.
|
|
216
|
+
def char_class(body)
|
|
217
|
+
negated = body.size > 1 && body.start_with?("!", "^")
|
|
218
|
+
body = body[1..] if negated
|
|
219
|
+
"(?!/)[#{'^' if negated}#{body.gsub(/[\\\[\]^&]/) { |char| "\\#{char}" }}]"
|
|
220
|
+
end
|
|
221
|
+
|
|
127
222
|
# One only: Regexp (or nil for no filter) from the patterns the paths
|
|
128
223
|
# produced and the declared only:. Within each group any may match;
|
|
129
224
|
# when both groups are present a path must satisfy one from each.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
3
5
|
require "ractor_queue"
|
|
4
6
|
|
|
5
7
|
module Asgard
|
|
@@ -8,7 +10,8 @@ module Asgard
|
|
|
8
10
|
# the declarations: a dispatch table from listener id (the entry name)
|
|
9
11
|
# to its spec, and one worker per distinct task, so two declarations
|
|
10
12
|
# for the same task share a worker and never overlap. Nothing here is
|
|
11
|
-
# mutated after construction, and the loop never
|
|
13
|
+
# mutated after construction but a spill counter, and the loop never
|
|
14
|
+
# blocks on a worker: a trigger for a full queue is dropped and reported.
|
|
12
15
|
class Dispatcher
|
|
13
16
|
# Triggers a busy task can have waiting. RactorQueue rounds this up.
|
|
14
17
|
QUEUE_CAPACITY = 256
|
|
@@ -22,13 +25,24 @@ module Asgard
|
|
|
22
25
|
@context = context
|
|
23
26
|
@reports = reports
|
|
24
27
|
@clock = clock
|
|
28
|
+
@spills = 0
|
|
25
29
|
@dispatch = specs.to_h { |spec| [spec[:name], spec] }.freeze
|
|
26
30
|
@workers = specs.map { |spec| spec[:task] }.uniq.to_h do |task|
|
|
27
|
-
queue
|
|
28
|
-
|
|
31
|
+
queue = RactorQueue.new(capacity: QUEUE_CAPACITY)
|
|
32
|
+
ractor = start.call(queue, reports)
|
|
33
|
+
[task, { queue:, ractor:, mailbox: Mailbox.new(queue, ractor) }.freeze]
|
|
29
34
|
end.freeze
|
|
30
35
|
end
|
|
31
36
|
|
|
37
|
+
# Every item waiting on +queue+, oldest first, leaving it empty.
|
|
38
|
+
def self.take_waiting(queue)
|
|
39
|
+
items = []
|
|
40
|
+
until (item = queue.try_pop).equal?(RactorQueue::EMPTY)
|
|
41
|
+
items << item
|
|
42
|
+
end
|
|
43
|
+
items
|
|
44
|
+
end
|
|
45
|
+
|
|
32
46
|
# Pops [listener id, batch] pairs from inbox until it pops nil.
|
|
33
47
|
def run(inbox)
|
|
34
48
|
while (id, batch = inbox.pop)
|
|
@@ -38,17 +52,54 @@ module Asgard
|
|
|
38
52
|
|
|
39
53
|
def start(inbox) = Thread.new { run(inbox) }
|
|
40
54
|
|
|
41
|
-
# One batch: drop the kinds the entry does not watch,
|
|
42
|
-
#
|
|
43
|
-
#
|
|
55
|
+
# One batch: drop the kinds the entry does not watch, report the
|
|
56
|
+
# trigger, and queue it for the task's worker, merged with any trigger
|
|
57
|
+
# of the same entry still waiting there, so a busy task runs once more
|
|
58
|
+
# with every change rather than once per save. The queued description,
|
|
59
|
+
# or nil when nothing was left to run or the queue was full.
|
|
44
60
|
def route(id, batch)
|
|
45
61
|
spec = @dispatch.fetch(id)
|
|
46
62
|
changes = Watch.filter_events(batch, spec[:events]) or return nil
|
|
47
63
|
task = spec[:task]
|
|
48
|
-
|
|
49
|
-
@reports.push(Ractor.make_shareable({ kind: :trigger,
|
|
50
|
-
@workers.fetch(task)[:
|
|
51
|
-
|
|
64
|
+
report = { entry: spec[:name], task:, at: @clock.now.to_f, changes: changes.to_h }
|
|
65
|
+
@reports.push(Ractor.make_shareable({ kind: :trigger, **report }))
|
|
66
|
+
enqueue(spec, changes, @workers.fetch(task)[:mailbox]) || drop(report)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# Takes back what is still waiting in +mailbox+, folds this entry's
|
|
72
|
+
# waiting triggers into the new one and queues the rest back first, in
|
|
73
|
+
# their order. A worker that took one meanwhile simply runs it.
|
|
74
|
+
def enqueue(spec, changes, mailbox)
|
|
75
|
+
same, others = Dispatcher.take_waiting(mailbox.queue).partition { |item| item.is_a?(Hash) && item[:entry] == spec[:name] }
|
|
76
|
+
same.each { |item| Worker.remove_changes_file(item[:changes_file]) }
|
|
77
|
+
merged = Watch.merge_changes([*same.map { |item| Changes.new(**item[:changes]) }, changes])
|
|
78
|
+
description = Spawn.description(spec, merged, @context, changes_file: next_changes_file(spec))
|
|
79
|
+
others.each { |item| mailbox.try_push(item) }
|
|
80
|
+
write_changes_file(description) # before the push: the worker may start it at once
|
|
81
|
+
return description if mailbox.try_push(description)
|
|
82
|
+
|
|
83
|
+
Worker.remove_changes_file(description[:changes_file])
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def drop(report)
|
|
88
|
+
@reports.push(Ractor.make_shareable({ kind: :dropped, **report }))
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Where this trigger's change lists go if they are too big for the
|
|
93
|
+
# environment; the file is only written when Spawn spilled to it.
|
|
94
|
+
def next_changes_file(spec)
|
|
95
|
+
@spills += 1
|
|
96
|
+
Spawn.changes_file(@context, spec[:name], @spills)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def write_changes_file(description)
|
|
100
|
+
path = description[:changes_file] or return
|
|
101
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
102
|
+
File.write(path, JSON.generate(description[:changes]))
|
|
52
103
|
end
|
|
53
104
|
end
|
|
54
105
|
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ractor_queue"
|
|
4
|
+
|
|
5
|
+
module Asgard
|
|
6
|
+
module Watch
|
|
7
|
+
# A bounded RactorQueue whose consumer sleeps instead of spinning.
|
|
8
|
+
# RactorQueue#pop polls every 100µs, which an idle daemon with a few
|
|
9
|
+
# workers pays for as a constant few percent of a core. Here the
|
|
10
|
+
# consumer pops with try_pop and, when the queue is empty, blocks in
|
|
11
|
+
# Ractor.receive; every push sends the consumer's Ractor (+owner+) a
|
|
12
|
+
# wake-up. A wake-up sent after the push can never be missed, and an
|
|
13
|
+
# extra one only costs one more empty try_pop. Ractor.receive is
|
|
14
|
+
# Ruby 3.x and 4.x alike.
|
|
15
|
+
Mailbox = Data.define(:queue, :owner) do
|
|
16
|
+
def self.ring(ractor)
|
|
17
|
+
ractor << :ring if ractor.is_a?(Ractor)
|
|
18
|
+
rescue Ractor::ClosedError
|
|
19
|
+
nil # the consumer has ended; nothing is waiting
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Blocking push (RactorQueue#push spins only while the queue is full).
|
|
23
|
+
def push(item)
|
|
24
|
+
queue.push(item)
|
|
25
|
+
Mailbox.ring(owner)
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# false when the queue is full. Named after RactorQueue#try_push.
|
|
30
|
+
def try_push(item) # rubocop:disable Naming/PredicateMethod
|
|
31
|
+
return false unless queue.try_push(item)
|
|
32
|
+
|
|
33
|
+
Mailbox.ring(owner)
|
|
34
|
+
true
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Must be called in +owner+, the Ractor the wake-ups go to.
|
|
38
|
+
def pop
|
|
39
|
+
loop do
|
|
40
|
+
item = queue.try_pop
|
|
41
|
+
return item unless item.equal?(RactorQueue::EMPTY)
|
|
42
|
+
|
|
43
|
+
Ractor.receive
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def empty? = queue.empty?
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "time"
|
|
4
|
+
require_relative "../pid"
|
|
5
|
+
require_relative "../state_dir"
|
|
4
6
|
|
|
5
7
|
module Asgard
|
|
6
8
|
module Watch
|
|
@@ -8,7 +10,8 @@ module Asgard
|
|
|
8
10
|
# the dispatcher push frozen reports onto a shared queue; the reporter
|
|
9
11
|
# drains it in a main-Ractor thread, appends a log line per report,
|
|
10
12
|
# keeps the latest record per entry on disk through the state, and
|
|
11
|
-
# tracks which pids are in flight so shutdown can
|
|
13
|
+
# tracks which pids are in flight so shutdown can say what it waits
|
|
14
|
+
# for.
|
|
12
15
|
class Reporter
|
|
13
16
|
class << self
|
|
14
17
|
# One log line for a report. behind: is the pid a trigger is queued
|
|
@@ -20,40 +23,58 @@ module Asgard
|
|
|
20
23
|
case kind
|
|
21
24
|
when :trigger then "#{head} #{changed}#{" (queued behind pid #{behind})" if behind}"
|
|
22
25
|
when :spawn then "#{head} pid=#{pid} #{changed}"
|
|
23
|
-
|
|
26
|
+
when :dropped then "#{head} #{changed} (dropped: the task's queue is full)"
|
|
27
|
+
else exit_line(head, report)
|
|
24
28
|
end
|
|
25
29
|
end
|
|
26
30
|
|
|
31
|
+
def exit_line(head, report)
|
|
32
|
+
line = "#{head} pid=#{report[:pid]} status=#{report[:status]} seconds=#{format('%.2f', report[:seconds])}"
|
|
33
|
+
report[:error] ? "#{line} error=#{report[:error]}" : line
|
|
34
|
+
end
|
|
35
|
+
|
|
27
36
|
# The entry's record after this report: a spawn starts a new record
|
|
28
37
|
# (no finished/status yet, which is how `list` shows "running"), an
|
|
29
|
-
# exit completes the previous one
|
|
38
|
+
# exit completes the previous one when it is the same run (same
|
|
39
|
+
# pid). A failed spawn (no pid) is a record of its own, with the
|
|
40
|
+
# error, so it never wears the previous run's pid and start time.
|
|
30
41
|
def entry_record(report, previous = nil)
|
|
31
42
|
entry, task, pid, at = report.values_at(:entry, :task, :pid, :at)
|
|
32
43
|
stamp = Time.at(at).iso8601
|
|
33
44
|
return { entry:, task:, pid:, started: stamp, changes: report[:changes] } if report[:kind] == :spawn
|
|
34
45
|
|
|
35
|
-
|
|
46
|
+
run = previous.to_h[:pid] == pid ? previous.to_h : {}
|
|
47
|
+
record = { entry:, task:, pid:, **run, finished: stamp, status: report[:status], seconds: report[:seconds] }
|
|
48
|
+
report[:error] ? record.merge(error: report[:error]) : record
|
|
36
49
|
end
|
|
37
50
|
|
|
38
51
|
# The changed paths of a trigger or spawn report, space-separated; "" for an exit.
|
|
39
52
|
def paths(report) = report.fetch(:changes) { {} }.values.flatten.join(" ")
|
|
40
53
|
|
|
41
54
|
# Whether a process exists; one we may not signal still exists.
|
|
42
|
-
def alive?(pid)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
55
|
+
def alive?(pid) = Pid.alive?(pid)
|
|
56
|
+
|
|
57
|
+
# The command line of a live process, or nil (see Asgard::Pid).
|
|
58
|
+
def command_line(pid) = Pid.command_line(pid)
|
|
59
|
+
|
|
60
|
+
# Whether pid is alive and is an asgard process whose command line
|
|
61
|
+
# carries every word: ("watcher", "run") for a daemon, a task name
|
|
62
|
+
# for a trigger. The current process always counts. A pidfile left
|
|
63
|
+
# by a crash or a reboot can name a pid that an unrelated process
|
|
64
|
+
# now holds, which a bare liveness check would call ours.
|
|
65
|
+
def asgard_process?(pid, *words, command: nil) = Pid.matches?(pid, "asgard", *words, command:)
|
|
50
66
|
end
|
|
51
67
|
|
|
52
|
-
# state: responds to write_entry(name, record).
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
68
|
+
# state: responds to write_entry(name, record). max_log: is the size
|
|
69
|
+
# past which the log is rotated (StateDir.rotate); on_rotate: is
|
|
70
|
+
# called after each rotation, so the daemon can move its own output
|
|
71
|
+
# to the new log.
|
|
72
|
+
def initialize(reports, log:, state:, max_log: StateDir.log_limit, on_rotate: nil)
|
|
73
|
+
@reports = reports
|
|
74
|
+
@log = log
|
|
75
|
+
@max_log = max_log
|
|
76
|
+
@on_rotate = on_rotate
|
|
77
|
+
@state = state
|
|
57
78
|
@records = {}
|
|
58
79
|
@running = {} # pid => task
|
|
59
80
|
@mutex = Mutex.new
|
|
@@ -68,30 +89,29 @@ module Asgard
|
|
|
68
89
|
|
|
69
90
|
def start = Thread.new { run }
|
|
70
91
|
|
|
92
|
+
# Tracks the report, then logs it. A report that cannot be written
|
|
93
|
+
# (the state directory removed under a running daemon) is said so on
|
|
94
|
+
# stderr; the reporter goes on, since every worker waits on it.
|
|
71
95
|
def handle(report)
|
|
72
|
-
|
|
96
|
+
kind = report[:kind]
|
|
97
|
+
trigger = kind == :trigger
|
|
73
98
|
line = Reporter.log_line(report, behind: trigger ? running_for(report[:task]) : nil)
|
|
99
|
+
track(report) if %i[spawn exit].include?(kind)
|
|
100
|
+
@on_rotate&.call if StateDir.rotate(@log, max_bytes: @max_log)
|
|
74
101
|
File.open(@log, "a") { |file| file.puts(line) }
|
|
75
|
-
|
|
102
|
+
rescue SystemCallError, IOError => e
|
|
103
|
+
$stderr.puts "watcher: could not write #{kind} report for #{report[:entry]}: #{e.message}" # rubocop:disable Style/StderrPuts
|
|
76
104
|
end
|
|
77
105
|
|
|
78
106
|
# Pids of tasks in flight, ascending.
|
|
79
107
|
def running = @mutex.synchronize { @running.keys.sort }
|
|
80
108
|
|
|
109
|
+
# Tasks in flight, as { pid => task name }.
|
|
110
|
+
def running_tasks = @mutex.synchronize { @running.dup }
|
|
111
|
+
|
|
81
112
|
# The in-flight pid of task, or nil.
|
|
82
113
|
def running_for(task) = @mutex.synchronize { @running.key(task) }
|
|
83
114
|
|
|
84
|
-
# Blocks until no task is in flight, saying what it waits for once
|
|
85
|
-
# per interval. A pid that is gone without an exit report (a worker
|
|
86
|
-
# died between its reports) is forgotten rather than waited on.
|
|
87
|
-
def drain(interval: 1.0, out: $stdout)
|
|
88
|
-
until (pids = running).empty?
|
|
89
|
-
out.puts "watcher: waiting for pid #{pids.join(', ')}"
|
|
90
|
-
sleep interval
|
|
91
|
-
forget_dead
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
|
|
95
115
|
private
|
|
96
116
|
|
|
97
117
|
def track(report)
|
|
@@ -101,10 +121,6 @@ module Asgard
|
|
|
101
121
|
@state.write_entry(entry, record)
|
|
102
122
|
@mutex.synchronize { record.key?(:finished) ? @running.delete(pid) : @running[pid] = record[:task] }
|
|
103
123
|
end
|
|
104
|
-
|
|
105
|
-
def forget_dead
|
|
106
|
-
@mutex.synchronize { @running.delete_if { |pid, _task| !Reporter.alive?(pid) } }
|
|
107
|
-
end
|
|
108
124
|
end
|
|
109
125
|
end
|
|
110
126
|
end
|
data/lib/asgard/watch/spawn.rb
CHANGED
|
@@ -14,8 +14,18 @@ module Asgard
|
|
|
14
14
|
# The only variables a task inherits from the daemon. Everything else
|
|
15
15
|
# it sees comes from the declaration's env:, from .env and .envrc
|
|
16
16
|
# loaded in the task's own process, or from the trigger itself.
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
# RUBYOPT, RUBYLIB, GEM_*, RUBY_*, RBENV_* and BUNDLE(R)_* carry the
|
|
18
|
+
# Ruby setup the daemon itself was started under (an rvm gemset,
|
|
19
|
+
# chruby's RUBY_ROOT, rbenv's RBENV_VERSION, a vendored bundle,
|
|
20
|
+
# `bundle exec`), without which the task's own asgard may not load.
|
|
21
|
+
BASE_ENV_KEYS = %w[PATH HOME USER LOGNAME SHELL LANG TMPDIR SSH_AUTH_SOCK RUBYOPT RUBYLIB].freeze
|
|
22
|
+
BASE_ENV_PATTERN = /\A(?:LC_|BUNDLER?_|GEM_|RUBY_|RBENV_)/
|
|
23
|
+
|
|
24
|
+
# Bytes of change lists the environment carries; past this they go
|
|
25
|
+
# to a file named in ASGARD_CHANGES_FILE. Argument and environment
|
|
26
|
+
# space together is about 1 MB on macOS, and a branch switch under a
|
|
27
|
+
# ** watch can change thousands of files.
|
|
28
|
+
CHANGES_ENV_LIMIT = 128 * 1024
|
|
19
29
|
|
|
20
30
|
# What every trigger of one daemon run shares: the real project root,
|
|
21
31
|
# the log file tasks append to, the base environment captured at
|
|
@@ -29,10 +39,29 @@ module Asgard
|
|
|
29
39
|
|
|
30
40
|
# The task's full environment, lowest precedence first: the context's
|
|
31
41
|
# base, the declaration's env:, the trigger variables.
|
|
32
|
-
def environment(spec, changes, context)
|
|
33
|
-
context.base.merge(spec[:env], { "ASGARD_WATCH" => spec[:name], "ASGARD_WATCH_ROOT" => context.root },
|
|
42
|
+
def environment(spec, changes, context, changes_file: nil)
|
|
43
|
+
context.base.merge(spec[:env], { "ASGARD_WATCH" => spec[:name], "ASGARD_WATCH_ROOT" => context.root },
|
|
44
|
+
change_env(changes, changes_file))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The trigger variables for +changes+, or only the name of +file+
|
|
48
|
+
# when there is a file to use and they would pass +limit+ bytes or
|
|
49
|
+
# a path holds a newline, which the one-per-line variables cannot
|
|
50
|
+
# carry.
|
|
51
|
+
def change_env(changes, file, limit: CHANGES_ENV_LIMIT)
|
|
52
|
+
vars = changes.to_env
|
|
53
|
+
return vars if file.nil? || fits_env?(vars, changes, limit)
|
|
54
|
+
|
|
55
|
+
{ Changes::FILE_VARIABLE => file }
|
|
34
56
|
end
|
|
35
57
|
|
|
58
|
+
def fits_env?(vars, changes, limit)
|
|
59
|
+
vars.sum { |key, value| key.bytesize + value.bytesize } <= limit && changes.all.none? { |path| path.include?("\n") }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The spill file for the +sequence+th trigger of +entry+, beside the log.
|
|
63
|
+
def changes_file(context, entry, sequence) = File.join(File.dirname(context.log), "changes", "#{entry}-#{sequence}.json")
|
|
64
|
+
|
|
36
65
|
# The task's argv: the context's asgard executable by absolute path, so
|
|
37
66
|
# a trigger runs the very asgard that started the watcher whatever PATH
|
|
38
67
|
# says, wrapped in direnv exec when available so .envrc loads in the
|
|
@@ -57,18 +86,14 @@ module Asgard
|
|
|
57
86
|
|
|
58
87
|
# Everything a worker needs to run one trigger, deep-frozen and
|
|
59
88
|
# Ractor-shareable: Strings, Arrays and Hashes only.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
log: context.log,
|
|
69
|
-
changes: changes.to_h
|
|
70
|
-
}
|
|
71
|
-
)
|
|
89
|
+
# +changes_file+ is where the lists may spill; the description names
|
|
90
|
+
# it as :changes_file only when they did.
|
|
91
|
+
def description(spec, changes, context, changes_file: nil)
|
|
92
|
+
env = environment(spec, changes, context, changes_file:)
|
|
93
|
+
desc = { entry: spec[:name], task: spec[:task], argv: argv(spec, context), env:,
|
|
94
|
+
root: context.root, log: context.log, changes: changes.to_h }
|
|
95
|
+
desc[:changes_file] = changes_file if env.key?(Changes::FILE_VARIABLE)
|
|
96
|
+
Ractor.make_shareable(desc)
|
|
72
97
|
end
|
|
73
98
|
end
|
|
74
99
|
end
|