Skip to content

Commit 2e550b8

Browse files
committed
Merge branch 'main' of github.com:ralsina/croupier
2 parents 51ca2dd + 0001311 commit 2e550b8

File tree

6 files changed

+206
-16
lines changed

6 files changed

+206
-16
lines changed

CHANGES.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## Version 0.3.1
4+
5+
* Added auto_run / auto_stop that control a "watchdog" fiber that
6+
automatically runs tasks if their dependencies change.
7+
38
## Version 0.3.0
49

510
* Removed name parameter

TODO.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,16 @@
33
## Things it may make sense to add
44

55
* Instrument the concurrent runner using [Fiber Metrics](https://github.com/didactic-drunk/fiber_metrics.cr)
6-
* Once it works fine with files, generalize to a k/v store using [kiwi](ihttps://github.com/crystal-community/kiwi)
6+
* Once it works fine with files, generalize to a k/v store using [kiwi](https://github.com/crystal-community/kiwi)
77
* Use state machines for tasks (see veelenga/aasm.cr)
8-
* Implement -k -i make options (keep going / ignore errors)
98
* Add a faster stale input check using file dates instead of hashes (like make)
109
* Add directory dependencies (depend on all files in the tree)
1110
* Add wildcard dependencies (depend on all files / tasks matching a pattern)
1211
* Implement failed state for tasks
13-
* Implement a "watchdog" mode
12+
* Implement -k -i make options (keep going / ignore errors)
13+
* Decide what to do in auto_run when no task has inputs
1414

15+
* ~~Implement a "watchdog" mode~~
1516
* ~~Rationalize id/name/output thing~~
1617
* ~~Make it fast again :-)~~ [Sort of]
1718
* ~~Implement the missing parts of the parallel runner~~

shard.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: croupier
2-
version: 0.2.4
2+
version: 0.3.1
33
description: A smart task definition and execution library
44
authors:
55
- Roberto Alsina <roberto.alsina@gmail.com>
@@ -11,6 +11,8 @@ license: MIT
1111
dependencies:
1212
crystalline:
1313
github: ralsina/crystalline
14+
inotify:
15+
github: petoem/inotify.cr
1416

1517
# Not the same crystalline as above :-)
1618
crystalline:

spec/croupier_spec.cr

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ def with_scenario(
1717
"dummy" => TaskProc.new { "" },
1818
"counter" => TaskProc.new {
1919
x += 1
20-
""
20+
x.to_s
2121
},
2222
"output2" => TaskProc.new {
2323
x += 1
@@ -686,6 +686,114 @@ describe "TaskManager" do
686686
end
687687
end
688688

689+
describe "watch" do
690+
it "should always start with no queued changes" do
691+
with_scenario("basic", to_create: {"input" => "foo"}) do
692+
TaskManager.watch
693+
Fiber.yield
694+
TaskManager.@queued_changes.empty?.should be_true
695+
end
696+
end
697+
698+
it "should queue changed inputs" do
699+
with_scenario("basic", to_create: {"input" => "foo"}) do
700+
TaskManager.watch
701+
File.open("input", "w") << "bar"
702+
# We need to yield or else the watch callbacks never run
703+
Fiber.yield
704+
TaskManager.@queued_changes.should eq Set{"input"}
705+
File.open("input2", "w") << "foo"
706+
Fiber.yield
707+
TaskManager.@queued_changes.should eq Set{"input", "input2"}
708+
end
709+
end
710+
end
711+
712+
describe "auto_run" do
713+
it "should run tasks when inputs change" do
714+
with_scenario("basic") do
715+
TaskManager.auto_run
716+
# We need to yield or else the watch callbacks never run
717+
Fiber.yield
718+
# At this point output3 doesn't exist
719+
File.exists?("output3").should be_false
720+
# We create input, which is output3's dependency
721+
File.open("input", "w") << "bar"
722+
Fiber.yield
723+
# Tasks are not runnable (missing input2)
724+
File.exists?("output3").should be_false
725+
# We create input, which is output3's dependency
726+
File.open("input2", "w") << "bar"
727+
Fiber.yield
728+
TaskManager.auto_stop
729+
# And now output3 should exist
730+
File.exists?("output3").should be_true
731+
end
732+
end
733+
734+
it "should not re-raise exceptions" do
735+
with_scenario("empty") do
736+
x = 0
737+
error_proc = TaskProc.new { x += 1; raise "boom" }
738+
Task.new(output: "t1", inputs: ["i"], proc: error_proc)
739+
TaskManager.auto_run
740+
Fiber.yield
741+
File.open("i", "w") << "foo"
742+
# We need to yield or else the watch callbacks never run
743+
Fiber.yield
744+
# auto_run logs all errors and continues, because it's
745+
# normal to have failed runs in auto mode
746+
TaskManager.auto_stop
747+
# It should have run
748+
(x > 0).should be_true
749+
end
750+
end
751+
752+
it "should not run when no inputs have changed" do
753+
with_scenario("empty") do
754+
x = 0
755+
counter = TaskProc.new { x += 1; x.to_s }
756+
Task.new(output: "t1", inputs: ["i"], proc: counter)
757+
TaskManager.auto_run
758+
# We need to yield or else the watch callbacks never run
759+
Fiber.yield
760+
TaskManager.auto_stop
761+
# It should never have ran
762+
x.should eq 0
763+
end
764+
end
765+
766+
it "should run only when inputs have changed" do
767+
with_scenario("empty") do
768+
x = 0
769+
counter = TaskProc.new { x += 1; x.to_s }
770+
Task.new(output: "t1", inputs: ["i"], proc: counter)
771+
TaskManager.auto_run
772+
Fiber.yield
773+
File.open("i", "w") << "foo"
774+
Fiber.yield
775+
TaskManager.auto_stop
776+
# It should only have ran once
777+
x.should eq 1
778+
end
779+
end
780+
781+
it "should run tasks without outputs" do
782+
with_scenario("empty") do
783+
x = 0
784+
counter = TaskProc.new { x += 1; x.to_s }
785+
Task.new(id: "t1", inputs: ["i"], proc: counter)
786+
TaskManager.auto_run
787+
Fiber.yield
788+
File.open("i", "w") << "foo"
789+
Fiber.yield
790+
TaskManager.auto_stop
791+
# It should only have ran once
792+
x.should eq 1
793+
end
794+
end
795+
end
796+
689797
describe "dependencies" do
690798
it "should report all tasks required to produce an output" do
691799
with_scenario("basic", to_create: {"input" => "foo", "input2" => "bar"}) do

spec/testcases/basic/tasks.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ output1:
88
always_run: false
99
no_save: false
1010
stale: true
11-
procs: "dummy"
11+
procs: "counter"
1212
output2:
1313
id: 052cd9c6f04c7451518f3f12a3f48caad072ec2a
1414
name: name

src/croupier.cr

Lines changed: 84 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
# Croupier describes a task graph and lets you operate on them
2-
require "digest/sha1"
3-
require "yaml"
2+
require "./topo_sort"
43
require "crystalline"
4+
require "digest/sha1"
5+
require "inotify"
56
require "log"
6-
require "./topo_sort"
7+
require "yaml"
78

89
module Croupier
9-
VERSION = "0.2.4"
10+
VERSION = "0.3.1"
1011

1112
# A Task is an object that may generate output
1213
#
@@ -154,7 +155,6 @@ module Croupier
154155
return true if @always_run || @inputs.empty?
155156
# Tasks don't get stale twice
156157
return false unless @stale
157-
158158
@outputs.any? { |output| !File.exists?(output) } ||
159159
# Any input file is modified
160160
@inputs.any? { |input| TaskManager.modified.includes? input } ||
@@ -203,7 +203,7 @@ module Croupier
203203
end
204204
end
205205

206-
struct TaskManagerType
206+
class TaskManagerType
207207
# Registry of all tasks
208208
property tasks = {} of String => Croupier::Task
209209
# Registry of modified files, which will make tasks stale
@@ -215,6 +215,8 @@ module Croupier
215215
# SAH1 of input files as of ending this run
216216
property next_run = {} of String => String
217217

218+
@queued_changes : Set(String) = Set(String).new
219+
218220
# Remove all tasks and everything else (good for tests)
219221
def cleanup
220222
modified.clear
@@ -393,12 +395,13 @@ module Croupier
393395
finished = Set(Task).new
394396
outputs.each do |output|
395397
next unless tasks.has_key?(output)
396-
next if finished.includes?(tasks[output])
397-
next unless run_all || tasks[output].stale? || tasks[output].@always_run
398+
t = tasks[output]
399+
next if finished.includes?(t)
400+
next unless run_all || t.stale? || t.@always_run
398401

399402
Log.debug { "Running task for #{output}" }
400-
tasks[output].run unless dry_run
401-
finished << tasks[output]
403+
t.run unless dry_run
404+
finished << t
402405
end
403406
save_run
404407
end
@@ -452,6 +455,77 @@ module Croupier
452455
# FIXME It's losing outputs for some reason
453456
save_run
454457
end
458+
459+
@autorun_control = Channel(Bool).new
460+
461+
def auto_stop
462+
@autorun_control.send true
463+
@autorun_control.receive?
464+
@autorun_control = Channel(Bool).new
465+
end
466+
467+
def auto_run
468+
# TODO consider how to handle task trees with no inputs
469+
# should they run? Once? Infinite times?
470+
watch
471+
spawn do
472+
loop do
473+
select
474+
when @autorun_control.receive
475+
Log.info { "Stopping automatic run" }
476+
@autorun_control.close
477+
break
478+
else
479+
begin
480+
# Sleep early is better for race conditions in tests
481+
# If we sleep late, it's likely that we'll get the
482+
# stop order and break the loop without running, so
483+
# we can't see the side effects without sleeping in
484+
# the tests.
485+
sleep 0.1.seconds
486+
# next if @queued_changes.empty?
487+
Log.info { "Detected changes in #{@queued_changes}" }
488+
self.modified += @queued_changes
489+
run_tasks
490+
# Only clean queued changes after a successful run
491+
@queued_changes.clear
492+
rescue ex
493+
# Sometimes we can't run because not all dependencies
494+
# are there yet or whatever. We'll try again later
495+
unless ex.message.to_s.starts_with?("Can't run: Unknown inputs")
496+
Log.warn { "Automatic run failed (will retry): #{ex.message}" }
497+
end
498+
end
499+
end
500+
end
501+
end
502+
end
503+
504+
# Watch for changes in inputs.
505+
# If an input has been changed BEFORE calling this method,
506+
# it will NOT be detected as a change.
507+
#
508+
# Changes are added to queued_changes
509+
def watch
510+
all_inputs.each do |input|
511+
if File.exists? input
512+
Inotify.watch input do |event|
513+
unless event.name.nil?
514+
@queued_changes << event.name.to_s
515+
end
516+
end
517+
else
518+
# It's a file that doesn't exist. To detect it
519+
# being created, we watch the directory
520+
Inotify.watch((Path[input].parent).to_s) do |event|
521+
if all_inputs.includes? event.name
522+
# It's a file we care about, add it to the queue
523+
@queued_changes << event.name.to_s
524+
end
525+
end
526+
end
527+
end
528+
end
455529
end
456530

457531
# The global task manager (singleton)

0 commit comments

Comments
 (0)