diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f38251..e6a7f0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## (unreleased) +* [#55](https://github.com/mongoid/mongo_session_store/pull/55): Add explicit bulk stale-session cleanup with an application-selected cutoff for both adapters - [@dblock](https://github.com/dblock). * [#54](https://github.com/mongoid/mongo_session_store/pull/54): Document and test storing permitted parameter hashes rather than request-bearing Rails parameter objects in sessions - [@dblock](https://github.com/dblock). * [#53](https://github.com/mongoid/mongo_session_store/pull/53): Cover Rails 6.1 through 8.0 in CI, update RuboCop, isolate Danger permissions, and report both adapters' coverage to Coveralls - [@dblock](https://github.com/dblock). * Your contribution here. diff --git a/README.md b/README.md index 55ff429..b411d96 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,28 @@ session[:search] = params.require(:search).permit(:query, :sort).to_h This stores the permitted values without the request context. It does not make uploaded files or other non-serializable values serializable; store a durable identifier for those instead. +### Deleting stale sessions + +Sessions are not automatically expired or deleted. To opt in to cleanup, call the store's `delete_stale_sessions` method with an explicit `Time` cutoff: + +```ruby +# Mongoid +MongoidStore.delete_stale_sessions(before: 1.week.ago) + +# MongoDB Ruby Driver +MongoStore.delete_stale_sessions(before: 1.week.ago) +``` + +The method returns the number deleted and uses the configured session collection. It performs a bulk database deletion without loading session data or running model callbacks. Only records whose `updated_at` is strictly earlier than the cutoff are deleted; records at the cutoff or without a timestamp are retained. Invalid cutoffs raise `ArgumentError`, and database errors propagate to the caller. + +Run cleanup explicitly from a scheduled job or a Rails runner command, choosing a retention policy appropriate for your application: + +```sh +bin/rails runner 'MongoidStore.delete_stale_sessions(before: 1.week.ago)' +``` + +Deleting a session signs that session out on its next request. Cleanup is not cookie expiration or a guarantee of inactivity-based logout: `updated_at` tracks session saves, and a request that does not save the session may not refresh it. No default maximum age, background job, or TTL index is installed. For large collections, consider creating an ordinary index on `updated_at` as part of your database setup. + ## Development ### Test environment diff --git a/lib/mongo_session_store/mongo_store_base.rb b/lib/mongo_session_store/mongo_store_base.rb index e35808a..5fdc7dd 100644 --- a/lib/mongo_session_store/mongo_store_base.rb +++ b/lib/mongo_session_store/mongo_store_base.rb @@ -16,6 +16,12 @@ def self.session_class self::Session end + def self.delete_stale_sessions(before:) + raise ArgumentError, "before must be a Time" unless before.is_a?(Time) + + session_class.collection.find("updated_at" => { "$lt" => before.getutc }).delete_many.deleted_count + end + private def session_class diff --git a/spec/lib/mongo_session_store/stale_sessions_spec.rb b/spec/lib/mongo_session_store/stale_sessions_spec.rb new file mode 100644 index 0000000..bac507b --- /dev/null +++ b/spec/lib/mongo_session_store/stale_sessions_spec.rb @@ -0,0 +1,77 @@ +require "spec_helper" + +describe ".delete_stale_sessions" do + let(:store) { mongo_orm == "mongoid" ? MongoidStore : MongoStore } + let(:collection) { store.session_class.collection } + let(:cutoff) { Time.utc(2026, 1, 1) } + + it "deletes only sessions last updated strictly before the cutoff" do + collection.insert_many([ + { :_id => "stale", :updated_at => cutoff - 1 }, + { :_id => "boundary", :updated_at => cutoff }, + { :_id => "active", :created_at => cutoff - 100, :updated_at => cutoff + 1 }, + { :_id => "undated" }, + { :_id => "null-date", :updated_at => nil } + ]) + + expect(store.delete_stale_sessions(:before => cutoff)).to eq(1) + expect(collection.find.map { |doc| doc["_id"] }.sort).to eq( + %w[active boundary null-date undated] + ) + expect(store.delete_stale_sessions(:before => cutoff)).to eq(0) + end + + it "returns zero for an empty collection" do + expect(store.delete_stale_sessions(:before => cutoff)).to eq(0) + end + + it "accepts a cutoff with a UTC offset" do + collection.insert_one(:_id => "stale", :updated_at => cutoff - 1) + local_cutoff = cutoff.getlocal("-05:00") + + expect(store.delete_stale_sessions(:before => local_cutoff)).to eq(1) + expect(local_cutoff.utc_offset).to eq(-5 * 60 * 60) + end + + it "accepts a Rails time-zone cutoff" do + collection.insert_one(:_id => "stale", :updated_at => cutoff - 1) + zoned_cutoff = cutoff.in_time_zone("Eastern Time (US & Canada)") + expect(store.delete_stale_sessions(:before => zoned_cutoff)).to eq(1) + end + + it "rejects invalid cutoffs without deleting sessions" do + collection.insert_one(:_id => "stale", :updated_at => cutoff - 1) + + [nil, "2026-01-01", 1].each do |value| + expect { store.delete_stale_sessions(:before => value) }.to raise_error( + ArgumentError, "before must be a Time" + ) + end + expect(collection.find.count).to eq(1) + end + + it "uses the configured collection without touching the default collection" do + collection.insert_one(:_id => "default", :updated_at => cutoff - 1) + original_name = MongoSessionStore.collection_name + begin + MongoSessionStore.collection_name = "custom_sessions" + custom_collection = store.session_class.collection + custom_collection.insert_one(:_id => "custom", :updated_at => cutoff - 1) + + expect(store.delete_stale_sessions(:before => cutoff)).to eq(1) + expect(custom_collection.find.count).to eq(0) + ensure + MongoSessionStore.collection_name = original_name + end + expect(collection.find.count).to eq(1) + end + + it "does not suppress database errors" do + allow(store.session_class).to receive(:collection) + .and_raise(MongoSessionStore::Error, "unavailable") + + expect { store.delete_stale_sessions(:before => cutoff) }.to raise_error( + MongoSessionStore::Error, "unavailable" + ) + end +end