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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +19 -0
  3. data/lib/glazed/collector.rb +10 -6
  4. data/lib/glazed/command/base.rb +3 -18
  5. data/lib/glazed/command/orchestrate.rb +1 -1
  6. data/lib/glazed/command/run.rb +1 -1
  7. data/lib/glazed/command/send_signal.rb +21 -13
  8. data/lib/glazed/config.rb +50 -0
  9. data/lib/glazed/coordinator/task_runner.rb +189 -0
  10. data/lib/glazed/coordinator.rb +252 -0
  11. data/lib/glazed/dialect/run.rb +9 -3
  12. data/lib/glazed/dialect/sensitive_pattern.rb +7 -2
  13. data/lib/glazed/orchestrator/policy.rb +28 -0
  14. data/lib/glazed/orchestrator.rb +15 -65
  15. data/lib/glazed/proto.rb +1 -0
  16. data/lib/glazed/redactor.rb +65 -26
  17. data/lib/glazed/secret.rb +12 -37
  18. data/lib/glazed/secrets/command.rb +66 -0
  19. data/lib/glazed/secrets.rb +27 -5
  20. data/lib/glazed/secrets_resolver.rb +85 -0
  21. data/lib/glazed/shell_command.rb +41 -6
  22. data/lib/glazed/sources/command.rb +11 -8
  23. data/lib/glazed/state_backends/base.rb +11 -1
  24. data/lib/glazed/state_backends/sqlite.rb +38 -1
  25. data/lib/glazed/step_context.rb +26 -8
  26. data/lib/glazed/steps/{batch_shell.rb → batch_command.rb} +15 -15
  27. data/lib/glazed/steps/{shell.rb → command.rb} +21 -18
  28. data/lib/glazed/steps/fetch.rb +1 -1
  29. data/lib/glazed/steps/redact.rb +3 -1
  30. data/lib/glazed/storage_context.rb +22 -0
  31. data/lib/glazed/storages/base.rb +3 -2
  32. data/lib/glazed/storages/filesystem.rb +3 -2
  33. data/lib/glazed/storages/git.rb +25 -8
  34. data/lib/glazed/storages/github.rb +22 -8
  35. data/lib/glazed/storages/memory.rb +3 -2
  36. data/lib/glazed/storages.rb +6 -4
  37. data/lib/glazed/transports/base.rb +14 -2
  38. data/lib/glazed/transports/ssh.rb +55 -2
  39. data/lib/glazed/transports/telnet.rb +4 -2
  40. data/lib/glazed/v1/config_pb.rb +2 -1
  41. data/lib/glazed/v1/coordinator_pb.rb +34 -0
  42. data/lib/glazed/v1/inventory_pb.rb +5 -1
  43. data/lib/glazed/v1/orchestrator_pb.rb +1 -1
  44. data/lib/glazed/v1/redaction_pb.rb +2 -1
  45. data/lib/glazed/v1/secret_pb.rb +3 -1
  46. data/lib/glazed/v1/state_pb.rb +2 -1
  47. data/lib/glazed/v1/storage_pb.rb +1 -1
  48. data/lib/glazed/v1/workflow_pb.rb +3 -3
  49. data/lib/glazed/version.rb +1 -1
  50. 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
@@ -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
- #: (cli: CliSession, node: Proto::V1::Node, dialect: singleton(Dialects::Base)) -> void
21
- def initialize(cli:, node:, dialect:)
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) = Secret.maybe(node.secrets[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
- #: (pattern: Regexp, determiner: SensitivePattern::determiner) -> void
18
- def initialize(pattern:, determiner:)
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
@@ -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/must'
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 = 30 #: Integer
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
- @backend = backend #: StateBackends::Base
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, @clock.call)) }
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 = @settings.poll_seconds.zero? ? DEFAULT_POLL : @settings.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
- @workflows.each_key do |workflow|
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
- @backend.update_all(workflow, names) { |_node, record| Policy.released(record, owner:) }
201
+ @coordinator.release(workflow, owner:, names:)
246
202
  raise
247
203
  end
248
- finished_at = @clock.call
249
- schedule = orchestration(workflow).schedule
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
- now = @clock.call
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 - now).clamp(0.0, poll_seconds.to_f).to_f
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
data/lib/glazed/proto.rb CHANGED
@@ -4,6 +4,7 @@
4
4
  # SPDX-License-Identifier: Apache-2.0
5
5
 
6
6
  require 'glazed/v1/config_pb'
7
+ require 'glazed/v1/coordinator_pb'
7
8
  require 'glazed/v1/inventory_pb'
8
9
  require 'glazed/v1/orchestrator_pb'
9
10
  require 'glazed/v1/redaction_pb'