farmbot_os/farmbot_celery_script/test/farmbot_celery_script/scheduler_test.exs

302 lines
7.7 KiB
Elixir
Raw Normal View History

2019-03-05 10:14:01 -07:00
defmodule FarmbotCeleryScript.SchedulerTest do
Implement new CeleryScript Runtime environment. This is obviously a rather large change warranting an essay describing it. A Brief overview Basically the old implementation had quite a few down sides preventing it from really working as intended, especially with the addition of the variables feature. Here is the shortlist of things that needed addressing: * No scoping between sequences. What this essentially means is that a sequence that executes another sequence is unable to add data to the calle. This is important for using Variables. * Error recovery certain nodes have a high likelyhood of failing such as anything that interfaces the firmware. Much focus was spent ensuring that errors would be recoverable when desired. * Complexity of control flow asts versus action asts. Nodes such as `if` will always work in the same way regardless of the state of the rest of the system meaning there is no reason for it to have a special implementation per environment. on the other hand `move_absolute` is bound to a specific part of the system. Seperating these concerns allows for better testing of each piece independently. A More In Depth overview The core of this change resolves around 1 really big change resulting in many more small changes. This change is the CeleryScript `compiler`. The TLDR of this system is that now CeleryScript ASTs are deterministicly compiled to Elixir's AST and executed. Doing this has some big benifits as described below. 1) CeleryScript "runtime" environment is now much simpiler in favor of a somewhat complex "compile time" environment. Basically instead of EVERY single CeleryScript AST having a custom runtime implementation, only a subset of ASTs that require external services such as the Firmware, Database, HTTP, etc require having a runtime implementation. This subset of ASTs are called `SysCalls`. Also the runtime implementations are compiled to a single function call that can be implemented instead of needing to have a contextual environment and making decisions at runtime to evaluate variables and the like. 2) Static analysis is now possible. This means an incorrectly crafted sequence can be validated at compile time rather than getting half way through a sequence before finding the error. 3) Having the "external services" separated leads to better plugability. There is now a behaviour to be implemented for the subset of syscalls that are system specific.
2019-02-20 12:57:45 -07:00
use ExUnit.Case, async: false
2019-03-05 10:14:01 -07:00
alias FarmbotCeleryScript.{Scheduler, Compiler, AST}
alias FarmbotCeleryScript.TestSupport.TestSysCalls
Implement new CeleryScript Runtime environment. This is obviously a rather large change warranting an essay describing it. A Brief overview Basically the old implementation had quite a few down sides preventing it from really working as intended, especially with the addition of the variables feature. Here is the shortlist of things that needed addressing: * No scoping between sequences. What this essentially means is that a sequence that executes another sequence is unable to add data to the calle. This is important for using Variables. * Error recovery certain nodes have a high likelyhood of failing such as anything that interfaces the firmware. Much focus was spent ensuring that errors would be recoverable when desired. * Complexity of control flow asts versus action asts. Nodes such as `if` will always work in the same way regardless of the state of the rest of the system meaning there is no reason for it to have a special implementation per environment. on the other hand `move_absolute` is bound to a specific part of the system. Seperating these concerns allows for better testing of each piece independently. A More In Depth overview The core of this change resolves around 1 really big change resulting in many more small changes. This change is the CeleryScript `compiler`. The TLDR of this system is that now CeleryScript ASTs are deterministicly compiled to Elixir's AST and executed. Doing this has some big benifits as described below. 1) CeleryScript "runtime" environment is now much simpiler in favor of a somewhat complex "compile time" environment. Basically instead of EVERY single CeleryScript AST having a custom runtime implementation, only a subset of ASTs that require external services such as the Firmware, Database, HTTP, etc require having a runtime implementation. This subset of ASTs are called `SysCalls`. Also the runtime implementations are compiled to a single function call that can be implemented instead of needing to have a contextual environment and making decisions at runtime to evaluate variables and the like. 2) Static analysis is now possible. This means an incorrectly crafted sequence can be validated at compile time rather than getting half way through a sequence before finding the error. 3) Having the "external services" separated leads to better plugability. There is now a behaviour to be implemented for the subset of syscalls that are system specific.
2019-02-20 12:57:45 -07:00
setup do
{:ok, shim} = TestSysCalls.checkout()
{:ok, sch} = Scheduler.start_link([], [])
[shim: shim, sch: sch]
end
test "syscall errors", %{sch: sch} do
execute_ast =
%{
kind: :rpc_request,
args: %{label: "hello world"},
body: [
%{kind: :read_pin, args: %{pin_number: 1, pin_mode: 0}}
]
}
|> AST.decode()
executed = Compiler.compile(execute_ast)
:ok =
TestSysCalls.handle(TestSysCalls, fn
:read_pin, _ -> {:error, "failed to read pin!"}
end)
{:ok, execute_ref} = Scheduler.schedule(sch, executed)
assert_receive {Scheduler, ^execute_ref, {:error, "failed to read pin!"}}
end
@tag :annoying
test "regular exceptions still occur", %{sch: sch} do
Process.flag(:trap_exit, true)
execute_ast =
%{
kind: :rpc_request,
args: %{label: "hello world"},
body: [
%{kind: :read_pin, args: %{pin_number: 1, pin_mode: 0}}
]
}
|> AST.decode()
# {:ok, execute_ref} = Scheduler.schedule(sch, executed)
# refute_receive {Scheduler, ^execute_ref, {:error, "failed to read pin!"}}
# assert_receive {:EXIT, ^sch, _}
executed = Compiler.compile(execute_ast)
:ok =
TestSysCalls.handle(TestSysCalls, fn
:read_pin, _ -> raise("failed to read pin!")
end)
{:ok, execute_ref} = Scheduler.execute(sch, executed)
refute_receive {Scheduler, ^execute_ref, {:error, "failed to read pin!"}}
assert_receive {:EXIT, ^sch, _}, 1000
end
test "executing a sequence on top of a scheduled sequence", %{sch: sch} do
scheduled_ast =
%{
kind: :sequence,
args: %{locals: %{kind: :variable_declaration, args: %{}}},
body: [
%{kind: :wait, args: %{milliseconds: 2000}},
%{kind: :write_pin, args: %{pin_number: 1, pin_mode: 0, pin_value: 1}}
]
}
|> AST.decode()
scheduled = Compiler.compile(scheduled_ast)
execute_ast =
%{
kind: :rpc_request,
args: %{label: "hello world"},
body: [
%{kind: :read_pin, args: %{pin_number: 1, pin_mode: 0}}
]
}
|> AST.decode()
executed = Compiler.compile(execute_ast)
pid = self()
:ok =
TestSysCalls.handle(TestSysCalls, fn
:wait, [millis] ->
send(pid, {:wait, :os.system_time()})
Process.sleep(millis)
:write_pin, _ ->
send(pid, {:write_pin, :os.system_time()})
:ok
:read_pin, _ ->
send(pid, {:read_pin, :os.system_time()})
1
end)
{:ok, scheduled_ref} = Scheduler.schedule(sch, scheduled)
{:ok, execute_ref} = Scheduler.schedule(sch, executed)
assert_receive {Scheduler, ^scheduled_ref, :ok}, 5_000
assert_receive {Scheduler, ^execute_ref, :ok}, 5_000
assert_receive {:wait, time_1}
assert_receive {:read_pin, time_2}
assert_receive {:write_pin, time_3}
assert [^time_1, ^time_3, ^time_2] = Enum.sort([time_1, time_2, time_3], &(&1 <= &2))
end
test "execute twice", %{sch: sch} do
execute_ast_1 =
%{
kind: :rpc_request,
args: %{label: "hello world 1"},
body: [
%{kind: :wait, args: %{milliseconds: 1000}}
]
}
|> AST.decode()
execute_ast_2 =
%{
kind: :rpc_request,
args: %{label: "hello world 2"},
body: [
%{kind: :read_pin, args: %{pin_number: 1, pin_mode: 0}}
]
}
|> AST.decode()
execute_1 = Compiler.compile(execute_ast_1)
execute_2 = Compiler.compile(execute_ast_2)
pid = self()
:ok =
TestSysCalls.handle(TestSysCalls, fn
:wait, [millis] ->
send(pid, {:wait, :os.system_time()})
Process.sleep(millis)
:read_pin, _ ->
send(pid, {:read_pin, :os.system_time()})
1
end)
task_1 =
Task.async(fn ->
{:ok, execute_ref_1} = Scheduler.execute(sch, execute_1)
IO.inspect(execute_ref_1, label: "task_1")
assert_receive {Scheduler, ^execute_ref_1, :ok}, 3000
end)
task_2 =
Task.async(fn ->
{:ok, execute_ref_2} = Scheduler.execute(sch, execute_2)
IO.inspect(execute_ref_2, label: "task_2")
assert_receive {Scheduler, ^execute_ref_2, :ok}, 3000
end)
_ = Task.await(task_1)
_ = Task.await(task_2)
assert_receive {:wait, time_1}
assert_receive {:read_pin, time_2}
assert time_2 >= time_1 + 1000
end
test "execute then schedule", %{sch: sch} do
execute_ast_1 =
%{
kind: :rpc_request,
args: %{label: "hello world 1"},
body: [
%{kind: :wait, args: %{milliseconds: 1000}}
]
}
|> AST.decode()
schedule_ast_1 =
%{
kind: :sequence,
args: %{locals: %{kind: :variable_declaration, args: %{}}},
body: [
%{kind: :read_pin, args: %{pin_number: 1, pin_mode: 0}}
]
}
|> AST.decode()
execute_1 = Compiler.compile(execute_ast_1)
schedule_1 = Compiler.compile(schedule_ast_1)
pid = self()
:ok =
TestSysCalls.handle(TestSysCalls, fn
:wait, [millis] ->
send(pid, {:wait, :os.system_time()})
Process.sleep(millis)
:read_pin, _ ->
send(pid, {:read_pin, :os.system_time()})
1
end)
task_1 =
Task.async(fn ->
{:ok, execute_ref_1} = Scheduler.execute(sch, execute_1)
IO.inspect(execute_ref_1, label: "task_1")
assert_receive {Scheduler, ^execute_ref_1, :ok}, 3000
end)
task_2 =
Task.async(fn ->
{:ok, execute_ref_2} = Scheduler.execute(sch, schedule_1)
IO.inspect(execute_ref_2, label: "task_2")
assert_receive {Scheduler, ^execute_ref_2, :ok}, 3000
end)
_ = Task.await(task_1)
_ = Task.await(task_2)
assert_receive {:wait, time_1}
assert_receive {:read_pin, time_2}
# Assert that the read pin didn't execute until the wait is complete
assert time_2 >= time_1 + 1000
end
test "schedule and execute simotaniously", %{sch: sch} do
schedule_ast_1 =
%{
kind: :sequence,
args: %{locals: %{kind: :variable_declaration, args: %{}}},
body: [
%{kind: :wait, args: %{milliseconds: 2500}}
]
}
|> AST.decode()
execute_ast_1 =
%{
kind: :rpc_request,
args: %{label: "hello world 1"},
body: [
%{kind: :read_pin, args: %{pin_number: 1, pin_mode: 0}}
]
}
|> AST.decode()
schedule_1 = Compiler.compile(schedule_ast_1)
execute_1 = Compiler.compile(execute_ast_1)
pid = self()
:ok =
TestSysCalls.handle(TestSysCalls, fn
:wait, [millis] ->
send(pid, {:wait, :os.system_time()})
Process.sleep(millis)
:read_pin, _ ->
send(pid, {:read_pin, :os.system_time()})
1
end)
task_1 =
Task.async(fn ->
{:ok, schedule_ref_1} = Scheduler.schedule(sch, schedule_1)
IO.inspect(schedule_ref_1, label: "task_1")
assert_receive {Scheduler, ^schedule_ref_1, :ok}, 3000
end)
task_2 =
Task.async(fn ->
{:ok, execute_ref_1} = Scheduler.execute(sch, execute_1)
IO.inspect(execute_ref_1, label: "task_2")
assert_receive {Scheduler, ^execute_ref_1, :ok}, 3000
end)
_ = Task.await(task_1)
_ = Task.await(task_2)
assert_receive {:wait, time_1}
assert_receive {:read_pin, time_2}
# Assert that the read pin executed and finished before the wait.
assert time_2 <= time_1 + 2500
end
end