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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions lib/mongo_session_store/mongo_store_base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 77 additions & 0 deletions spec/lib/mongo_session_store/stale_sessions_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading