glazed 0.1.0 → 0.2.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 +19 -0
- data/lib/glazed/collector.rb +10 -6
- data/lib/glazed/command/base.rb +3 -18
- data/lib/glazed/command/orchestrate.rb +1 -1
- data/lib/glazed/command/run.rb +1 -1
- data/lib/glazed/command/send_signal.rb +21 -13
- data/lib/glazed/config.rb +50 -0
- data/lib/glazed/coordinator/task_runner.rb +189 -0
- data/lib/glazed/coordinator.rb +252 -0
- data/lib/glazed/dialect/run.rb +9 -3
- data/lib/glazed/dialect/sensitive_pattern.rb +7 -2
- data/lib/glazed/orchestrator/policy.rb +28 -0
- data/lib/glazed/orchestrator.rb +15 -65
- data/lib/glazed/proto.rb +1 -0
- data/lib/glazed/redactor.rb +65 -26
- data/lib/glazed/secret.rb +12 -37
- data/lib/glazed/secrets/command.rb +66 -0
- data/lib/glazed/secrets.rb +27 -5
- data/lib/glazed/secrets_resolver.rb +85 -0
- data/lib/glazed/shell_command.rb +41 -6
- data/lib/glazed/sources/command.rb +11 -8
- data/lib/glazed/state_backends/base.rb +11 -1
- data/lib/glazed/state_backends/sqlite.rb +38 -1
- data/lib/glazed/step_context.rb +26 -8
- data/lib/glazed/steps/{batch_shell.rb → batch_command.rb} +15 -15
- data/lib/glazed/steps/{shell.rb → command.rb} +21 -18
- data/lib/glazed/steps/fetch.rb +1 -1
- data/lib/glazed/steps/redact.rb +3 -1
- data/lib/glazed/storage_context.rb +22 -0
- data/lib/glazed/storages/base.rb +3 -2
- data/lib/glazed/storages/filesystem.rb +3 -2
- data/lib/glazed/storages/git.rb +25 -8
- data/lib/glazed/storages/github.rb +22 -8
- data/lib/glazed/storages/memory.rb +3 -2
- data/lib/glazed/storages.rb +6 -4
- data/lib/glazed/transports/base.rb +14 -2
- data/lib/glazed/transports/ssh.rb +55 -2
- data/lib/glazed/transports/telnet.rb +4 -2
- data/lib/glazed/v1/config_pb.rb +2 -1
- data/lib/glazed/v1/coordinator_pb.rb +34 -0
- data/lib/glazed/v1/inventory_pb.rb +5 -1
- data/lib/glazed/v1/orchestrator_pb.rb +1 -1
- data/lib/glazed/v1/redaction_pb.rb +2 -1
- data/lib/glazed/v1/secret_pb.rb +3 -1
- data/lib/glazed/v1/state_pb.rb +2 -1
- data/lib/glazed/v1/storage_pb.rb +1 -1
- data/lib/glazed/v1/workflow_pb.rb +3 -3
- data/lib/glazed/version.rb +1 -1
- metadata +10 -3
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
require 'securerandom'
|
|
7
|
+
require 'time'
|
|
8
|
+
require 'socket'
|
|
9
|
+
require 'glazed'
|
|
10
|
+
require 'glazed/must'
|
|
11
|
+
require 'glazed/orchestrator/policy'
|
|
12
|
+
require 'glazed/proto'
|
|
13
|
+
require 'glazed/runner'
|
|
14
|
+
require 'glazed/state_backends/base'
|
|
15
|
+
|
|
16
|
+
module Glazed
|
|
17
|
+
# Changes node schedules in a state backend as the orchestrated workflows
|
|
18
|
+
# run: follows the inventory, applies signals, leases due nodes and records
|
|
19
|
+
# the outcome of their runs. {Orchestrator} drives it in one process; an
|
|
20
|
+
# external coordinator such as AWS Step Functions drives it one call at a time.
|
|
21
|
+
#
|
|
22
|
+
# @example
|
|
23
|
+
# coordinator = Glazed::Coordinator.new(settings: config.orchestrator, backend:)
|
|
24
|
+
# coordinator.sync_inventory(nodes.map(&:name))
|
|
25
|
+
# owner, names = coordinator.claim('backup')
|
|
26
|
+
# coordinator.complete('backup', owner:, names:, errors: {})
|
|
27
|
+
class Coordinator
|
|
28
|
+
# Seconds a lease lasts when the settings set none.
|
|
29
|
+
DEFAULT_LEASE = 3600 #: Integer
|
|
30
|
+
# Seconds between checks for due nodes when the settings set none.
|
|
31
|
+
DEFAULT_POLL = 30 #: Integer
|
|
32
|
+
|
|
33
|
+
# @param settings [Proto::V1::Orchestrator] orchestrator settings
|
|
34
|
+
# @param backend [StateBackends::Base] where schedules are kept
|
|
35
|
+
# @param listener [Proc] receives events of schedule changes
|
|
36
|
+
# @param clock [Proc] returns the current time
|
|
37
|
+
# @param random [Random] source of jitter
|
|
38
|
+
# @param owner [String] identifies this process in leases
|
|
39
|
+
#: (settings: Proto::V1::Orchestrator, backend: StateBackends::Base, ?listener: Runner::listener, ?clock: ^() -> Time, ?random: Random, ?owner: String) -> void
|
|
40
|
+
def initialize(settings:, backend:, listener: ->(_event) {}, clock: -> { Time.now }, random: Random.new,
|
|
41
|
+
owner: "#{Socket.gethostname}:#{Process.pid}:#{SecureRandom.hex(4)}")
|
|
42
|
+
@settings = settings #: Proto::V1::Orchestrator
|
|
43
|
+
@backend = backend #: StateBackends::Base
|
|
44
|
+
@listener = listener #: Runner::listener
|
|
45
|
+
@clock = clock #: ^() -> Time
|
|
46
|
+
@random = random #: Random
|
|
47
|
+
@owner = owner #: String
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Proto::V1::Orchestrator] orchestrator settings
|
|
51
|
+
#: Proto::V1::Orchestrator
|
|
52
|
+
attr_reader :settings
|
|
53
|
+
# @return [StateBackends::Base] where schedules are kept
|
|
54
|
+
#: StateBackends::Base
|
|
55
|
+
attr_reader :backend
|
|
56
|
+
# @return [String] identifies this process in leases
|
|
57
|
+
#: String
|
|
58
|
+
attr_reader :owner
|
|
59
|
+
|
|
60
|
+
# @return [Array<String>] names of the orchestrated workflows
|
|
61
|
+
#: () -> Array[String]
|
|
62
|
+
def workflows = @settings.workflows.keys
|
|
63
|
+
|
|
64
|
+
# @return [Integer] seconds between checks for due nodes
|
|
65
|
+
#: () -> Integer
|
|
66
|
+
def poll_seconds = @settings.poll_seconds.zero? ? DEFAULT_POLL : @settings.poll_seconds
|
|
67
|
+
|
|
68
|
+
# @return [Integer] the longest seconds a lease of any workflow lasts
|
|
69
|
+
#: () -> Integer
|
|
70
|
+
def longest_lease = @settings.workflows.values.map { lease_seconds(it) }.max || DEFAULT_LEASE
|
|
71
|
+
|
|
72
|
+
# @param workflow [String] workflow name
|
|
73
|
+
# @return [Proto::V1::Orchestration] how the workflow is orchestrated
|
|
74
|
+
# @raise [ArgumentError] when the workflow is not orchestrated
|
|
75
|
+
#: (String workflow) -> Proto::V1::Orchestration
|
|
76
|
+
def orchestration(workflow)
|
|
77
|
+
@settings.workflows[workflow] or raise ArgumentError, "workflow #{workflow.inspect} is not orchestrated"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Adds records for nodes new to the inventory, due within the schedule's
|
|
81
|
+
# jitter, and deletes those of nodes gone, in every workflow.
|
|
82
|
+
#
|
|
83
|
+
# @param nodes [Array<String>] names of every node in the inventory
|
|
84
|
+
# @return [void]
|
|
85
|
+
#: (Array[String] nodes) -> void
|
|
86
|
+
def sync_inventory(nodes)
|
|
87
|
+
now = @clock.call
|
|
88
|
+
workflows.each do |workflow|
|
|
89
|
+
known = @backend.list(workflow).map(&:node)
|
|
90
|
+
added = nodes - known
|
|
91
|
+
removed = known - nodes
|
|
92
|
+
next if added.empty? && removed.empty?
|
|
93
|
+
|
|
94
|
+
schedule = orchestration(workflow).schedule
|
|
95
|
+
@backend.update_all(workflow, added + removed) do |node, record|
|
|
96
|
+
next nil if removed.include?(node)
|
|
97
|
+
|
|
98
|
+
record || Orchestrator::Policy.discovered(workflow, node, now:, schedule:, random: @random)
|
|
99
|
+
end
|
|
100
|
+
notify(level: 'info', workflow:, message: 'inventory changed', nodes: nodes.size, added:, removed:)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Applies signals with each workflow's debounce, one change per workflow.
|
|
105
|
+
# Signals naming a node or workflow unknown here are reported and skipped.
|
|
106
|
+
#
|
|
107
|
+
# @param signals [Array<Proto::V1::Signal>] the signals
|
|
108
|
+
# @param nodes [Array<String>] names of every node in the inventory, which all_nodes signals stand for
|
|
109
|
+
# @return [Hash{String => Hash{String => Proto::V1::NodeSchedule}}] the resulting records, by workflow and node name
|
|
110
|
+
#: (Array[Proto::V1::Signal] signals, nodes: Array[String]) -> Hash[String, Hash[String, Proto::V1::NodeSchedule]]
|
|
111
|
+
def signal(signals, nodes:)
|
|
112
|
+
now = @clock.call
|
|
113
|
+
pending = {} #: Hash[String, Hash[String, Array[Proto::V1::Signal]]]
|
|
114
|
+
signals.each do |signal|
|
|
115
|
+
targets = signal.all_nodes ? nodes : [signal.node]
|
|
116
|
+
unknown_nodes = targets - nodes
|
|
117
|
+
targets -= unknown_nodes
|
|
118
|
+
notify(level: 'warn', message: 'signal for unknown nodes skipped', source: signal.source, nodes: unknown_nodes) unless unknown_nodes.empty?
|
|
119
|
+
|
|
120
|
+
chosen = signal.workflows.empty? ? workflows : signal.workflows.to_a
|
|
121
|
+
unknown_workflows = chosen - workflows
|
|
122
|
+
unless unknown_workflows.empty?
|
|
123
|
+
notify(level: 'warn', message: 'signal for workflows not orchestrated skipped', source: signal.source, workflows: unknown_workflows)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
(chosen - unknown_workflows).product(targets).each do |workflow, node|
|
|
127
|
+
((pending[workflow] ||= {})[node] ||= []) << for_node(signal, node)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
pending.to_h do |workflow, by_node|
|
|
132
|
+
debounce = orchestration(workflow).debounce
|
|
133
|
+
records = @backend.update_all(workflow, by_node.keys) do |node, record|
|
|
134
|
+
by_node.fetch(node).inject(record) { |current, signal| Orchestrator::Policy.signal(current, signal, workflow:, now:, debounce:) }
|
|
135
|
+
end
|
|
136
|
+
[workflow, records]
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Leases due nodes of a workflow, at most the workflow's batch size.
|
|
141
|
+
#
|
|
142
|
+
# @param workflow [String] workflow name
|
|
143
|
+
# @return [Array(String, Array<String>)] identifier of the batch, and names of the leased nodes
|
|
144
|
+
#: (String workflow) -> [String, Array[String]]
|
|
145
|
+
def claim(workflow)
|
|
146
|
+
now = @clock.call
|
|
147
|
+
owner = "#{@owner}:#{SecureRandom.hex(4)}"
|
|
148
|
+
settings = orchestration(workflow)
|
|
149
|
+
limit = settings.batch_size.zero? ? nil : settings.batch_size
|
|
150
|
+
due = @backend.list_due(workflow, now:, limit:)
|
|
151
|
+
return [owner, []] if due.empty?
|
|
152
|
+
|
|
153
|
+
lease_seconds = lease_seconds(settings)
|
|
154
|
+
records = @backend.update_all(workflow, due.map(&:node)) do |_node, record|
|
|
155
|
+
Orchestrator::Policy.claim(record, owner:, now:, lease_seconds:)
|
|
156
|
+
end
|
|
157
|
+
[owner, records.values.select { it.lease_owner == owner }.map(&:node)]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Records the outcome of a batch and schedules each node's next run.
|
|
161
|
+
# Records leased to another batch, and records deleted meanwhile, stay as they are.
|
|
162
|
+
#
|
|
163
|
+
# @param workflow [String] workflow name
|
|
164
|
+
# @param owner [String] identifier of the batch
|
|
165
|
+
# @param names [Array<String>] names of the nodes the batch ran
|
|
166
|
+
# @param errors [Hash{String => String}] why nodes failed, by node name
|
|
167
|
+
# @return [void]
|
|
168
|
+
#: (String workflow, owner: String, names: Array[String], errors: Hash[String, String]) -> void
|
|
169
|
+
def complete(workflow, owner:, names:, errors:)
|
|
170
|
+
finished_at = @clock.call
|
|
171
|
+
schedule = orchestration(workflow).schedule
|
|
172
|
+
@backend.update_all(workflow, names) do |node, record|
|
|
173
|
+
Orchestrator::Policy.completed(record, owner:, finished_at:, error: errors[node], schedule:, random: @random)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Gives up leases without recording a run, so the nodes run again soon.
|
|
178
|
+
#
|
|
179
|
+
# @param workflow [String] workflow name
|
|
180
|
+
# @param owner [String] identifier of the batch
|
|
181
|
+
# @param names [Array<String>] names of the leased nodes
|
|
182
|
+
# @return [void]
|
|
183
|
+
#: (String workflow, owner: String, names: Array[String]) -> void
|
|
184
|
+
def release(workflow, owner:, names:)
|
|
185
|
+
@backend.update_all(workflow, names) { |_node, record| Orchestrator::Policy.released(record, owner:) }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# The earliest time a node of any workflow may be claimed: its due date,
|
|
189
|
+
# or the end of its lease while another batch holds it.
|
|
190
|
+
#
|
|
191
|
+
# @return [Time, nil] the time; nil when there are no records
|
|
192
|
+
#: () -> Time?
|
|
193
|
+
def next_due
|
|
194
|
+
now = @clock.call
|
|
195
|
+
times = workflows.flat_map do |workflow|
|
|
196
|
+
@backend.list(workflow).filter_map do |record|
|
|
197
|
+
Orchestrator::Policy.time(Orchestrator::Policy.running?(record, now) ? record.lease_until : record.due_at)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
times.min
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Decides whether a coordinator execution waits for the next due node,
|
|
204
|
+
# recording it as the waiter, or ends because another execution already
|
|
205
|
+
# waits until then. See {Orchestrator::Policy.arm}.
|
|
206
|
+
#
|
|
207
|
+
# @param token [String] identifies the execution
|
|
208
|
+
# @param cycle [Integer] times the execution has run due nodes already
|
|
209
|
+
# @param max_cycles [Integer] cycles after which the execution ends
|
|
210
|
+
# @param grace [Integer, nil] seconds after waking until another execution may take over; {#longest_lease} when nil
|
|
211
|
+
# @return [Time, nil] when to wake up; nil when the execution ends
|
|
212
|
+
#: (token: String, cycle: Integer, max_cycles: Integer, ?grace: Integer?) -> Time?
|
|
213
|
+
def arm(token:, cycle:, max_cycles:, grace: nil)
|
|
214
|
+
next_due = self.next_due
|
|
215
|
+
now = @clock.call
|
|
216
|
+
wake = nil #: Time?
|
|
217
|
+
@backend.update_wakeup do |record|
|
|
218
|
+
kept, wake = Orchestrator::Policy.arm(record, token:, next_due:, now:, gap: poll_seconds, grace: grace || longest_lease, cycle:, max_cycles:)
|
|
219
|
+
kept
|
|
220
|
+
end
|
|
221
|
+
if wake
|
|
222
|
+
notify(level: 'info', message: 'waiting for due nodes', token:, cycle:, wait_until: wake.utc.iso8601)
|
|
223
|
+
else
|
|
224
|
+
notify(level: 'info', message: 'not waiting', token:, cycle:)
|
|
225
|
+
end
|
|
226
|
+
wake
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# @param settings [Proto::V1::Orchestration] how a workflow is orchestrated
|
|
230
|
+
# @return [Integer] seconds a lease of the workflow lasts
|
|
231
|
+
#: (Proto::V1::Orchestration settings) -> Integer
|
|
232
|
+
private def lease_seconds(settings) = settings.lease_seconds.zero? ? DEFAULT_LEASE : settings.lease_seconds
|
|
233
|
+
|
|
234
|
+
# @param signal [Proto::V1::Signal] a signal for one node or every node
|
|
235
|
+
# @param node [String] node name
|
|
236
|
+
# @return [Proto::V1::Signal] the signal for that node alone
|
|
237
|
+
#: (Proto::V1::Signal signal, String node) -> Proto::V1::Signal
|
|
238
|
+
private def for_node(signal, node)
|
|
239
|
+
return signal unless signal.all_nodes
|
|
240
|
+
|
|
241
|
+
signal.dup.tap do |copy|
|
|
242
|
+
copy.node = node
|
|
243
|
+
copy.all_nodes = false
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# @param event [Hash{Symbol => String, Integer, Boolean, Array<String>, nil}] event fields
|
|
248
|
+
# @return [void]
|
|
249
|
+
#: (**Runner::event_value event) -> void
|
|
250
|
+
private def notify(**event) = @listener.call(event)
|
|
251
|
+
end
|
|
252
|
+
end
|
data/lib/glazed/dialect/run.rb
CHANGED
|
@@ -9,6 +9,7 @@ require 'glazed/dialect'
|
|
|
9
9
|
require 'glazed/dialect/actions'
|
|
10
10
|
require 'glazed/proto'
|
|
11
11
|
require 'glazed/secret'
|
|
12
|
+
require 'glazed/secrets_resolver'
|
|
12
13
|
|
|
13
14
|
module Glazed
|
|
14
15
|
module Dialect
|
|
@@ -17,11 +18,13 @@ module Glazed
|
|
|
17
18
|
# @param cli [CliSession] session to the node
|
|
18
19
|
# @param node [Proto::V1::Node] node being run against
|
|
19
20
|
# @param dialect [Class<Dialects::Base>] dialect of the node
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
# @param secrets [SecretsResolver] reads the node's secrets
|
|
22
|
+
#: (cli: CliSession, node: Proto::V1::Node, dialect: singleton(Dialects::Base), ?secrets: SecretsResolver) -> void
|
|
23
|
+
def initialize(cli:, node:, dialect:, secrets: SecretsResolver.new)
|
|
22
24
|
@cli = cli #: CliSession
|
|
23
25
|
@node = node #: Proto::V1::Node
|
|
24
26
|
@dialect = dialect #: singleton(Dialects::Base)
|
|
27
|
+
@secrets = secrets #: SecretsResolver
|
|
25
28
|
@vars = {} #: Hash[Symbol, untyped]
|
|
26
29
|
@output = +'' #: String
|
|
27
30
|
end
|
|
@@ -32,6 +35,9 @@ module Glazed
|
|
|
32
35
|
# @return [Proto::V1::Node] node being run against
|
|
33
36
|
#: Proto::V1::Node
|
|
34
37
|
attr_reader :node
|
|
38
|
+
# @return [SecretsResolver] reads the node's secrets
|
|
39
|
+
#: SecretsResolver
|
|
40
|
+
attr_reader :secrets
|
|
35
41
|
# @return [Class<Dialects::Base>] dialect of the node
|
|
36
42
|
#: singleton(Dialects::Base)
|
|
37
43
|
attr_reader :dialect
|
|
@@ -78,7 +84,7 @@ module Glazed
|
|
|
78
84
|
# @param name [String] secret name
|
|
79
85
|
# @return [Secret, nil] the node's secret
|
|
80
86
|
#: (String name) -> Secret?
|
|
81
|
-
def secret(name) =
|
|
87
|
+
def secret(name) = secrets.maybe(node.secrets[name])
|
|
82
88
|
end
|
|
83
89
|
end
|
|
84
90
|
end
|
|
@@ -14,10 +14,12 @@ module Glazed
|
|
|
14
14
|
# @param pattern [Regexp] pattern matched against the whole configuration
|
|
15
15
|
# @param determiner [Proc] returns the secret within a match, or its offsets in the configuration,
|
|
16
16
|
# or nil when the match holds none
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
# @param note [String, nil] shown in the replacement of every secret this pattern locates
|
|
18
|
+
#: (pattern: Regexp, determiner: SensitivePattern::determiner, ?note: String?) -> void
|
|
19
|
+
def initialize(pattern:, determiner:, note: nil)
|
|
19
20
|
@pattern = pattern #: Regexp
|
|
20
21
|
@determiner = determiner #: SensitivePattern::determiner
|
|
22
|
+
@note = note #: String?
|
|
21
23
|
end
|
|
22
24
|
|
|
23
25
|
# @return [Regexp] pattern matched against the whole configuration
|
|
@@ -26,6 +28,9 @@ module Glazed
|
|
|
26
28
|
# @return [Proc] returns the secret within a match, or its offsets
|
|
27
29
|
#: SensitivePattern::determiner
|
|
28
30
|
attr_reader :determiner
|
|
31
|
+
# @return [String, nil] shown in the replacement of every secret this pattern locates
|
|
32
|
+
#: String?
|
|
33
|
+
attr_reader :note
|
|
29
34
|
end
|
|
30
35
|
end
|
|
31
36
|
end
|
|
@@ -146,6 +146,34 @@ module Glazed
|
|
|
146
146
|
end
|
|
147
147
|
end
|
|
148
148
|
|
|
149
|
+
# Decides whether a coordinator execution waits for the next due node.
|
|
150
|
+
# It does unless another live execution already waits until then, or
|
|
151
|
+
# it has looped max_cycles times; a waiter that stops waiting gives up
|
|
152
|
+
# its record.
|
|
153
|
+
#
|
|
154
|
+
# @param record [Proto::V1::Wakeup, nil] the current waiter
|
|
155
|
+
# @param token [String] identifies the deciding execution
|
|
156
|
+
# @param next_due [Time, nil] when a node may next be claimed; nil when never
|
|
157
|
+
# @param now [Time] current time
|
|
158
|
+
# @param gap [Integer] seconds the execution waits at least after its first cycle
|
|
159
|
+
# @param grace [Integer] seconds after waking until another execution may take over
|
|
160
|
+
# @param cycle [Integer] times the execution has run due nodes already
|
|
161
|
+
# @param max_cycles [Integer] cycles after which the execution ends
|
|
162
|
+
# @return [Array(Proto::V1::Wakeup, Time), Array(Proto::V1::Wakeup, nil), Array(nil, nil)] the waiter
|
|
163
|
+
# record to keep, and when to wake; nil when the execution ends
|
|
164
|
+
#: (Proto::V1::Wakeup? record, token: String, next_due: Time?, now: Time, gap: Integer, grace: Integer, cycle: Integer, max_cycles: Integer) -> [Proto::V1::Wakeup?, Time?]
|
|
165
|
+
def self.arm(record, token:, next_due:, now:, gap:, grace:, cycle:, max_cycles:)
|
|
166
|
+
own = record&.owner == token
|
|
167
|
+
return [own ? nil : record, nil] if next_due.nil? || cycle >= max_cycles
|
|
168
|
+
|
|
169
|
+
wake = [next_due, cycle.zero? ? now : now + gap].max
|
|
170
|
+
expires_at = time(record&.expires_at)
|
|
171
|
+
wake_at = time(record&.wake_at)
|
|
172
|
+
return [record, nil] if !own && expires_at && expires_at > now && wake_at && wake_at <= wake
|
|
173
|
+
|
|
174
|
+
[Proto::V1::Wakeup.new(owner: token, wake_at: timestamp(wake), expires_at: timestamp(wake + grace)), wake]
|
|
175
|
+
end
|
|
176
|
+
|
|
149
177
|
# @param record [Proto::V1::NodeSchedule] the node's record
|
|
150
178
|
# @param now [Time] current time
|
|
151
179
|
# @return [Boolean] whether an orchestrator holds an unexpired lease on the node
|
data/lib/glazed/orchestrator.rb
CHANGED
|
@@ -3,10 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
# SPDX-License-Identifier: Apache-2.0
|
|
5
5
|
|
|
6
|
-
require 'securerandom'
|
|
7
|
-
require 'socket'
|
|
8
6
|
require 'glazed'
|
|
9
|
-
require 'glazed/
|
|
7
|
+
require 'glazed/coordinator'
|
|
10
8
|
require 'glazed/orchestrator/policy'
|
|
11
9
|
require 'glazed/proto'
|
|
12
10
|
require 'glazed/runner'
|
|
@@ -28,12 +26,9 @@ module Glazed
|
|
|
28
26
|
# orchestrator.run
|
|
29
27
|
class Orchestrator
|
|
30
28
|
# Seconds between checks for due nodes when the settings set none.
|
|
31
|
-
DEFAULT_POLL =
|
|
29
|
+
DEFAULT_POLL = Coordinator::DEFAULT_POLL #: Integer
|
|
32
30
|
# Seconds between inventory reloads when the settings set none.
|
|
33
31
|
DEFAULT_INVENTORY_REFRESH = 300 #: Integer
|
|
34
|
-
# Seconds a lease lasts when the settings set none.
|
|
35
|
-
DEFAULT_LEASE = 3600 #: Integer
|
|
36
|
-
|
|
37
32
|
# Byte written to the wakeup pipe by {#stop}.
|
|
38
33
|
STOP = 's' #: String
|
|
39
34
|
# Byte written to the wakeup pipe by {#reload_inventory}.
|
|
@@ -49,8 +44,11 @@ module Glazed
|
|
|
49
44
|
# @param debounce [Proto::V1::Debounce, nil] the debounce settings
|
|
50
45
|
# @param now [Time] current time, used for signals without a reception time
|
|
51
46
|
# @return [Hash{String => Proto::V1::NodeSchedule}] the resulting records, by node name
|
|
47
|
+
# @raise [ArgumentError] for a signal of every node, which {Coordinator#signal} expands
|
|
52
48
|
#: (backend: StateBackends::Base, workflow: String, signals: Array[Proto::V1::Signal], debounce: Proto::V1::Debounce?, ?now: Time) -> Hash[String, Proto::V1::NodeSchedule]
|
|
53
49
|
def self.signal(backend:, workflow:, signals:, debounce:, now: Time.now)
|
|
50
|
+
raise ArgumentError, 'expand all_nodes signals with Coordinator#signal' if signals.any?(&:all_nodes)
|
|
51
|
+
|
|
54
52
|
by_node = signals.group_by(&:node)
|
|
55
53
|
backend.update_all(workflow, by_node.keys) do |node, record|
|
|
56
54
|
by_node.fetch(node).inject(record) { |current, signal| Policy.signal(current, signal, workflow:, now:, debounce:) }
|
|
@@ -74,13 +72,11 @@ module Glazed
|
|
|
74
72
|
|
|
75
73
|
@settings = settings #: Proto::V1::Orchestrator
|
|
76
74
|
@workflows = workflows #: Hash[String, Workflow]
|
|
77
|
-
@
|
|
75
|
+
@coordinator = Coordinator.new(settings:, backend:, listener:, clock:, random:) #: Coordinator
|
|
78
76
|
@inventory = inventory #: Orchestrator::inventory
|
|
79
77
|
@jobs = jobs #: Integer
|
|
80
78
|
@listener = listener #: Runner::listener
|
|
81
79
|
@clock = clock #: ^() -> Time
|
|
82
|
-
@random = random #: Random
|
|
83
|
-
@owner = "#{Socket.gethostname}:#{Process.pid}:#{SecureRandom.hex(4)}" #: String
|
|
84
80
|
@nodes = {} #: Hash[String, Proto::V1::Node]
|
|
85
81
|
@inventory_loaded_at = nil #: Time?
|
|
86
82
|
# A pipe, as a thread blocked in Queue#pop misses a push from a signal handler on that thread.
|
|
@@ -98,7 +94,7 @@ module Glazed
|
|
|
98
94
|
# @raise [Glazed::Error] when the first inventory load or the state backend fails
|
|
99
95
|
#: () -> void
|
|
100
96
|
def run
|
|
101
|
-
notify(level: 'info', message: 'orchestrator started', owner: @owner)
|
|
97
|
+
notify(level: 'info', message: 'orchestrator started', owner: @coordinator.owner)
|
|
102
98
|
until @stopping
|
|
103
99
|
wait = tick
|
|
104
100
|
wait_for_wakeup(wait)
|
|
@@ -128,24 +124,17 @@ module Glazed
|
|
|
128
124
|
now = @clock.call
|
|
129
125
|
loaded_at = @inventory_loaded_at
|
|
130
126
|
refresh_inventory(now) if loaded_at.nil? || now - loaded_at >= inventory_refresh_seconds
|
|
131
|
-
@workflows.each_key { |workflow| run_batch(workflow, claim(workflow
|
|
127
|
+
@workflows.each_key { |workflow| run_batch(workflow, @coordinator.claim(workflow)) }
|
|
132
128
|
next_wait
|
|
133
129
|
end
|
|
134
130
|
|
|
135
|
-
# @param workflow [String] workflow name
|
|
136
|
-
# @return [Proto::V1::Orchestration] how the workflow is orchestrated
|
|
137
|
-
#: (String workflow) -> Proto::V1::Orchestration
|
|
138
|
-
private def orchestration(workflow)
|
|
139
|
-
Must.because(@settings.workflows[workflow]) { 'initialize checks the workflows against the settings' }
|
|
140
|
-
end
|
|
141
|
-
|
|
142
131
|
# @return [Integer] seconds between inventory reloads
|
|
143
132
|
#: () -> Integer
|
|
144
133
|
private def inventory_refresh_seconds = @settings.inventory_refresh_seconds.zero? ? DEFAULT_INVENTORY_REFRESH : @settings.inventory_refresh_seconds
|
|
145
134
|
|
|
146
135
|
# @return [Integer] seconds between checks for due nodes
|
|
147
136
|
#: () -> Integer
|
|
148
|
-
private def poll_seconds = @
|
|
137
|
+
private def poll_seconds = @coordinator.poll_seconds
|
|
149
138
|
|
|
150
139
|
# @param request [String] one byte naming the request
|
|
151
140
|
# @return [void]
|
|
@@ -189,40 +178,7 @@ module Glazed
|
|
|
189
178
|
@nodes = nodes.to_h { [it.name, it] }
|
|
190
179
|
@inventory_loaded_at = now
|
|
191
180
|
notify(level: 'info', message: 'inventory loaded', nodes: @nodes.size)
|
|
192
|
-
@
|
|
193
|
-
known = @backend.list(workflow).map(&:node)
|
|
194
|
-
added = @nodes.keys - known
|
|
195
|
-
removed = known - @nodes.keys
|
|
196
|
-
next if added.empty? && removed.empty?
|
|
197
|
-
|
|
198
|
-
schedule = orchestration(workflow).schedule
|
|
199
|
-
@backend.update_all(workflow, added + removed) do |node, record|
|
|
200
|
-
next nil unless @nodes.key?(node)
|
|
201
|
-
|
|
202
|
-
record || Policy.discovered(workflow, node, now:, schedule:, random: @random)
|
|
203
|
-
end
|
|
204
|
-
notify(level: 'info', workflow:, message: 'inventory changed', nodes: @nodes.size, added:, removed:)
|
|
205
|
-
end
|
|
206
|
-
end
|
|
207
|
-
|
|
208
|
-
# Leases due nodes of a workflow.
|
|
209
|
-
#
|
|
210
|
-
# @param workflow [String] workflow name
|
|
211
|
-
# @param now [Time] current time
|
|
212
|
-
# @return [Array(String, Array<String>)] identifier of the batch, and names of the leased nodes
|
|
213
|
-
#: (String workflow, Time now) -> [String, Array[String]]
|
|
214
|
-
private def claim(workflow, now)
|
|
215
|
-
owner = "#{@owner}:#{SecureRandom.hex(4)}"
|
|
216
|
-
settings = orchestration(workflow)
|
|
217
|
-
limit = settings.batch_size.zero? ? nil : settings.batch_size
|
|
218
|
-
due = @backend.list_due(workflow, now:, limit:)
|
|
219
|
-
return [owner, []] if due.empty?
|
|
220
|
-
|
|
221
|
-
lease_seconds = settings.lease_seconds.zero? ? DEFAULT_LEASE : settings.lease_seconds
|
|
222
|
-
records = @backend.update_all(workflow, due.map(&:node)) do |_node, record|
|
|
223
|
-
Policy.claim(record, owner:, now:, lease_seconds:)
|
|
224
|
-
end
|
|
225
|
-
[owner, records.values.select { it.lease_owner == owner }.map(&:node)]
|
|
181
|
+
@coordinator.sync_inventory(@nodes.keys)
|
|
226
182
|
end
|
|
227
183
|
|
|
228
184
|
# Runs the workflow for leased nodes and records the outcomes. When
|
|
@@ -242,16 +198,11 @@ module Glazed
|
|
|
242
198
|
begin
|
|
243
199
|
errors = run_workflow(workflow, nodes)
|
|
244
200
|
rescue Interrupt
|
|
245
|
-
@
|
|
201
|
+
@coordinator.release(workflow, owner:, names:)
|
|
246
202
|
raise
|
|
247
203
|
end
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
@backend.update_all(workflow, names) do |node, record|
|
|
251
|
-
next nil unless @nodes.key?(node)
|
|
252
|
-
|
|
253
|
-
Policy.completed(record, owner:, finished_at:, error: errors[node], schedule:, random: @random)
|
|
254
|
-
end
|
|
204
|
+
# Records of nodes gone from the inventory are deleted by the next reload.
|
|
205
|
+
@coordinator.complete(workflow, owner:, names: names & @nodes.keys, errors:)
|
|
255
206
|
notify(level: 'info', workflow:, message: 'batch finished', nodes: nodes.size, failed: errors.size)
|
|
256
207
|
end
|
|
257
208
|
|
|
@@ -273,11 +224,10 @@ module Glazed
|
|
|
273
224
|
# @return [Float] seconds until the earliest known due date of any workflow, at most the poll interval
|
|
274
225
|
#: () -> Float
|
|
275
226
|
private def next_wait
|
|
276
|
-
|
|
277
|
-
upcoming = @workflows.keys.filter_map { @backend.list_due(it, now: now + poll_seconds, limit: 1).first&.due_at&.to_time }.min
|
|
227
|
+
upcoming = @coordinator.next_due
|
|
278
228
|
return poll_seconds.to_f unless upcoming
|
|
279
229
|
|
|
280
|
-
(upcoming -
|
|
230
|
+
(upcoming - @clock.call).clamp(0.0, poll_seconds.to_f).to_f
|
|
281
231
|
end
|
|
282
232
|
|
|
283
233
|
# @param event [Hash{Symbol => String, Integer, Boolean, Array<String>, nil}] event fields
|