diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f5fc4a..7216aa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index e641844..e4f588d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -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 diff --git a/lib/solid_objects/instance_pruner.rb b/lib/solid_objects/instance_pruner.rb index d66c13b..3315ea5 100644 --- a/lib/solid_objects/instance_pruner.rb +++ b/lib/solid_objects/instance_pruner.rb @@ -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 diff --git a/lib/solid_objects/version.rb b/lib/solid_objects/version.rb index 1d09f5e..fad4efd 100644 --- a/lib/solid_objects/version.rb +++ b/lib/solid_objects/version.rb @@ -1,5 +1,5 @@ # rbs_inline: enabled module SolidObjects - VERSION = "0.15.0" + VERSION = "0.15.1" end diff --git a/test/integration/instance_pruner_test.rb b/test/integration/instance_pruner_test.rb new file mode 100644 index 0000000..57c8b84 --- /dev/null +++ b/test/integration/instance_pruner_test.rb @@ -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