Skip to content

feat: Support Multiple session stores#277

Merged
m4tx merged 44 commits into
cot-rs:masterfrom
ElijahAhianyo:elijah/session-store-dynamic
Jun 25, 2025
Merged

feat: Support Multiple session stores#277
m4tx merged 44 commits into
cot-rs:masterfrom
ElijahAhianyo:elijah/session-store-dynamic

Conversation

@ElijahAhianyo

@ElijahAhianyo ElijahAhianyo commented Apr 6, 2025

Copy link
Copy Markdown
Contributor

This Pr lays the foundation for supporting multiple session stores. Currently, there are 4 supported store types:
Memory, Cache, File and DB(implementation to be added in a follow up PR).

Memory

This is the default store which stores the session data in a thread-safe hashmap. It is suitable for development environments

Toml Example

...
[middlewares.session.store]
type = "memory"

Config Example

use cot::config::{MiddlewareConfig, ProjectConfig, SessionMiddlewareConfig, SessionStoreConfig, SessionStoreTypeConfig};
...
struct FooProject;

impl Project for FooProject {

    fn config(&self, _config_name: &str) -> cot::Result<ProjectConfig> {
        Ok(ProjectConfig::builder()
            .middlewares(
                MiddlewareConfig::builder()
                    .session(SessionMiddlewareConfig::builder()
                        .secure(false)
                        .store(SessionStoreConfig::builder()
                            .store_type(SessionStoreTypeConfig::Memory)
                            .build()
                        )
                        .build())
                    .build(),
            )
            .build()
        )
    }
}

File Store

The file store persists sessions in a specified directory as files on a file system.

Toml Example

...
[middlewares.session.store]
type = "file"
path = "/path/to/session/storage"

Config Example

use std::path::PathBuf;
use cot::config::{MiddlewareConfig, ProjectConfig, SessionMiddlewareConfig, SessionStoreConfig, SessionStoreTypeConfig};

...
struct FooProject;

impl Project for FooProject {

    fn config(&self, _config_name: &str) -> cot::Result<ProjectConfig> {
        Ok(ProjectConfig::builder()
            .middlewares(
                MiddlewareConfig::builder()
                    .session(SessionMiddlewareConfig::builder()
                        .secure(false)
                        .store(SessionStoreConfig::builder()
                            .store_type(SessionStoreTypeConfig::File{ path : PathBuf::from("/path/to/dir")})
                            .build()
                        )
                        .build())
                    .build(),
            )
            .build()
        )
    }
}

Cache Store

The cache store uses a configured cache backed to persist data. This PR ships with support for a Redis backend. Support for other cache backends will be added in follow up PRs.
The cache store is gated behind the cache feature, while the redis implementation is gated behind the redis feature. To use the redis cache store, you will have to enable both.

Toml Example

...
[middlewares.session.store]
type = "cache"
uri = "redis://localhost:6379"

Config Example

use cot::config::{CacheUrl, MiddlewareConfig, ProjectConfig, SessionMiddlewareConfig, SessionStoreConfig, SessionStoreTypeConfig};

...
struct FooProject;

impl Project for FooProject {

    fn config(&self, _config_name: &str) -> cot::Result<ProjectConfig> {
        Ok(ProjectConfig::builder()
            .middlewares(
                MiddlewareConfig::builder()
                    .session(SessionMiddlewareConfig::builder()
                        .secure(false)
                        .store(SessionStoreConfig::builder()
                            .store_type(SessionStoreTypeConfig::Cache{ uri : CacheUri::from("redis://localhost:6379")})
                            .build()
                        )
                        .build())
                    .build(),
            )
            .build()
        )
    }
}

DB store

The DB store uses Cot's ORM to persist session data, Its implementation details will be added in a follow up PR.

@github-actions github-actions Bot added the C-lib Crate: cot (main library crate) label Apr 6, 2025
@codecov

codecov Bot commented Apr 6, 2025

Copy link
Copy Markdown

Codecov Report

Attention: Patch coverage is 88.99083% with 72 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
cot/src/session/store/file.rs 86.25% 15 Missing and 14 partials ⚠️
cot/src/session/store/redis.rs 83.97% 10 Missing and 15 partials ⚠️
cot/src/middleware.rs 66.66% 12 Missing ⚠️
cot/src/config.rs 95.94% 6 Missing ⚠️
Flag Coverage Δ
rust 88.92% <88.99%> (+0.05%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
cot/src/session.rs 82.35% <ø> (ø)
cot/src/session/store.rs 100.00% <100.00%> (ø)
cot/src/session/store/memory.rs 100.00% <100.00%> (ø)
cot/src/config.rs 96.71% <95.94%> (+2.94%) ⬆️
cot/src/middleware.rs 81.84% <66.66%> (-2.48%) ⬇️
cot/src/session/store/redis.rs 83.97% <83.97%> (ø)
cot/src/session/store/file.rs 86.25% <86.25%> (ø)

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@ElijahAhianyo ElijahAhianyo force-pushed the elijah/session-store-dynamic branch from f8e50cd to 3c6dbd6 Compare April 24, 2025 13:33
@ElijahAhianyo

Copy link
Copy Markdown
Contributor Author

@m4tx @seqre this PR is not fully ready, but I'd love some initial feedback if this design is heading in the right direction or makes any sense to you at all.

Example usages

No session storage specified(using the admin example)

when the session_store is not provided, it defaults to the MemoryStore in the config

struct AdminProject;

impl Project for AdminProject {
   ...
    fn config(&self, _config_name: &str) -> cot::Result<ProjectConfig> {

        Ok(ProjectConfig::builder()
            .debug(true)
            .database(
                DatabaseConfig::builder()
                    .url("sqlite://db.sqlite3?mode=rwc")
                    .build(),
            )
            .auth_backend(AuthBackendConfig::Database)
            .middlewares(
                MiddlewareConfig::builder()
                    .session(SessionMiddlewareConfig::builder()
                        .secure(false)
                        .build())
                    .build(),
            )
            .build())
    }

    ...
}

Session storage provided using MemoryStore provided by Cot

struct AdminProject;

impl Project for AdminProject {
   ...
    fn config(&self, _config_name: &str) -> cot::Result<ProjectConfig> {

        Ok(ProjectConfig::builder()
            .debug(true)
            .database(
                DatabaseConfig::builder()
                    .url("sqlite://db.sqlite3?mode=rwc")
                    .build(),
            )
            .auth_backend(AuthBackendConfig::Database)
            .middlewares(
                MiddlewareConfig::builder()
                    .session(SessionMiddlewareConfig::builder()
                        .secure(false)
                        .session_store(Arc::new(MemoryStore::default()))
                        .build())
                    .build(),
            )
            .build())
    }

    ...
}

(Also haven't played around custom SessionStores yet)

@m4tx

m4tx commented Apr 25, 2025

Copy link
Copy Markdown
Member

@ElijahAhianyo I think we should do this similarly to the Auth backend, i.e. the ProjectConfig should only include a serializable enum of possible choices for the Session store (Memory Store, ORM, Redis, etc., possibly with some inner configs as well), and the Bootstrapper should initialize a proper Session Store backend by calling a method (called session_store?) on the Project trait. The idea is that ProjectConfig must be serializable to/from a TOML file, and if you want to override the default behavior, you can do it by overriding a method in the Project trait. See the following snippets:

cot/cot/src/project.rs

Lines 335 to 351 in 70995f0

fn auth_backend(&self, context: &AuthBackendContext) -> Arc<dyn AuthBackend> {
#[expect(trivial_casts)] // cast to Arc<dyn AuthBackend>
match &context.config().auth_backend {
AuthBackendConfig::None => Arc::new(NoAuthBackend) as Arc<dyn AuthBackend>,
#[cfg(feature = "db")]
AuthBackendConfig::Database => Arc::new(DatabaseUserBackend::new(
context
.try_database()
.expect(
"Database missing when constructing database auth backend. \
Make sure the database config is set up correctly or disable \
authentication in the config.",
)
.clone(),
)) as Arc<dyn AuthBackend>,
}
}

cot/cot/src/project.rs

Lines 1240 to 1241 in 70995f0

let auth_backend = self.project.auth_backend(&self.context);
let context = self.context.with_auth(auth_backend);

It's not ideal (mainly because it inflates the number of methods in the Project trait), but it's the best solution that we could come up with, allowing the users to use the "common" options easily, but not limiting them in what they can do. However, if you have ideas for a better design, I'm happy to discuss them.

@ElijahAhianyo

Copy link
Copy Markdown
Contributor Author

@ElijahAhianyo I think we should do this similarly to the Auth backend, i.e. the ProjectConfig should only include a serializable enum of possible choices for the Session store (Memory Store, ORM, Redis, etc., possibly with some inner configs as well), and the Bootstrapper should initialize a proper Session Store backend by calling a method (called session_store?) on the Project trait. The idea is that ProjectConfig must be serializable to/from a TOML file, and if you want to override the default behavior, you can do it by overriding a method in the Project trait. See the following snippets:

cot/cot/src/project.rs

Lines 335 to 351 in 70995f0

fn auth_backend(&self, context: &AuthBackendContext) -> Arc<dyn AuthBackend> {
#[expect(trivial_casts)] // cast to Arc<dyn AuthBackend>
match &context.config().auth_backend {
AuthBackendConfig::None => Arc::new(NoAuthBackend) as Arc<dyn AuthBackend>,
#[cfg(feature = "db")]
AuthBackendConfig::Database => Arc::new(DatabaseUserBackend::new(
context
.try_database()
.expect(
"Database missing when constructing database auth backend. \
Make sure the database config is set up correctly or disable \
authentication in the config.",
)
.clone(),
)) as Arc<dyn AuthBackend>,
}
}

cot/cot/src/project.rs

Lines 1240 to 1241 in 70995f0

let auth_backend = self.project.auth_backend(&self.context);
let context = self.context.with_auth(auth_backend);

It's not ideal (mainly because it inflates the number of methods in the Project trait), but it's the best solution that we could come up with, allowing the users to use the "common" options easily, but not limiting them in what they can do. However, if you have ideas for a better design, I'm happy to discuss them.

Based on your suggestion, I did some research and now have a clearer picture. My plan is to start by sketching out what the TOML file might look like, then work my way down into implementation details.

I looked at how other frameworks handle this and identified two main patterns we canexplore:

1 a. Self-contained store config

You declare the store type and provide any necessary connection details (or file paths, in the case of file storage):

secret_key = "{{ dev_secret_key }}"

[database]
url = "sqlite://db.sqlite3?mode=rwc"

[auth_backend]
type = "database"

[middlewares]
live_reload.enabled = true

[middlewares.session]
secure = false

[middlewares.session.store]
type = "redis"
connection = "redis://"

1 b Per-store subsection

The approach in 1.a could get quite noisy(or maybe not) if different store types have some configs specific to them.

secret_key = "{{ dev_secret_key }}"

[database]
url = "sqlite://db.sqlite3?mode=rwc"

[auth_backend]
type = "database"

[middlewares]
live_reload.enabled = true

[middlewares.session]
secure = false

[middlewares.session.store]
type = "file"

[middlewares.session.store.file]
dir = "/tmp/"

2. Named connections

The first pattern forces you to re-declare connection details even if you’ve already defined them under [databases] or [caches](currently not implemented). It also assumes a single DB/cache backend. We may need to structurally change the API to support this:

secret_key = "{{ dev_secret_key }}"

[databases]
default = "postgres://…"

[caches]
redis_main = "redis://…"

[middlewares.session]
store      = "redis"
connection = "cache.redis_main"  # or, alternatively, use a separate `connection_source` field instead of dotted notation

Option 1 should be easy to implement given our current design. However, with option 2, do we have any plans to support multiple DBs or caches?

@m4tx let me also know if you've got other opinions on what the TOML should look like.

@m4tx

m4tx commented Apr 30, 2025

Copy link
Copy Markdown
Member

@ElijahAhianyo

do we have any plans to support multiple DBs or caches?

Ah, that's a good question. Definitely yes, but certainly not in the near future.

After some consideration, I think it would make sense to have some sort of hybrid between 1a and 2. I can imagine the following typical use cases for the session backends:

  1. in-memory store, file store - for development or super low traffic websites where performance and reliability don't matter
  2. database (using Cot's ORM) - for development or low/medium traffic websites that need simplicity and reliability, but don't have high enough traffic to require caching
  3. cache (Redis, MongoDB, etc.) - for high traffic websites that need caching for other stuff anyway

If we want to store sessions externally, we probably should already have an API for that. This doesn't apply for in-memory or file stores (because they're mainly development-focused anyway), but we already have a way to define a DB connection in the config (for use with the Cot's ORM) and the framework user shouldn't need to provide the same credentials twice, but the session store should rather use the Cot's ORM directly. The same should be true for Redis/Mongo/whatever cache – we don't have any Cache API at the moment, but I think we should at some point. And when we have the Cache API, we should just be able to use it to implement session stores.

So I think we could try to translate these use cases into Config files:

  1. In-memory/file backend
[middlewares.session.store]
type = "memory"
[middlewares.session.store]
type = "file"
path = "sessions.db"
  1. Database backend
[middlewares.session.store]
type = "database"  # will just use Cot's ORM
  1. Redis

For now, something like so should be good enough:

[middlewares.session.store]
type = "redis"
url = "redis://localhost:6379"

But, when Cot gets a dedicated Cache API with its own connection settings, the above can be just changed to:

[middlewares.session.store]
type = "cache"  # similarly to "database", this just means we'll use Cot's Cache API
name = "cache1"  # optional; only needed if more than one cache is defined

Do you think the above makes sense?

Obviously, implementing a Cache API is out of scope for this PR, but if you'd like to do it as well, you're more than welcome to.

@ElijahAhianyo

Copy link
Copy Markdown
Contributor Author

@ElijahAhianyo

do we have any plans to support multiple DBs or caches?

Ah, that's a good question. Definitely yes, but certainly not in the near future.

After some consideration, I think it would make sense to have some sort of hybrid between 1a and 2. I can imagine the following typical use cases for the session backends:

  1. in-memory store, file store - for development or super low traffic websites where performance and reliability don't matter
  2. database (using Cot's ORM) - for development or low/medium traffic websites that need simplicity and reliability, but don't have high enough traffic to require caching
  3. cache (Redis, MongoDB, etc.) - for high traffic websites that need caching for other stuff anyway

If we want to store sessions externally, we probably should already have an API for that. This doesn't apply for in-memory or file stores (because they're mainly development-focused anyway), but we already have a way to define a DB connection in the config (for use with the Cot's ORM) and the framework user shouldn't need to provide the same credentials twice, but the session store should rather use the Cot's ORM directly. The same should be true for Redis/Mongo/whatever cache – we don't have any Cache API at the moment, but I think we should at some point. And when we have the Cache API, we should just be able to use it to implement session stores.

So I think we could try to translate these use cases into Config files:

  1. In-memory/file backend
[middlewares.session.store]
type = "memory"
[middlewares.session.store]
type = "file"
path = "sessions.db"
  1. Database backend
[middlewares.session.store]
type = "database"  # will just use Cot's ORM
  1. Redis

For now, something like so should be good enough:

[middlewares.session.store]
type = "redis"
url = "redis://localhost:6379"

But, when Cot gets a dedicated Cache API with its own connection settings, the above can be just changed to:

[middlewares.session.store]
type = "cache"  # similarly to "database", this just means we'll use Cot's Cache API
name = "cache1"  # optional; only needed if more than one cache is defined

Do you think the above makes sense?

Obviously, implementing a Cache API is out of scope for this PR, but if you'd like to do it as well, you're more than welcome to.

@m4tx Yes, that makes sense. I can look into having a Cache API(we can create an issue to track this), in a separate PR—almost async with this one. Would you still want that designed with a singular backend in mind for now, and support multi later?

@m4tx m4tx mentioned this pull request Apr 30, 2025
@m4tx

m4tx commented Apr 30, 2025

Copy link
Copy Markdown
Member

@ElijahAhianyo actually, I think cases 1 and 3 from my answer can be merged together – there's nothing stopping us from implementing in-memory and file-backed caching. This would leave us with only two session backends that we would need to implement - database and cache.

I've created an issue to track the Cache API: #308.

Would you still want that designed with a singular backend in mind for now, and support multi later?

I think having just one backend is 100% fine for now - we can work on this to add support for multiple ones later, as this doesn't sound like an everyday use case anyways.

@ElijahAhianyo

ElijahAhianyo commented May 1, 2025

Copy link
Copy Markdown
Contributor Author

@ElijahAhianyo actually, I think cases 1 and 3 from my answer can be merged together – there's nothing stopping us from implementing in-memory and file-backed caching. This would leave us with only two session backends that we would need to implement - database and cache.

I've created an issue to track the Cache API: #308.

Would you still want that designed with a singular backend in mind for now, and support multi later?

I think having just one backend is 100% fine for now - we can work on this to add support for multiple ones later, as this doesn't sound like an everyday use case anyways.

@m4tx I see, just so we're on the same page on what the TOML file should look like in the context of this PR, If we have two(database and cache) store variants. How do we differentiate what cache type(redis, file, memcache, etc) it is from the TOML file?
Would you rather

  1. Ask the user for more help by introducing another config knob to identify what cache type they want:
[middlewares.session.store]
type = "cache"
cache_type = "redis" # very verbose
url = "redis://localhost:6379"
  1. Do some implicit gymnastics using the url/path provided to determine which cache the user wants:
[middlewares.session.store]
type = "cache"
url = "redis://localhost:6379" # will be infered as redis store from the uri
  1. Any other option?

@m4tx

m4tx commented May 1, 2025

Copy link
Copy Markdown
Member

@ElijahAhianyo I think option number 2 should be good enough, as it doesn't duplicate the data in the config, and it will be consistent with the ORM behavior. When creating the Cache API, we just need to make sure it's possible to register new cache backends (for instance, the cache backend may need to provide all the URL schemas it supports, and our code would just try to match the URL to all registered backends).

By the way, just to be sure, this is fine for now:

[middlewares.session.store]
type = "cache"
url = "redis://localhost:6379"

But eventually we'll want to have something closer to this (when we have the Cache API):

[cache]
URL = "redis://localhost:6379"

[middlewares.session.store]
type = "cache"

@ElijahAhianyo ElijahAhianyo force-pushed the elijah/session-store-dynamic branch from 1c7763d to fe3713b Compare May 13, 2025 13:03
@github-actions github-actions Bot added A-ci Area: CI (Continuous Integration) C-cli Crate: cot-cli (issues and Pull Requests related to Cot CLI) labels May 15, 2025
Comment thread cot/src/config.rs Outdated
},
#[cfg(feature = "db")]
Self::Database => {
unimplemented!();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ended up moving the DB implementation into another PR since there are some nuances to it; The DB option requires a migration model and generated migration, which I haven't gotten to the bottom of

@ElijahAhianyo ElijahAhianyo changed the title [WIP]Support Multiple session stores feat:Support Multiple session stores May 26, 2025
@ElijahAhianyo ElijahAhianyo marked this pull request as ready for review May 26, 2025 10:26
@ElijahAhianyo ElijahAhianyo changed the title feat:Support Multiple session stores feat: Support Multiple session stores May 26, 2025
@ElijahAhianyo

ElijahAhianyo commented May 26, 2025

Copy link
Copy Markdown
Contributor Author

@m4tx @seqre This should somewhat be ready for review, Let me know what your initial thoughts are

@m4tx m4tx left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey, thanks for this very high-quality contribution! It looks pretty good already, but I have a few minor issues I've pointed out in the comments—please have a look.

Comment thread examples/sessions/src/main.rs Outdated
Comment thread cot/src/config.rs Outdated
Comment thread cot/src/config.rs Outdated
Comment thread cot/src/middleware.rs Outdated
Comment thread cot-cli/src/project_template/config/dev.toml Outdated
Comment thread cot/src/session/store/redis.rs
Comment thread cot/src/session/store/redis.rs Outdated
Comment thread cot/Cargo.toml Outdated
Comment thread cot/src/session/store/redis.rs Outdated
Comment thread cot/src/session/store/redis.rs Outdated
Comment thread cot/src/config.rs
@ElijahAhianyo ElijahAhianyo requested review from m4tx and seqre June 9, 2025 19:08

@m4tx m4tx left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left a few comments that I would like to see resolved before merging.

@seqre please have a look at this; I think this is already almost ready for merging, but perhaps you have some more thoughts.

Comment thread cot/src/middleware.rs Outdated
Comment thread cot/src/session/store/file.rs Outdated
Comment thread cot/src/session/store.rs Outdated
/// }
/// }
/// ```
pub trait ToSessionStore {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if I get the idea behind this trait. We don't ever use to_session_store in a generic context, do we? It seems like we only ever use it in SessionMiddleware::from_context with SessionStoreTypeConfig. Can we just make this a private method of SessionMiddleware?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree this is premature abstraction. I think the goal I had in mind was to decouple the SessionStoreConfig to SessionStore conversion from the session middleware itself since it had more to do with the Store than the middleware itself. I couldn't think of a more ergonomic way of doing it at the time.

@ElijahAhianyo ElijahAhianyo force-pushed the elijah/session-store-dynamic branch from f707aaa to 0e8138b Compare June 10, 2025 21:05
@ElijahAhianyo ElijahAhianyo requested a review from m4tx June 10, 2025 21:11

@m4tx m4tx left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me; please fix one last thing I've mentioned and I think we're ready to go! Thanks a lot for this!

@seqre please have a look as well.

Comment thread cot/src/middleware.rs Outdated

@seqre seqre left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall, great work, thank you once again for the contribution! I just have one leftover item in the FileStore, and once we take care of it, I'll gladly approve and merge.

Comment thread cot/src/session/store/memory.rs Outdated
Comment thread cot/src/session/store/file.rs Outdated

@seqre seqre left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your work @ElijahAhianyo! Please resolve the merge conflicts and we'll merge the PR

@ElijahAhianyo ElijahAhianyo force-pushed the elijah/session-store-dynamic branch from 2809122 to 87f15d0 Compare June 24, 2025 20:40
@m4tx m4tx merged commit 030bbd2 into cot-rs:master Jun 25, 2025
22 checks passed
@cotbot cotbot Bot mentioned this pull request Jun 25, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-ci Area: CI (Continuous Integration) C-cli Crate: cot-cli (issues and Pull Requests related to Cot CLI) C-lib Crate: cot (main library crate)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants