Skip to content
Merged
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## 0.15.1 - 2026-09-16

- Use the existing cleanup index when finding expired actor instances. Preserve
creation-time fallback for never-used instances and the strict retention cutoff.

## 0.15.0 - 2026-09-15

- Maintain effect-owner heartbeats during long-running handlers, completion, and
Expand Down
4 changes: 2 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
solid_objects (0.15.0)
solid_objects (0.15.1)
actioncable (>= 7.1)
actionpack (>= 7.1)
actionview (>= 7.1)
Expand Down Expand Up @@ -384,7 +384,7 @@ CHECKSUMS
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
solid_objects (0.15.0)
solid_objects (0.15.1)
sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc
sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d
sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b
Expand Down
3 changes: 2 additions & 1 deletion lib/solid_objects/instance_pruner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,10 @@ def prune
# @rbs () -> Array[ActiveRecord::Relation[Instance]]
def policy_relations
SolidObjects.configuration.instance_retention_by_actor_type.map do |actor_type, retention|
cutoff = now - retention
prunable
.where(actor_type: actor_type.to_s)
.where("COALESCE(last_used_at, created_at) < ?", now - retention)
.where("last_used_at < ? OR (last_used_at IS NULL AND created_at < ?)", cutoff, cutoff)
end
end

Expand Down
2 changes: 1 addition & 1 deletion lib/solid_objects/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# rbs_inline: enabled

module SolidObjects
VERSION = "0.15.0"
VERSION = "0.15.1"
end
62 changes: 62 additions & 0 deletions test/integration/instance_pruner_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# rbs_inline: enabled

require "database_test_helper"

class InstancePrunerTest < ActiveSupport::TestCase
test "retention uses the last use or creation time with a strict cutoff" do
now = Time.utc(2026, 9, 16, 12)
cutoff = now - 30.days
SolidObjects.configuration.instance_retention_by_actor_type = { "expiry-test" => 30.days }
expired = [
create_instance("unused-old", created_at: cutoff - 1.second, last_used_at: nil),
create_instance("used-old", created_at: now, last_used_at: cutoff - 1.second)
]
retained = [
create_instance("unused-boundary", created_at: cutoff, last_used_at: nil),
create_instance("unused-recent", created_at: now, last_used_at: nil),
create_instance("used-boundary", created_at: cutoff - 1.day, last_used_at: cutoff),
create_instance("used-recent", created_at: cutoff - 1.day, last_used_at: now)
]
pruner = SolidObjects::InstancePruner.new(now:)

assert_equal 2, pruner.preview
assert_equal 2, pruner.prune
assert_empty SolidObjects::Instance.where(id: expired.map(&:id))
assert_equal retained.map(&:id).sort, SolidObjects::Instance.order(:id).pluck(:id)
end

test "MySQL finds expired candidates through the cleanup index" do
skip "requires a MySQL query plan" unless database_family == :mysql

now = Time.utc(2026, 9, 16, 12)
SolidObjects.configuration.instance_retention_by_actor_type = { "expiry-test" => 30.days }
SolidObjects::Instance.insert_all!(Array.new(2_000) { |index|
{ actor_type: "expiry-test", actor_id: "recent-#{index}", state: {},
created_at: now, updated_at: now, last_used_at: now }
})
connection = SolidObjects::Instance.connection
connection.execute("ANALYZE TABLE solid_objects_instances")
statements = []
subscriber = ->(*arguments) { statements << arguments.last[:sql].to_s }

ActiveSupport::Notifications.subscribed(subscriber, "sql.active_record") do
assert_equal 0, SolidObjects::InstancePruner.new(now:).prune
end

query = statements.find { |sql| sql.match?(/\ASELECT .* FROM `solid_objects_instances` /) }
assert query, "the candidate lookup was not captured"
plan = connection.select_all("EXPLAIN FORMAT=TRADITIONAL #{query}").to_a
.find { |row| row["table"] == "solid_objects_instances" }

assert plan, "the candidate table was not present in the query plan"
assert_equal "idx_so_instances_cleanup", plan&.fetch("key")
assert_operator plan&.fetch("rows").to_i, :<, 20
end

private

# @rbs (String, created_at: Time, last_used_at: Time?) -> SolidObjects::Instance
def create_instance(actor_id, created_at:, last_used_at:)
SolidObjects::Instance.create!(actor_type: "expiry-test", actor_id:, created_at:, last_used_at:)
end
end
Loading