Skip to content

Commit 99c41e1

Browse files
authored
Merge pull request #69 from cardmagic/fix/indexed-instance-retention
Use the cleanup index for actor retention
2 parents 0630154 + efa407e commit 99c41e1

5 files changed

Lines changed: 72 additions & 4 deletions

File tree

‎CHANGELOG.md‎

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

3+
## 0.15.1 - 2026-09-16
4+
5+
- Use the existing cleanup index when finding expired actor instances. Preserve
6+
creation-time fallback for never-used instances and the strict retention cutoff.
7+
38
## 0.15.0 - 2026-09-15
49

510
- Maintain effect-owner heartbeats during long-running handlers, completion, and

‎Gemfile.lock‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
solid_objects (0.15.0)
4+
solid_objects (0.15.1)
55
actioncable (>= 7.1)
66
actionpack (>= 7.1)
77
actionview (>= 7.1)
@@ -384,7 +384,7 @@ CHECKSUMS
384384
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
385385
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
386386
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
387-
solid_objects (0.15.0)
387+
solid_objects (0.15.1)
388388
sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc
389389
sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d
390390
sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b

‎lib/solid_objects/instance_pruner.rb‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,10 @@ def prune
3333
# @rbs () -> Array[ActiveRecord::Relation[Instance]]
3434
def policy_relations
3535
SolidObjects.configuration.instance_retention_by_actor_type.map do |actor_type, retention|
36+
cutoff = now - retention
3637
prunable
3738
.where(actor_type: actor_type.to_s)
38-
.where("COALESCE(last_used_at, created_at) < ?", now - retention)
39+
.where("last_used_at < ? OR (last_used_at IS NULL AND created_at < ?)", cutoff, cutoff)
3940
end
4041
end
4142

‎lib/solid_objects/version.rb‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# rbs_inline: enabled
22

33
module SolidObjects
4-
VERSION = "0.15.0"
4+
VERSION = "0.15.1"
55
end
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# rbs_inline: enabled
2+
3+
require "database_test_helper"
4+
5+
class InstancePrunerTest < ActiveSupport::TestCase
6+
test "retention uses the last use or creation time with a strict cutoff" do
7+
now = Time.utc(2026, 9, 16, 12)
8+
cutoff = now - 30.days
9+
SolidObjects.configuration.instance_retention_by_actor_type = { "expiry-test" => 30.days }
10+
expired = [
11+
create_instance("unused-old", created_at: cutoff - 1.second, last_used_at: nil),
12+
create_instance("used-old", created_at: now, last_used_at: cutoff - 1.second)
13+
]
14+
retained = [
15+
create_instance("unused-boundary", created_at: cutoff, last_used_at: nil),
16+
create_instance("unused-recent", created_at: now, last_used_at: nil),
17+
create_instance("used-boundary", created_at: cutoff - 1.day, last_used_at: cutoff),
18+
create_instance("used-recent", created_at: cutoff - 1.day, last_used_at: now)
19+
]
20+
pruner = SolidObjects::InstancePruner.new(now:)
21+
22+
assert_equal 2, pruner.preview
23+
assert_equal 2, pruner.prune
24+
assert_empty SolidObjects::Instance.where(id: expired.map(&:id))
25+
assert_equal retained.map(&:id).sort, SolidObjects::Instance.order(:id).pluck(:id)
26+
end
27+
28+
test "MySQL finds expired candidates through the cleanup index" do
29+
skip "requires a MySQL query plan" unless database_family == :mysql
30+
31+
now = Time.utc(2026, 9, 16, 12)
32+
SolidObjects.configuration.instance_retention_by_actor_type = { "expiry-test" => 30.days }
33+
SolidObjects::Instance.insert_all!(Array.new(2_000) { |index|
34+
{ actor_type: "expiry-test", actor_id: "recent-#{index}", state: {},
35+
created_at: now, updated_at: now, last_used_at: now }
36+
})
37+
connection = SolidObjects::Instance.connection
38+
connection.execute("ANALYZE TABLE solid_objects_instances")
39+
statements = []
40+
subscriber = ->(*arguments) { statements << arguments.last[:sql].to_s }
41+
42+
ActiveSupport::Notifications.subscribed(subscriber, "sql.active_record") do
43+
assert_equal 0, SolidObjects::InstancePruner.new(now:).prune
44+
end
45+
46+
query = statements.find { |sql| sql.match?(/\ASELECT .* FROM `solid_objects_instances` /) }
47+
assert query, "the candidate lookup was not captured"
48+
plan = connection.select_all("EXPLAIN FORMAT=TRADITIONAL #{query}").to_a
49+
.find { |row| row["table"] == "solid_objects_instances" }
50+
51+
assert plan, "the candidate table was not present in the query plan"
52+
assert_equal "idx_so_instances_cleanup", plan&.fetch("key")
53+
assert_operator plan&.fetch("rows").to_i, :<, 20
54+
end
55+
56+
private
57+
58+
# @rbs (String, created_at: Time, last_used_at: Time?) -> SolidObjects::Instance
59+
def create_instance(actor_id, created_at:, last_used_at:)
60+
SolidObjects::Instance.create!(actor_type: "expiry-test", actor_id:, created_at:, last_used_at:)
61+
end
62+
end

0 commit comments

Comments
 (0)