Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions shard.lock
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ shards:

openssl_ext:
git: https://github.com/spider-gazelle/openssl_ext.git
version: 2.8.2
version: 2.8.3

pars: # Overridden
git: https://github.com/spider-gazelle/pars.git
Expand Down Expand Up @@ -187,7 +187,7 @@ shards:

placeos-models:
git: https://github.com/placeos/models.git
version: 9.81.1
version: 9.82.0

placeos-resource:
git: https://github.com/place-labs/resource.git
Expand Down
44 changes: 44 additions & 0 deletions spec/api/command_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,50 @@ module PlaceOS::Core::Api
ensure
resource_manager.try &.stop
end

it "executes a command on a lazy module (launch_on_execute)" do
_, _, mod, resource_manager = create_resources
mod_id = mod.id.as(String)

# Set module as lazy-load
mod.launch_on_execute = true
mod.running = true
mod.save!

module_manager = module_manager_mock
# Register as lazy (don't spawn driver)
module_manager.load_module(mod)
Command.mock_module_manager = module_manager

# Verify driver is not spawned
module_manager.local_processes.module_loaded?(mod_id).should be_false
module_manager.lazy_module?(mod_id).should be_true

# Execute should work (will spawn driver on demand)
route = File.join(namespace, mod_id, "execute")
response = client.post(route, headers: json_headers, body: EXEC_PAYLOAD)
response.status_code.should eq 200

result = response.body rescue nil
result.should eq %("you can delete this file")
ensure
resource_manager.try &.stop
end

it "returns 404 for non-lazy module that is not loaded" do
_, _, mod = setup(role: PlaceOS::Model::Driver::Role::Service)
mod_id = mod.id.as(String)

# Don't load the module, but it's not lazy either
module_manager = module_manager_mock
Command.mock_module_manager = module_manager

route = File.join(namespace, mod_id, "execute")
response = client.post(route, headers: json_headers, body: EXEC_PAYLOAD)
response.status_code.should eq 404
ensure
module_manager.try &.stop
end
end

describe "command/:module_id/debugger" do
Expand Down
192 changes: 192 additions & 0 deletions spec/module_manager_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,198 @@
end
end

describe "lazy modules (launch_on_execute)" do
it "registers lazy module without spawning driver" do
_, driver, mod = setup(role: PlaceOS::Model::Driver::Role::Service)

module_manager = module_manager_mock
builder = DriverResource.new(startup: true, module_manager: module_manager)
resource_manager = ResourceManager.new(driver_builder: builder)
resource_manager.start { }

mod_id = mod.id.as(String)

mod.reload!
mod.driver = mod.driver.not_nil!.reload!

# Set module as lazy-load
mod.launch_on_execute = true
mod.running = true
mod.save!

# Load the lazy module
module_manager.load_module(mod)

# Driver should NOT be spawned
module_manager.local_processes.run_count.modules.should eq 0
module_manager.local_processes.module_loaded?(mod_id).should be_false

# But module should be registered as lazy
module_manager.lazy_module?(mod_id).should be_true

# Metadata should be populated in Redis
metadata = Driver::RedisStorage.with_redis { |r| r.get("interface/#{mod_id}") }

Check notice on line 85 in spec/module_manager_spec.cr

View workflow job for this annotation

GitHub Actions / Ameba

Style/VerboseBlock

Use short block notation instead: `with_redis(&.get("interface/#{mod_id}"))`
Raw output
> metadata = Driver::RedisStorage.with_redis { |r| r.get("interface/#{mod_id}") }
                                  ^
metadata.should_not be_nil
ensure
module_manager.try &.stop
resource_manager.try &.stop
end

it "spawns driver on execute and unloads after idle" do
# Use a short unload delay for testing
original_delay = ModuleManager.lazy_unload_delay
ModuleManager.lazy_unload_delay = 500.milliseconds

_, driver, mod = setup(role: PlaceOS::Model::Driver::Role::Service)

module_manager = module_manager_mock
builder = DriverResource.new(startup: true, module_manager: module_manager)
resource_manager = ResourceManager.new(driver_builder: builder)
resource_manager.start { }

mod_id = mod.id.as(String)

mod.reload!
mod.driver = mod.driver.not_nil!.reload!

# Set module as lazy-load
mod.launch_on_execute = true
mod.running = true
mod.save!

# Reload to get fresh associations
mod = Model::Module.find!(mod_id)
mod.driver = driver.reload!

# Register the lazy module
module_manager.load_module(mod)
module_manager.lazy_module?(mod_id).should be_true
module_manager.local_processes.module_loaded?(mod_id).should be_false

# Execute should spawn driver and load module
result, code = module_manager.local_processes.execute(
module_id: mod_id,
payload: ModuleManager.execute_payload(:used_for_place_testing),
user_id: nil
)

result.should eq %("you can delete this file")
code.should eq 200

# Module should now be loaded
module_manager.local_processes.module_loaded?(mod_id).should be_true

# Wait for idle unload
sleep 1.second

# Module should be unloaded and back to lazy state
module_manager.local_processes.module_loaded?(mod_id).should be_false
module_manager.lazy_module?(mod_id).should be_true

# Metadata should still be in Redis
metadata = Driver::RedisStorage.with_redis { |r| r.get("interface/#{mod_id}") }

Check notice on line 144 in spec/module_manager_spec.cr

View workflow job for this annotation

GitHub Actions / Ameba

Style/VerboseBlock

Use short block notation instead: `with_redis(&.get("interface/#{mod_id}"))`
Raw output
> metadata = Driver::RedisStorage.with_redis { |r| r.get("interface/#{mod_id}") }
                                  ^
metadata.should_not be_nil
ensure
ModuleManager.lazy_unload_delay = original_delay.not_nil!
module_manager.try &.stop
resource_manager.try &.stop
end

it "does not unload while executions are active" do
original_delay = ModuleManager.lazy_unload_delay
ModuleManager.lazy_unload_delay = 200.milliseconds

_, driver, mod = setup(role: PlaceOS::Model::Driver::Role::Service)

module_manager = module_manager_mock
builder = DriverResource.new(startup: true, module_manager: module_manager)
resource_manager = ResourceManager.new(driver_builder: builder)
resource_manager.start { }

mod_id = mod.id.as(String)

mod.reload!
mod.driver = mod.driver.not_nil!.reload!

mod.launch_on_execute = true
mod.running = true
mod.save!

module_manager.load_module(mod)

# Start multiple concurrent executions
results = Channel(Tuple(String, Int32)).new(3)

3.times do
spawn do
r, c = module_manager.local_processes.execute(
module_id: mod_id,
payload: ModuleManager.execute_payload(:used_for_place_testing),
user_id: nil
)
results.send({r, c})
end
end

# Collect results
3.times do
result, code = results.receive
result.should eq %("you can delete this file")
code.should eq 200
end

# Module should still be loaded (unload scheduled but not executed yet)
# Give a tiny bit of time for the last execution to complete
sleep 50.milliseconds
module_manager.local_processes.module_loaded?(mod_id).should be_true

# Wait for unload
sleep 500.milliseconds
module_manager.local_processes.module_loaded?(mod_id).should be_false
ensure
ModuleManager.lazy_unload_delay = original_delay.not_nil!
module_manager.try &.stop
resource_manager.try &.stop
end

it "clears metadata when lazy module is stopped" do
_, driver, mod = setup(role: PlaceOS::Model::Driver::Role::Service)

module_manager = module_manager_mock
builder = DriverResource.new(startup: true, module_manager: module_manager)
resource_manager = ResourceManager.new(driver_builder: builder)
resource_manager.start { }

mod_id = mod.id.as(String)

mod.reload!
mod.driver = mod.driver.not_nil!.reload!

mod.launch_on_execute = true
mod.running = true
mod.save!

module_manager.load_module(mod)

# Metadata should exist
metadata = Driver::RedisStorage.with_redis { |r| r.get("interface/#{mod_id}") }

Check notice on line 229 in spec/module_manager_spec.cr

View workflow job for this annotation

GitHub Actions / Ameba

Style/VerboseBlock

Use short block notation instead: `with_redis(&.get("interface/#{mod_id}"))`
Raw output
> metadata = Driver::RedisStorage.with_redis { |r| r.get("interface/#{mod_id}") }
                                  ^
metadata.should_not be_nil

# Stop the module
module_manager.stop_module(mod)

# Metadata should be cleared
metadata = Driver::RedisStorage.with_redis { |r| r.get("interface/#{mod_id}") }

Check notice on line 236 in spec/module_manager_spec.cr

View workflow job for this annotation

GitHub Actions / Ameba

Style/VerboseBlock

Use short block notation instead: `with_redis(&.get("interface/#{mod_id}"))`
Raw output
> metadata = Driver::RedisStorage.with_redis { |r| r.get("interface/#{mod_id}") }
                                  ^
metadata.should be_nil

# Module should not be in lazy tracking
module_manager.lazy_module?(mod_id).should be_false
ensure
module_manager.try &.stop
resource_manager.try &.stop
end
end

describe "startup" do
it "registers to redis" do
# Clear relevant tables
Expand Down
12 changes: 10 additions & 2 deletions src/api/command.cr
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,17 @@ module PlaceOS::Core::Api
@[AC::Param::Info(description: "the user context for the execution", example: "user-1234")]
user_id : String? = nil,
) : Nil
# Check if module is loaded, or is a lazy module that can be loaded on demand
unless module_manager.process_manager(module_id, &.module_loaded?(module_id))
Log.info { {module_id: module_id, message: "module not loaded"} }
raise Error::NotFound.new("module #{module_id} not loaded")
# Not loaded - check if it's a lazy module
unless module_manager.lazy_module?(module_id)
# Not a registered lazy module - check DB for launch_on_execute flag
mod = Model::Module.find(module_id)
unless mod && mod.launch_on_execute
Log.info { {module_id: module_id, message: "module not loaded"} }
raise Error::NotFound.new("module #{module_id} not loaded")
end
end
end

# NOTE:: we don't use the AC body helper for performance reasons.
Expand Down
Loading
Loading