kaisoku 0.1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +133 -0
- data/Rakefile +8 -0
- data/exe/kaisoku +6 -0
- data/lib/kaisoku/adapter.rb +81 -0
- data/lib/kaisoku/adapters/cucumber.rb +91 -0
- data/lib/kaisoku/adapters/minitest.rb +96 -0
- data/lib/kaisoku/adapters/rspec.rb +111 -0
- data/lib/kaisoku/adapters/test_unit.rb +35 -0
- data/lib/kaisoku/checksum.rb +55 -0
- data/lib/kaisoku/cli.rb +158 -0
- data/lib/kaisoku/commands/daemon_command.rb +31 -0
- data/lib/kaisoku/commands/map_command.rb +86 -0
- data/lib/kaisoku/commands/preload_command.rb +40 -0
- data/lib/kaisoku/commands/run_command.rb +93 -0
- data/lib/kaisoku/commands/watch_command.rb +37 -0
- data/lib/kaisoku/configuration.rb +69 -0
- data/lib/kaisoku/coverage_collector.rb +83 -0
- data/lib/kaisoku/daemon/client.rb +22 -0
- data/lib/kaisoku/daemon/server.rb +39 -0
- data/lib/kaisoku/daemon/socket_path.rb +13 -0
- data/lib/kaisoku/doctor.rb +46 -0
- data/lib/kaisoku/entity.rb +77 -0
- data/lib/kaisoku/entity_result.rb +19 -0
- data/lib/kaisoku/environment_tracker.rb +28 -0
- data/lib/kaisoku/evaluator/cli.rb +112 -0
- data/lib/kaisoku/evaluator/command_result.rb +64 -0
- data/lib/kaisoku/evaluator/history.rb +23 -0
- data/lib/kaisoku/evaluator/metrics.rb +45 -0
- data/lib/kaisoku/evaluator/mutation_seeder.rb +78 -0
- data/lib/kaisoku/evaluator/replay.rb +121 -0
- data/lib/kaisoku/evaluator/report.rb +128 -0
- data/lib/kaisoku/hybrid_policy.rb +14 -0
- data/lib/kaisoku/listen_watcher.rb +34 -0
- data/lib/kaisoku/lsp/message_io.rb +42 -0
- data/lib/kaisoku/lsp/server.rb +100 -0
- data/lib/kaisoku/method_map.rb +75 -0
- data/lib/kaisoku/preload/client.rb +22 -0
- data/lib/kaisoku/preload/daemon.rb +48 -0
- data/lib/kaisoku/preload/fallback_runner.rb +45 -0
- data/lib/kaisoku/preload/server.rb +123 -0
- data/lib/kaisoku/preload/socket_path.rb +13 -0
- data/lib/kaisoku/prioritizer.rb +18 -0
- data/lib/kaisoku/rails/integration.rb +56 -0
- data/lib/kaisoku/reporters/console.rb +24 -0
- data/lib/kaisoku/reporters/factory.rb +24 -0
- data/lib/kaisoku/reporters/json.rb +28 -0
- data/lib/kaisoku/reporters/junit.rb +50 -0
- data/lib/kaisoku/reporters/tui.rb +29 -0
- data/lib/kaisoku/runner.rb +48 -0
- data/lib/kaisoku/runtime_context.rb +28 -0
- data/lib/kaisoku/scheduler/fork_pool.rb +105 -0
- data/lib/kaisoku/scheduler/result_persister.rb +43 -0
- data/lib/kaisoku/selection.rb +51 -0
- data/lib/kaisoku/selector.rb +173 -0
- data/lib/kaisoku/snapshot/inline.rb +51 -0
- data/lib/kaisoku/snapshot/rspec.rb +22 -0
- data/lib/kaisoku/static_analysis/analyzer.rb +120 -0
- data/lib/kaisoku/test_map/dependency_queries.rb +81 -0
- data/lib/kaisoku/test_map/health.rb +24 -0
- data/lib/kaisoku/test_map/hot_files.rb +32 -0
- data/lib/kaisoku/test_map/schema.rb +81 -0
- data/lib/kaisoku/test_map/snapshot.rb +53 -0
- data/lib/kaisoku/test_map/stats.rb +19 -0
- data/lib/kaisoku/test_map.rb +170 -0
- data/lib/kaisoku/version.rb +5 -0
- data/lib/kaisoku/watch_session.rb +35 -0
- data/lib/kaisoku/watcher.rb +63 -0
- data/lib/kaisoku/worker.rb +46 -0
- data/lib/kaisoku.rb +67 -0
- metadata +186 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kaisoku
|
|
4
|
+
module Evaluator
|
|
5
|
+
class Report
|
|
6
|
+
def initialize(results)
|
|
7
|
+
@results = results
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def render
|
|
11
|
+
[
|
|
12
|
+
"=== Kaisoku Evaluation Report (#{@results.length} commits) ===",
|
|
13
|
+
"Safety violations : #{safety_violations}",
|
|
14
|
+
"Selection ratio : #{format('%.1f%%', selection_ratio * 100)}",
|
|
15
|
+
"Reduction ratio : #{format('%.1f%%', reduction_ratio * 100)}",
|
|
16
|
+
"Precision : #{format('%.1f%%', precision * 100)}",
|
|
17
|
+
"APFD : #{format('%.2f', apfd)}",
|
|
18
|
+
"E2E time ratio : #{format('%.1f%%', e2e_time_ratio * 100)}",
|
|
19
|
+
"Fallback commits : #{fallback_count}/#{@results.length}",
|
|
20
|
+
diagnostics
|
|
21
|
+
].join("\n")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_h
|
|
25
|
+
{
|
|
26
|
+
commit_count: @results.length,
|
|
27
|
+
safety_violations: safety_violations,
|
|
28
|
+
selection_ratio: selection_ratio,
|
|
29
|
+
reduction_ratio: reduction_ratio,
|
|
30
|
+
precision: precision,
|
|
31
|
+
apfd: apfd,
|
|
32
|
+
e2e_time_ratio: e2e_time_ratio,
|
|
33
|
+
fallback_count: fallback_count,
|
|
34
|
+
commits: @results.map { |result| result_payload(result) }
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def safe?
|
|
39
|
+
safety_violations.zero?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def safety_violations
|
|
45
|
+
@results.sum { |result| Array(result.safety_violations).length }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def precision
|
|
49
|
+
return 1.0 if @results.empty?
|
|
50
|
+
|
|
51
|
+
@results.sum { |result| result.precision || 1.0 } / @results.length
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def selection_ratio
|
|
55
|
+
median(selection_ratios)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def reduction_ratio
|
|
59
|
+
ratios = selection_ratios
|
|
60
|
+
return 0.0 if ratios.empty?
|
|
61
|
+
|
|
62
|
+
1.0 - median(ratios)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def e2e_time_ratio
|
|
66
|
+
full = @results.sum(&:full_time)
|
|
67
|
+
return 0.0 if full.zero?
|
|
68
|
+
|
|
69
|
+
@results.sum(&:selected_time) / full
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def apfd
|
|
73
|
+
return 1.0 if @results.empty?
|
|
74
|
+
|
|
75
|
+
@results.sum { |result| result.apfd || 1.0 } / @results.length
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def fallback_count
|
|
79
|
+
@results.count(&:fallback)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def diagnostics
|
|
83
|
+
violations = @results.select { |result| Array(result.safety_violations).any? }
|
|
84
|
+
return 'Diagnostics : none' if violations.empty?
|
|
85
|
+
|
|
86
|
+
lines = violations.flat_map do |result|
|
|
87
|
+
result.safety_violations.map { |id| "#{result.commit}: missed #{id}" }
|
|
88
|
+
end
|
|
89
|
+
"Diagnostics :\n#{lines.join("\n")}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def result_payload(result)
|
|
93
|
+
{
|
|
94
|
+
commit: result.commit,
|
|
95
|
+
full_status: result.full_status,
|
|
96
|
+
selected_status: result.selected_status,
|
|
97
|
+
full_time: result.full_time,
|
|
98
|
+
selected_time: result.selected_time,
|
|
99
|
+
fallback: result.fallback,
|
|
100
|
+
safety_violations: Array(result.safety_violations),
|
|
101
|
+
precision: result.precision,
|
|
102
|
+
apfd: result.apfd,
|
|
103
|
+
changed_files: Array(result.changed_files),
|
|
104
|
+
selected_count: result.selected_count,
|
|
105
|
+
total_count: result.total_count
|
|
106
|
+
}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def median(values)
|
|
110
|
+
return 0.0 if values.empty?
|
|
111
|
+
|
|
112
|
+
sorted = values.sort
|
|
113
|
+
middle = sorted.length / 2
|
|
114
|
+
return sorted[middle] if sorted.length.odd?
|
|
115
|
+
|
|
116
|
+
(sorted[middle - 1] + sorted[middle]) / 2.0
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def selection_ratios
|
|
120
|
+
@results.filter_map do |result|
|
|
121
|
+
next unless result.total_count.to_i.positive?
|
|
122
|
+
|
|
123
|
+
result.selected_count.to_f / result.total_count
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kaisoku
|
|
4
|
+
class HybridPolicy
|
|
5
|
+
def initialize(test_map:, configuration:)
|
|
6
|
+
@test_map = test_map
|
|
7
|
+
@configuration = configuration
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def method_level?(path)
|
|
11
|
+
@test_map.dependency_entity_count(path, adapter: @configuration.adapter) >= @configuration.method_level_threshold
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'listen'
|
|
4
|
+
|
|
5
|
+
module Kaisoku
|
|
6
|
+
class ListenWatcher
|
|
7
|
+
DEFAULT_PATHS = Watcher::DEFAULT_PATHS
|
|
8
|
+
attr_reader :configuration, :paths
|
|
9
|
+
|
|
10
|
+
def initialize(configuration: Configuration.new, paths: DEFAULT_PATHS, interval: 0.2)
|
|
11
|
+
@configuration = configuration
|
|
12
|
+
@paths = Array(paths).empty? ? DEFAULT_PATHS : Array(paths)
|
|
13
|
+
@interval = interval
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def each_change
|
|
17
|
+
listener = Listen.to(*existing_paths, latency: @interval) do |modified, added, removed|
|
|
18
|
+
changed = (modified + added + removed).map { |path| configuration.relative_path(path) }.sort
|
|
19
|
+
yield changed unless changed.empty?
|
|
20
|
+
end
|
|
21
|
+
listener.start
|
|
22
|
+
sleep
|
|
23
|
+
ensure
|
|
24
|
+
listener&.stop
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def existing_paths
|
|
30
|
+
found = paths.map { |path| configuration.absolute_path(path) }.select { |path| File.exist?(path) }
|
|
31
|
+
found.empty? ? [configuration.project_root] : found
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Kaisoku
|
|
6
|
+
module LSP
|
|
7
|
+
class MessageIO
|
|
8
|
+
def initialize(input: $stdin, output: $stdout)
|
|
9
|
+
@input = input
|
|
10
|
+
@output = output
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def read
|
|
14
|
+
headers = read_headers
|
|
15
|
+
length = headers['content-length'].to_i
|
|
16
|
+
return nil if length.zero?
|
|
17
|
+
|
|
18
|
+
JSON.parse(@input.read(length))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def write(payload)
|
|
22
|
+
json = JSON.generate(payload)
|
|
23
|
+
@output.write("Content-Length: #{json.bytesize}\r\n\r\n#{json}")
|
|
24
|
+
@output.flush
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def read_headers
|
|
30
|
+
headers = {}
|
|
31
|
+
while (line = @input.gets)
|
|
32
|
+
line = line.strip
|
|
33
|
+
break if line.empty?
|
|
34
|
+
|
|
35
|
+
key, value = line.split(/:\s*/, 2)
|
|
36
|
+
headers[key.downcase] = value
|
|
37
|
+
end
|
|
38
|
+
headers
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module Kaisoku
|
|
6
|
+
module LSP
|
|
7
|
+
class Server
|
|
8
|
+
def initialize(configuration: Configuration.new, io: MessageIO.new)
|
|
9
|
+
@configuration = configuration
|
|
10
|
+
@io = io
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def start
|
|
14
|
+
while (message = @io.read)
|
|
15
|
+
handle(message)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def handle(message)
|
|
22
|
+
case message['method']
|
|
23
|
+
when 'initialize'
|
|
24
|
+
respond(message['id'], capabilities)
|
|
25
|
+
when 'shutdown'
|
|
26
|
+
respond(message['id'], nil)
|
|
27
|
+
when 'textDocument/didSave'
|
|
28
|
+
publish_diagnostics(message)
|
|
29
|
+
when 'textDocument/codeLens'
|
|
30
|
+
respond(message['id'], code_lenses(message))
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def respond(id, result)
|
|
35
|
+
@io.write(jsonrpc: '2.0', id: id, result: result)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def publish_diagnostics(message)
|
|
39
|
+
uri = message.dig('params', 'textDocument', 'uri')
|
|
40
|
+
selection = selection_for(uri_to_path(uri))
|
|
41
|
+
@io.write(
|
|
42
|
+
jsonrpc: '2.0',
|
|
43
|
+
method: 'textDocument/publishDiagnostics',
|
|
44
|
+
params: { uri: uri, diagnostics: diagnostics(selection) }
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def selection_for(path)
|
|
49
|
+
context = RuntimeContext.new(adapter: @configuration.adapter, project_root: @configuration.project_root)
|
|
50
|
+
Selector.new(test_map: context.test_map, configuration: @configuration).select(
|
|
51
|
+
changed_paths: [path],
|
|
52
|
+
all_entities: context.adapter.discover
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def diagnostics(selection)
|
|
57
|
+
[{
|
|
58
|
+
severity: selection.safe? ? 3 : 2,
|
|
59
|
+
source: 'kaisoku',
|
|
60
|
+
message: "selected #{selection.count} test entities: #{selection.reason}",
|
|
61
|
+
range: {
|
|
62
|
+
start: { line: 0, character: 0 },
|
|
63
|
+
end: { line: 0, character: 0 }
|
|
64
|
+
}
|
|
65
|
+
}]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def code_lenses(message)
|
|
69
|
+
uri = message.dig('params', 'textDocument', 'uri')
|
|
70
|
+
selection = selection_for(uri_to_path(uri))
|
|
71
|
+
[{
|
|
72
|
+
range: {
|
|
73
|
+
start: { line: 0, character: 0 },
|
|
74
|
+
end: { line: 0, character: 0 }
|
|
75
|
+
},
|
|
76
|
+
command: {
|
|
77
|
+
title: "Kaisoku: #{selection.count} selected test entities",
|
|
78
|
+
command: 'kaisoku.showSelection',
|
|
79
|
+
arguments: [selection.entities.map(&:id), selection.reason]
|
|
80
|
+
}
|
|
81
|
+
}]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def capabilities
|
|
85
|
+
{
|
|
86
|
+
capabilities: {
|
|
87
|
+
textDocumentSync: { save: true },
|
|
88
|
+
codeLensProvider: { resolveProvider: false }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def uri_to_path(uri)
|
|
94
|
+
return uri unless uri&.start_with?('file://')
|
|
95
|
+
|
|
96
|
+
URI.decode_www_form_component(uri.sub('file://', ''))
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'prism'
|
|
5
|
+
|
|
6
|
+
module Kaisoku
|
|
7
|
+
class MethodMap
|
|
8
|
+
TOP_LEVEL = '__toplevel__'
|
|
9
|
+
|
|
10
|
+
def initialize(configuration: Configuration.new)
|
|
11
|
+
@configuration = configuration
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def checksums(path)
|
|
15
|
+
absolute = @configuration.absolute_path(path)
|
|
16
|
+
return {} unless File.file?(absolute) && File.extname(absolute) == '.rb'
|
|
17
|
+
|
|
18
|
+
source = File.read(absolute)
|
|
19
|
+
lines = source.lines
|
|
20
|
+
ranges = method_ranges(source)
|
|
21
|
+
ranges.to_h do |name, range|
|
|
22
|
+
["#{@configuration.relative_path(path)}##{name}", digest(lines[(range.begin - 1)..(range.end - 1)].join)]
|
|
23
|
+
end.merge(top_level_checksum(path, lines, ranges.values))
|
|
24
|
+
rescue StandardError
|
|
25
|
+
{}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def keys_for_lines(path, touched_lines)
|
|
29
|
+
absolute = @configuration.absolute_path(path)
|
|
30
|
+
return [] unless File.file?(absolute) && File.extname(absolute) == '.rb'
|
|
31
|
+
|
|
32
|
+
ranges = method_ranges(File.read(absolute))
|
|
33
|
+
touched = Array(touched_lines).map(&:to_i)
|
|
34
|
+
keys = ranges.filter_map do |name, range|
|
|
35
|
+
"#{@configuration.relative_path(path)}##{name}" if touched.any? { |line| range.cover?(line) }
|
|
36
|
+
end
|
|
37
|
+
keys << "#{@configuration.relative_path(path)}##{TOP_LEVEL}" if top_level_touched?(touched, ranges.values)
|
|
38
|
+
keys
|
|
39
|
+
rescue StandardError
|
|
40
|
+
[]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def method_ranges(source)
|
|
46
|
+
nodes(Prism.parse(source).value).each_with_object({}) do |node, ranges|
|
|
47
|
+
next unless node.is_a?(Prism::DefNode)
|
|
48
|
+
|
|
49
|
+
ranges[node.name.to_s] = node.location.start_line..node.location.end_line
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def nodes(root, &block)
|
|
54
|
+
return enum_for(:nodes, root) unless block_given?
|
|
55
|
+
|
|
56
|
+
yield root
|
|
57
|
+
root.child_nodes.compact.each { |child| nodes(child, &block) } if root.respond_to?(:child_nodes)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def top_level_checksum(path, lines, ranges)
|
|
61
|
+
top_lines = lines.each_with_index.filter_map do |line, index|
|
|
62
|
+
line unless ranges.any? { |range| range.cover?(index + 1) }
|
|
63
|
+
end
|
|
64
|
+
{ "#{@configuration.relative_path(path)}##{TOP_LEVEL}" => digest(top_lines.join) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def top_level_touched?(touched_lines, ranges)
|
|
68
|
+
touched_lines.any? { |line| ranges.none? { |range| range.cover?(line) } }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def digest(source)
|
|
72
|
+
Digest::SHA256.hexdigest(source)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'msgpack'
|
|
4
|
+
require 'socket'
|
|
5
|
+
|
|
6
|
+
module Kaisoku
|
|
7
|
+
module Preload
|
|
8
|
+
class Client
|
|
9
|
+
def initialize(socket_path:)
|
|
10
|
+
@socket_path = socket_path
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def request(command, payload = {})
|
|
14
|
+
UNIXSocket.open(@socket_path) do |socket|
|
|
15
|
+
socket.write(MessagePack.pack(payload.merge('command' => command)))
|
|
16
|
+
socket.close_write
|
|
17
|
+
MessagePack.unpack(socket.read)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'msgpack'
|
|
5
|
+
require 'socket'
|
|
6
|
+
|
|
7
|
+
module Kaisoku
|
|
8
|
+
module Preload
|
|
9
|
+
class Daemon
|
|
10
|
+
def initialize(socket_path:, server:)
|
|
11
|
+
@socket_path = socket_path
|
|
12
|
+
@server = server
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def start
|
|
16
|
+
FileUtils.mkdir_p(File.dirname(@socket_path))
|
|
17
|
+
FileUtils.rm_f(@socket_path)
|
|
18
|
+
UNIXServer.open(@socket_path) { |socket| loop { handle(socket.accept) } }
|
|
19
|
+
ensure
|
|
20
|
+
File.unlink(@socket_path) if @socket_path && File.socket?(@socket_path)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def handle(socket)
|
|
26
|
+
request = MessagePack.unpack(socket.read)
|
|
27
|
+
response = dispatch(request)
|
|
28
|
+
socket.write(MessagePack.pack(response))
|
|
29
|
+
rescue StandardError => e
|
|
30
|
+
socket.write(MessagePack.pack('ok' => false, 'error' => "#{e.class}: #{e.message}"))
|
|
31
|
+
ensure
|
|
32
|
+
socket.close
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def dispatch(request)
|
|
36
|
+
case request['command']
|
|
37
|
+
when 'status'
|
|
38
|
+
{ 'ok' => true, 'status' => @server.status }
|
|
39
|
+
when 'refresh'
|
|
40
|
+
changed = Array(request['changed_paths'])
|
|
41
|
+
{ 'ok' => true, 'refreshed' => @server.refresh_if_needed(changed), 'status' => @server.status }
|
|
42
|
+
else
|
|
43
|
+
{ 'ok' => false, 'error' => 'unknown preload command' }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kaisoku
|
|
4
|
+
module Preload
|
|
5
|
+
class FallbackRunner
|
|
6
|
+
def initialize(preload_server:)
|
|
7
|
+
@preload_server = preload_server
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def run(&)
|
|
11
|
+
return yield if @preload_server.disabled?
|
|
12
|
+
|
|
13
|
+
preload_result = @preload_server.run(&)
|
|
14
|
+
return preload_result unless suspicious_result?(preload_result)
|
|
15
|
+
|
|
16
|
+
cold_result = yield
|
|
17
|
+
disable_if_different(preload_result, cold_result)
|
|
18
|
+
cold_result
|
|
19
|
+
rescue StandardError => e
|
|
20
|
+
@preload_server.disable!("preload failed: #{e.class}: #{e.message}")
|
|
21
|
+
yield
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def suspicious_result?(result)
|
|
27
|
+
Array(result).any? do |item|
|
|
28
|
+
item.respond_to?(:error) && item.error.to_s.match?(/Bootsnap|LoadError|NameError|Zeitwerk|ActiveRecord/)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def disable_if_different(preload_result, cold_result)
|
|
33
|
+
return if signature(preload_result) == signature(cold_result)
|
|
34
|
+
|
|
35
|
+
@preload_server.disable!('preload result differed from cold run')
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def signature(result)
|
|
39
|
+
Array(result).map do |item|
|
|
40
|
+
item.respond_to?(:status) ? [item.entity&.id, item.status, item.error] : item
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'English'
|
|
4
|
+
|
|
5
|
+
module Kaisoku
|
|
6
|
+
module Preload
|
|
7
|
+
class Server
|
|
8
|
+
attr_reader :generation, :last_restart_reason, :last_heartbeat_at, :worker_failures, :disabled_reason
|
|
9
|
+
|
|
10
|
+
def initialize(configuration: Configuration.new, checksum: Checksum.new)
|
|
11
|
+
@configuration = configuration
|
|
12
|
+
@checksum = checksum
|
|
13
|
+
@generation = 0
|
|
14
|
+
@last_restart_reason = 'initial boot'
|
|
15
|
+
@last_heartbeat_at = nil
|
|
16
|
+
@worker_failures = 0
|
|
17
|
+
@disabled_reason = nil
|
|
18
|
+
@loaded_checksums = {}
|
|
19
|
+
boot!
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def boot!
|
|
23
|
+
@generation += 1
|
|
24
|
+
heartbeat!
|
|
25
|
+
@loaded_checksums = loaded_file_checksums
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def stale_for?(changed_paths)
|
|
29
|
+
Array(changed_paths).any? do |path|
|
|
30
|
+
relative = @configuration.relative_path(path)
|
|
31
|
+
@configuration.boot_impact_file?(relative) || loaded_file_changed?(relative)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def refresh_if_needed(changed_paths)
|
|
36
|
+
return false if disabled?
|
|
37
|
+
return false unless stale_for?(changed_paths)
|
|
38
|
+
|
|
39
|
+
@last_restart_reason = "changed: #{Array(changed_paths).join(', ')}"
|
|
40
|
+
boot!
|
|
41
|
+
true
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def run
|
|
45
|
+
raise Error, "preload disabled: #{disabled_reason}" if disabled?
|
|
46
|
+
|
|
47
|
+
refresh_if_needed([])
|
|
48
|
+
heartbeat!
|
|
49
|
+
reader, writer = IO.pipe
|
|
50
|
+
pid = fork do
|
|
51
|
+
begin
|
|
52
|
+
reader.close
|
|
53
|
+
Marshal.dump(yield, writer)
|
|
54
|
+
ensure
|
|
55
|
+
writer.close
|
|
56
|
+
end
|
|
57
|
+
exit! 0
|
|
58
|
+
end
|
|
59
|
+
writer.close
|
|
60
|
+
result = Marshal.load(reader)
|
|
61
|
+
Process.wait(pid)
|
|
62
|
+
if $CHILD_STATUS.success?
|
|
63
|
+
@worker_failures = 0
|
|
64
|
+
heartbeat!
|
|
65
|
+
return result
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
@worker_failures += 1
|
|
69
|
+
@last_restart_reason = "worker failure: status #{$CHILD_STATUS.exitstatus}"
|
|
70
|
+
boot!
|
|
71
|
+
raise Error, "preload worker exited with status #{$CHILD_STATUS.exitstatus}"
|
|
72
|
+
rescue StandardError
|
|
73
|
+
raise
|
|
74
|
+
ensure
|
|
75
|
+
reader&.close
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def status
|
|
79
|
+
{
|
|
80
|
+
generation: generation,
|
|
81
|
+
loaded_files: @loaded_checksums.length,
|
|
82
|
+
last_restart_reason: last_restart_reason,
|
|
83
|
+
last_heartbeat_at: last_heartbeat_at,
|
|
84
|
+
worker_failures: worker_failures,
|
|
85
|
+
disabled_reason: disabled_reason,
|
|
86
|
+
healthy: !disabled?
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def disable!(reason)
|
|
91
|
+
@disabled_reason = reason
|
|
92
|
+
@last_restart_reason = reason
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def disabled?
|
|
96
|
+
!disabled_reason.nil?
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def heartbeat!
|
|
102
|
+
@last_heartbeat_at = Time.now.to_i
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def loaded_file_changed?(relative)
|
|
106
|
+
return false unless @loaded_checksums.key?(relative)
|
|
107
|
+
|
|
108
|
+
current = @checksum.digest(@configuration.absolute_path(relative))
|
|
109
|
+
current != @loaded_checksums[relative]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def loaded_file_checksums
|
|
113
|
+
$LOADED_FEATURES.each_with_object({}) do |file, checksums|
|
|
114
|
+
absolute = File.expand_path(file)
|
|
115
|
+
next unless @configuration.project_file?(absolute)
|
|
116
|
+
|
|
117
|
+
digest = @checksum.digest(absolute)
|
|
118
|
+
checksums[@configuration.relative_path(absolute)] = digest if digest
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kaisoku
|
|
4
|
+
class Prioritizer
|
|
5
|
+
def order(entities)
|
|
6
|
+
entities.sort_by do |entity|
|
|
7
|
+
[
|
|
8
|
+
entity.failed? ? 0 : 1,
|
|
9
|
+
-(entity.last_failed_at || 0),
|
|
10
|
+
-entity.failure_count.to_i,
|
|
11
|
+
entity.avg_duration_ms || Float::INFINITY,
|
|
12
|
+
entity.file,
|
|
13
|
+
entity.identifier
|
|
14
|
+
]
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|