gopgsession

package module
v0.3.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 19, 2024 License: MIT Imports: 21 Imported by: 0

README

go-pg-session

go-pg-session is a distributed session management library that uses PostgreSQL's LISTEN/NOTIFY mechanism to handle session updates across multiple nodes. It leverages the pgln library to quickly recover from disconnections, ensuring robust and reliable session management.

⭐️ Star This Project ⭐️

If you find this project helpful, please give it a star on GitHub! Your support is greatly appreciated.

Table of Contents

Background

The go-pg-session library was created with the goal of combining the speed of JWTs with the simplicity and security of a backend session library. Recognizing that sessions are typically slow to change per user and are mainly read rather than written, this library implements an eventually consistent memory caching strategy on each node.

This approach offers a hybrid solution that leverages the benefits of both cookie-based and server-side session management:

  • Security:

    • Minimized Data Exposure: Only the session identifier is stored in the cookie, minimizing exposure to sensitive data.
    • Server-Side Data Integrity: Actual session data is stored and managed server-side, ensuring data integrity and security.
  • Scalability:

    • Lightweight Cookies: The cookie remains lightweight as it only contains the session identifier. This significantly reduces data transfer costs, which are often the highest expense in modern web applications.
  • Performance:

    • Efficient Session Retrieval: Session data can be fetched quickly using the identifier, and caching strategies are employed to optimize performance.
    • In-Memory Read Operations: Unlike Redis-based solutions, read operations are performed directly from memory, making them even faster than Redis reads. This approach provides ultra-low latency for session data access.
  • Flexibility:

    • No Size Limitations: The database can store large and complex session data without the size limitations of cookies.
    • Persistent Storage: Sessions can persist across server restarts and crashes.
  • Simplified Infrastructure:

    • No Additional Caching Servers: Unlike solutions that rely on Redis, go-pg-session eliminates the need for additional Redis servers and complex Redis cluster management. This simplifies the infrastructure, reducing operational complexity and costs.
    • Leveraging Existing Database: By using PostgreSQL for both data storage and real-time notifications, the solution minimizes the number of components in the system architecture.

Features

  • Distributed Session Management: Uses PostgreSQL LISTEN/NOTIFY for real-time session updates.
  • Quick Recovery: Utilizes the pgln library to handle disconnections efficiently.
  • Session Caching: In-memory caching of sessions with LRU eviction policy.
  • Session Expiration: Automatically expires sessions based on configured durations.
  • Periodic Cleanup: Periodically cleans up expired sessions.
  • Efficient Last Access Update: Accumulates last access times and updates them in batches at a predefined interval, reducing performance hits during session retrieval.
  • Highly Configurable: Various settings can be customized via a configuration structure.
  • Optimistic Locking:
    • Session-Level Versioning: Supports version-based optimistic locking for entire sessions.
    • Attribute-Level Versioning: Option to check versions of individual session attributes, providing fine-grained control over concurrent modifications.
  • Attribute-Level Expiration: Individual session attributes can have their own expiration times, providing fine-grained control over data lifecycle.
  • Distributed Locks: Provides a mechanism for coordinating access to shared resources across multiple nodes.
Attribute-Level Expiration: A Powerful Feature

The attribute-level expiration feature in go-pg-session offers several significant benefits:

  1. Fine-Grained Data Control: Unlike whole-session expiration, attribute expiration allows you to set different lifetimes for different pieces of data within a session. This is particularly useful for managing sensitive or temporary information.

  2. Enhanced Security: Sensitive data can be automatically removed from the session after a short period, reducing the window of vulnerability without affecting the overall session.

  3. Compliance with Data Regulations: For applications that need to comply with data protection regulations (like GDPR), attribute expiration provides a mechanism to ensure that certain types of data are not retained longer than necessary.

  4. Optimized Storage: By allowing frequently changing or temporary data to expire automatically, you can keep your session data lean and relevant, potentially improving performance and reducing storage costs.

  5. Flexible User Experiences: You can implement features that require temporary elevated permissions or time-limited offers without compromising on security or user convenience.

Example use cases:

  • Elevated Security (Step-Up Authentication): After a user performs a sensitive action requiring additional authentication, you can store a temporary "elevated_access" attribute that automatically expires after a short period:

    // Grant elevated access for 15 minutes after step-up authentication
    elevatedExpiry := time.Now().Add(15 * time.Minute)
    session.UpdateAttribute("elevated_access", "true", &elevatedExpiry)
    
  • Limited-Time Offers: Store time-sensitive promotional codes or offers that automatically expire:

    // Set a promotional offer that expires in 1 hour
    offerExpiry := time.Now().Add(1 * time.Hour)
    session.UpdateAttribute("promo_code", "FLASH_SALE_20", &offerExpiry)
    
  • Abandoned UI Processes: For multi-step processes in your UI, store temporary state that cleans itself up if the user abandons the process:

    // Store temporary form data for a multi-step process, expires in 30 minutes
    formExpiry := time.Now().Add(30 * time.Minute)
    session.UpdateAttribute("temp_form_data", jsonEncodedFormData, &formExpiry)
    
  • Temporary Access Tokens: Store short-lived access tokens for external services:

    // Store an API token that expires in 5 minutes
    tokenExpiry := time.Now().Add(5 * time.Minute)
    session.UpdateAttribute("external_api_token", "token123", &tokenExpiry)
    

By leveraging attribute-level expiration, you can implement these features securely and efficiently, automatically cleaning up sensitive or temporary data without manual intervention or complex cleanup processes. This feature allows for more nuanced session management, enhancing both security and user experience in your application.

Benchmark Results

The following table compares the performance of go-pg-session (PostgreSQL-based) with a Redis-based session management solution. These benchmarks were run on an ARM64 Darwin system.

Operation Storage Operations/sec Nanoseconds/op
GetSession PostgreSQL + go-pg-session 326,712 3,061
GetSession Redis 38,567 25,929
UpdateSession PostgreSQL + go-pg-session 2,137 467,921
UpdateSession Redis 19,515 51,243
Key Observations:
  1. Read Performance (GetSession):

    • go-pg-session outperforms Redis by a significant margin, processing about 8.5 times more read operations per second.
    • The latency for read operations with go-pg-session is about 8.5 times lower than Redis.
  2. Write Performance (UpdateSession):

    • Redis shows better performance for write operations, processing about 9 times more updates per second.
    • The latency for write operations with Redis is about 9 times lower than go-pg-session.
  3. Overall Performance:

    • go-pg-session excels in read-heavy scenarios, which aligns with typical session management use cases where reads are far more frequent than writes.
    • The PostgreSQL-based solution offers a better balance between read and write performance, making it suitable for a wide range of applications.

These results demonstrate that go-pg-session is particularly well-suited for applications with high read volumes, offering superior performance for session retrieval operations. While Redis shows better performance for write operations, the overall balance and especially the read performance of go-pg-session make it an excellent choice for most session management scenarios.

Remember that performance can vary based on hardware, network conditions, and specific use cases. It's always recommended to benchmark in your specific environment for the most accurate results.

Note: A would be go-pg-session for redis (or go-redis-session) could outperform go-pg-session when writing for sure, but the aim of this library is to use only PostgreSQL without additional services like redis. Moreover, since sessions are mostly read oriented, this is less of an advantage. It is possible that a go-redis-session would be great for developers who use redis already and may be developed if there is a demand.

Installation

To install the package, run:

go get github.com/tzahifadida/go-pg-session

Configuration

Create a configuration using the Config struct. You can use the DefaultConfig function to get a default configuration and modify it as needed.

Config Fields
  • MaxSessions: Maximum number of concurrent sessions allowed per user.
  • MaxAttributeLength: Maximum length of session attributes.
  • SessionExpiration: Duration after which a session expires.
  • InactivityDuration: Duration of inactivity after which a session expires.
  • CleanupInterval: Interval at which expired sessions are cleaned up.
  • CacheSize: Size of the in-memory cache for sessions.
  • TablePrefix: Prefix for the table names used in the database.
  • SchemaName: Name of the schema used in the database.
  • CreateSchemaIfMissing: Flag to create the schema if it is missing.
  • LastAccessUpdateInterval: Interval for updating the last access time of sessions.
  • LastAccessUpdateBatchSize: Batch size for updating last access times.
  • NotifyOnUpdates: Flag to enable/disable notifications on updates.
cfg := gopgsession.DefaultConfig()
cfg.MaxSessions = 10
cfg.SessionExpiration = 24 * time.Hour // 1 day
cfg.CreateSchemaIfMissing = true

Usage Examples

Initialization

Initialize a SessionManager with the configuration and a PostgreSQL database connection.

import (
    "database/sql"
    _ "github.com/jackc/pgx/v5/stdlib"
    "github.com/tzahifadida/go-pg-session"
)

// Open a database connection
db, err := sql.Open("pgx", "postgres://username:password@localhost/dbname?sslmode=disable")
if err != nil {
    log.Fatalf("Failed to open database: %v", err)
}
defer db.Close()

// Create a new SessionManager
sessionManager, err := gopgsession.NewSessionManager(ctx, cfg, db)
if err != nil {
    log.Fatalf("Failed to initialize session manager: %v", err)
}
defer sessionManager.Shutdown(context.Background())
Creating a Session

Create a session for a user with initial attributes.

ctx := context.Background()
userID := uuid.New()
attributes := map[string]gopgsession.SessionAttributeValue{
    "role":        {Value: "admin"},
    "preferences": {Value: map[string]string{"theme": "dark"}},
}

session, err := sessionManager.CreateSession(ctx, userID, attributes)
if err != nil {
    log.Fatalf("Failed to create session: %v", err)
}

log.Printf("Created session with ID: %s", session.ID)
Retrieving a Session

Retrieve a session by its ID. You can use GetSession with optional parameters for more control.

session, err := sessionManager.GetSession(ctx, sessionID)
if err != nil {
    log.Fatalf("Failed to retrieve session: %v", err)
}

// With options
session, err := sessionManager.GetSession(ctx, sessionID, gopgsession.WithDoNotUpdateSessionLastAccess(), gopgsession.WithForceRefresh())
if err != nil {
    log.Fatalf("Failed to retrieve session: %v", err)
}

var preferences map[string]string
_, err = session.GetAttributeAndRetainUnmarshaled("preferences", &preferences)
if err != nil {
    log.Fatalf("Failed to get preferences: %v", err)
}

log.Printf("User preferences: %v", preferences)
Updating a Session with Version Check

Update a session while ensuring version consistency at the session level.

session, err := sessionManager.GetSession(ctx, sessionID)
if err != nil {
    log.Fatalf("Failed to retrieve session: %v", err)
}

err = session.UpdateAttribute("last_access", time.Now(), nil)
if err != nil {
    log.Fatalf("Failed to update session attribute: %v", err)
}

updatedSession, err := sessionManager.UpdateSession(ctx, session, gopgsession.WithCheckVersion())
if err != nil {
    log.Fatalf("Failed to update session: %v", err)
}
Updating a Session with Attribute Version Check

Update a session while ensuring version consistency at the attribute level.

session, err := sessionManager.GetSession(ctx, sessionID)
if err != nil {
    log.Fatalf("Failed to retrieve session: %v", err)
}

var preferences map[string]string
_, err = session.GetAttributeAndRetainUnmarshaled("preferences", &preferences)
if err != nil {
    log.Fatalf("Failed to get preferences: %v", err)
}

preferences["theme"] = "light"
err = session.UpdateAttribute("preferences", preferences, nil)
if err != nil {
    log.Fatalf("Failed to update session attribute: %v", err)
}

updatedSession, err := sessionManager.UpdateSession(ctx, session, gopgsession.WithCheckAttributeVersion())
if err != nil {
    log.Fatalf("Failed to update session: %v", err)
}
Deleting a Session

Delete a session by its ID.

err = sessionManager.DeleteSession(ctx, sessionID)
if err != nil {
    log.Fatalf("Failed to delete session: %v", err)
}

log.Printf("Deleted session with ID: %s", sessionID)
Login and Logout Handlers

Here are examples of login and logout handlers that demonstrate session creation and deletion:

func loginHandler(sm *gopgsession.SessionManager) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        // Authenticate user (simplified for example)
        username := r.FormValue("username")
        password := r.FormValue("password")
        
        userID, err := authenticateUser(username, password)
        if err != nil {
            http.Error(w, "Authentication failed", http.StatusUnauthorized)
            return
        }

        // Create new session
        attributes := map[string]gopgsession.SessionAttributeValue{
            "username":    {Value: username},
            "last_login":  {Value: time.Now().Format(time.RFC3339)},
        }
        session, err := sm.CreateSession(r.Context(), userID, attributes)
        if err != nil {
            http.Error(w, "Failed to create session", http.StatusInternalServerError)
            return
        }

        // Set session cookie
        http.SetCookie(w, &http.Cookie{
            Name:     "session_id",
            Value:    session.ID.String(),
            HttpOnly: true,
            Secure:   true,
            SameSite: http.SameSiteStrictMode,
        })

        w.WriteHeader(http.StatusOK)
        w.Write([]byte("Login successful"))
    }
}

func logoutHandler(sm *gopgsession.SessionManager) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        sessionCookie, err := r.Cookie("session_id")
        if err != nil {
            http.Error(w, "No session found", http.StatusBadRequest)
            return
        }

        sessionID, err := uuid.Parse(sessionCookie.Value)
        if err != nil {
            http.Error(w, "Invalid session ID", http.StatusBadRequest)
            return
        }

        // Delete the session
        err = sm.DeleteSession(r.Context(), sessionID)
        if err != nil {
            http.Error(w, "Failed to delete session", http.StatusInternalServerError)
            return
        }

        // Clear the session cookie
        http.SetCookie(w, &http.Cookie{
            Name:     "session_id",
            Value:    "",
            HttpOnly: true,
            Secure:   true,
            SameSite: http.SameSiteStrictMode,
            MaxAge:   -1,
        })

        w.WriteHeader(http.StatusOK)
        w.Write([]byte("Logout successful"))
    }
}
Concurrent Updates Example

Here's a refined example of handling concurrent updates to a session, useful for scenarios like a shopping cart:

func addToCartHandler(sm *gopgsession.SessionManager) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        sessionID, _ := uuid.Parse(r.Cookie("session_id").Value)
        itemID := r.FormValue("item_id")

        maxRetries := 3
        for attempt := 0; attempt < maxRetries; attempt++ {
            var session *gopgsession.Session
            var err error

            if attempt == 0 {
                session, err = sm.GetSession(r.Context(), sessionID)
            } else {
                session, err = sm.GetSession(r.Context(), sessionID, gopgsession.WithForceRefresh())
            }

            if err != nil {
                http.Error(w, "Failed to retrieve session", http.StatusInternalServerError)
                return
            }

            // Business logic: Update cart
            var cartItems []string
            _, err = session.GetAttributeAndRetainUnmarshaled("cart", &cartItems)
            if err != nil && err.Error() != "attribute cart not found" {
                http.Error(w, "Failed to get cart", http.StatusInternalServerError)
                return
            }
            cartItems = append(cartItems, itemID)

            err = session.UpdateAttribute("cart", cartItems, nil)
            if err != nil {
                http.Error(w, "Failed to update cart", http.StatusInternalServerError)
                return
            }

            _, err = sm.UpdateSession(r.Context(), session, gopgsession.WithCheckAttributeVersion())
            if err != nil {
                if err.Error() == "attribute cart version mismatch" {
                    continue // Retry
                }
                http.Error(w, "Failed to save session", http.StatusInternalServerError)
                return
            }

            // Success
            w.WriteHeader(http.StatusOK)
            w.Write([]byte("Item added to cart"))
            return
        }

        http.Error(w, "Failed to add item to cart after max retries", http.StatusConflict)
    }
}
Signed Cookies for DDOS Mitigation

To reduce overhead when handling requests for non-existent sessions (which could be part of a DDOS attack), you can sign the session ID cookie. This allows you to verify the signature before attempting to retrieve the session from the database or cache. The HMACSHA256SignerPool provides SignAndEncode and VerifyAndDecode methods for this purpose.

Here's an example of how to implement this:

import (
    "github.com/tzahifadida/go-pg-session"
    "github.com/google/uuid"
    "fmt"
)

// Initialize the signer
secret := []byte("your-secret-key")
signerPool := gopgsession.NewHMACSHA256SignerPool(secret, 10)

// When creating a session and setting the cookie
func loginHandler(sm *gopgsession.SessionManager) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        // ... (authentication logic)

        session, err := sm.CreateSession(r.Context(), userID, attributes)
        if err != nil {
            http.Error(w, "Failed to create session", http.StatusInternalServerError)
            return
        }

        // Sign and encode the session ID
        signedSessionID, err := signerPool.SignAndEncode(session.ID.String())
        if err != nil {
            http.Error(w, "Failed to sign session", http.StatusInternalServerError)
            return
        }

        // Set the signed session cookie
        http.SetCookie(w, &http.Cookie{
            Name:     "session_id",
            Value:    signedSessionID,
            HttpOnly: true,
            Secure:   true,
            SameSite: http.SameSiteStrictMode,
        })

        w.WriteHeader(http.StatusOK)
        w.Write([]byte("Login successful"))
    }
}

// When verifying and retrieving a session
func getSession(sm *gopgsession.SessionManager, r *http.Request) (*gopgsession.Session, error) {
    cookie, err := r.Cookie("session_id")
    if err != nil {
        return nil, err
    }

    // Verify the signature and decode the session ID
    isValid, sessionIDString, err := signerPool.VerifyAndDecode(cookie.Value)
    if err != nil {
        return nil, fmt.Errorf("failed to verify and decode session data: %w", err)
    }
    if !isValid {
        return nil, fmt.Errorf("invalid session signature")
    }

    // Parse the session ID
    sessionID, err := uuid.Parse(sessionIDString)
    if err != nil {
        return nil, fmt.Errorf("invalid session ID: %w", err)
    }

    // Retrieve the session
    return sm.GetSession(r.Context(), sessionID)
}

This approach leverages the HMACSHA256SignerPool's methods for signing and verifying session information:

  1. SignAndEncode: This method signs the given session ID and encodes it into a single string.
  2. VerifyAndDecode: This method verifies the signature and decodes the original session ID.

By using these methods, you ensure that the session ID is signed, providing an additional layer of security. This approach can significantly reduce the impact of DDOS attacks targeting your session management system by allowing you to quickly reject invalid or tampered requests without querying the database or cache.

Key benefits of this approach:

  1. Efficiency: The signature can be verified without accessing the database, reducing load on your backend for invalid requests.
  2. Security: The signed session ID prevents tampering and forgery attempts.
  3. Simplicity: By focusing on just the session ID, the implementation remains straightforward and easy to understand.

Remember that while this method provides an extra layer of security, it's still important to implement other security best practices, such as using HTTPS, setting appropriate cookie flags, and implementing proper session management on the server side.

Choosing Between CheckVersion and CheckAttributeVersion

The go-pg-session library offers two types of version checking for optimistic locking:

  1. Session-Level Version Checking (WithCheckVersion()):

    • Checks the version of the entire session.
    • Useful when you want to ensure the entire session hasn't been modified since it was retrieved.
  2. Attribute-Level Version Checking (WithCheckAttributeVersion()):

    • Checks the version of individual attributes being updated.
    • Provides finer-grained control over concurrent modifications.
    • Useful when different parts of your application may be updating different attributes concurrently.

Choose the appropriate version checking method based on your specific use case:

  • Use WithCheckVersion() when:

    • You need to ensure the entire session state is as expected before making updates.
    • You want to detect any changes to the session, even to attributes you're not currently modifying.
  • Use WithCheckAttributeVersion() when:

    • You want to allow concurrent updates to different attributes of the same session.
    • You need more granular conflict detection and resolution.
    • You're updating a specific attribute and don't care about changes to other attributes.

Remember, you should choose either WithCheckVersion() or WithCheckAttributeVersion(), not both, as WithCheckVersion() implicitly checks all attribute versions as well.

Distributed Locks

The go-pg-session library provides a distributed locking mechanism that allows you to coordinate access to shared resources across multiple nodes in a distributed system. This feature is particularly useful for scenarios where you need to ensure that only one process or node can access or modify a specific resource at a time.

Key Features
  • Lease-based locking with automatic expiration
  • Heartbeat mechanism to maintain locks
  • Configurable retry and timeout settings
  • Support for extending lock lease time
Use Cases
  • Coordinating access to shared resources in a distributed system
  • Implementing distributed cron jobs or scheduled tasks
  • Ensuring single-writer scenarios in multi-node deployments
  • Preventing race conditions in distributed workflows
Basic Usage

Here's a basic example of how to use the distributed lock:

func performCriticalOperation(sm *gopgsession.SessionManager, sessionID uuid.UUID) error {
    // Create a new distributed lock
    lock := sm.NewDistributedLock(sessionID, "critical-operation", nil)

    // Attempt to acquire the lock
    err := lock.Lock(context.Background())
    if err != nil {
        return fmt.Errorf("failed to acquire lock: %w", err)
    }
    defer lock.Unlock(context.Background())

    // Perform your critical operation here
    // ...

    return nil
}
Advanced Usage
Custom Configuration

You can customize the lock behavior by providing a DistributedLockConfig:

config := &gopgsession.DistributedLockConfig{
    MaxRetries:        5,
    RetryDelay:        100 * time.Millisecond,
    HeartbeatInterval: 5 * time.Second,
    LeaseTime:         30 * time.Second,
}

lock := sm.NewDistributedLock(sessionID, "custom-lock", config)
Extending Lease Time

If your operation takes longer than expected, you can extend the lease time:

func longRunningOperation(sm *gopgsession.SessionManager, sessionID uuid.UUID) error {
    lock := sm.NewDistributedLock(sessionID, "long-operation", nil)

    err := lock.Lock(context.Background())
    if err != nil {
        return fmt.Errorf("failed to acquire lock: %w", err)
    }
    defer lock.Unlock(context.Background())

    // Start the operation
    for {
        // Do some work...

        // Extend the lease if needed
        err := lock.ExtendLease(context.Background(), 30*time.Second)
        if err != nil {
            return fmt.Errorf("failed to extend lease: %w", err)
        }

        // Check if the operation is complete
        if isComplete() {
            break
        }
    }

    return nil
}
Handling Lock Acquisition Failures

In some cases, you might want to handle scenarios where the lock can't be acquired:

func tryOperation(sm *gopgsession.SessionManager, sessionID uuid.UUID) error {
    lock := sm.NewDistributedLock(sessionID, "try-operation", nil)

    err := lock.Lock(context.Background())
    if err != nil {
        if errors.Is(err, gopgsession.ErrLockAlreadyHeld) {
            // Handle the case where the lock is already held
            return fmt.Errorf("operation already in progress")
        }
        return fmt.Errorf("failed to acquire lock: %w", err)
    }
    defer lock.Unlock(context.Background())

    // Perform the operation
    // ...

    return nil
}
Best Practices
  1. Always use deferred unlock: Use defer lock.Unlock(ctx) immediately after acquiring the lock to ensure it's released even if there's a panic.

  2. Use appropriate timeout: Set a context timeout when acquiring locks to prevent indefinite waiting.

  3. Handle errors properly: Check for and handle all potential errors, including lock acquisition failures and unlock errors.

  4. Use unique resource names: Ensure that your resource names (second parameter in NewDistributedLock) are unique and descriptive for the operation you're protecting.

  5. Minimize lock duration: Keep the critical section as short as possible to reduce contention.

  6. Consider using try-lock pattern: In some scenarios, it might be better to fail fast if a lock can't be acquired immediately, rather than waiting.

By using the distributed lock feature of go-pg-session, you can coordinate operations across multiple instances of your application, ensuring data consistency and preventing race conditions in distributed environments.

Performance Considerations

The distributed memory cache in go-pg-session provides excellent read performance, as most session retrievals will be served from memory. Write operations are managed efficiently through batched updates and PostgreSQL's NOTIFY mechanism.

In high-traffic scenarios, consider adjusting the following configuration parameters:

  • CacheSize: Increase this value to cache more sessions in memory, reducing database reads.
  • LastAccessUpdateInterval and LastAccessUpdateBatchSize: Tune these values to optimize the frequency and size of batched last-access updates.
  • CleanupInterval: Adjust this value to balance between timely session cleanup and database load.

Remember to monitor your PostgreSQL server's performance and scale it accordingly as your application grows.

When using version checking (either session-level or attribute-level), keep in mind:

  • It provides protection against concurrent modifications but may increase the complexity of conflict resolution.
  • In high-concurrency scenarios, it may lead to more update conflicts, requiring careful retry logic in your application.
  • The performance impact is generally minimal, but extensive use in extremely high-traffic applications should be monitored.
  • Attribute-level version checking can offer more granular control and potentially reduce conflicts in scenarios where different attributes are often updated independently.

Exported Functions and Configuration

NewSessionManager

Initializes a new SessionManager with the given configuration and PostgreSQL database connection.

func NewSessionManager(ctx context.Context, cfg *Config, db *sql.DB) (*SessionManager, error)
CreateSession

Creates a new session for the specified user with given attributes.

func (sm *SessionManager) CreateSession(ctx context.Context, userID uuid.UUID, attributes map[string]SessionAttributeValue) (*Session, error)
GetSession

Retrieves a session by its ID with optional parameters.

func (sm *SessionManager) GetSession(ctx context.Context, sessionID uuid.UUID, options ...SessionOption) (*Session, error)
UpdateSession

Updates the session in the database with any changed attributes. Supports session-level or attribute-level version checking and other options.

func (sm *SessionManager) UpdateSession(ctx context.Context, session *Session, options ...UpdateSessionOption) (*Session, error)
Update Session Options
  • WithCheckVersion(): Enables version checking for the entire session.
  • WithCheckAttributeVersion(): Enables version checking for individual attributes.
  • WithDoNotNotify(): Disables notifications for this update operation.

Usage examples:

// Session-level version checking
updatedSession, err := sm.UpdateSession(ctx, session, gopgsession.WithCheckVersion())

// Attribute-level version checking
updatedSession, err := sm.UpdateSession(ctx, session, gopgsession.WithCheckAttributeVersion())
DeleteSession

Deletes a session by its ID.

func (sm *SessionManager) DeleteSession(ctx context.Context, sessionID uuid.UUID) error
DeleteAllUserSessions

Deletes all sessions for a given user.

func (sm *SessionManager) DeleteAllUserSessions(ctx context.Context, userID uuid.UUID) error
Shutdown

Shuts down the session manager gracefully, ensuring all ongoing operations are completed.

func (sm *SessionManager) Shutdown(ctx context.Context) error
Session Methods
UpdateAttribute

Updates or sets an attribute for the session.

func (s *Session) UpdateAttribute(key string, value interface{}, expiresAt *time.Time) error
DeleteAttribute

Deletes an attribute from the session.

func (s *Session) DeleteAttribute(key string)
GetAttributes

Returns all attributes of the session.

func (s *Session) GetAttributes() map[string]SessionAttributeValue
GetAttribute

Retrieves a specific attribute from the session.

func (s *Session) GetAttribute(key string) (SessionAttributeValue, bool)
GetAttributeAndRetainUnmarshaled

Retrieves a specific attribute, unmarshals it if necessary, and retains the unmarshaled value in memory for future use.

func (s *Session) GetAttributeAndRetainUnmarshaled(key string, v interface{}) (SessionAttributeValue, error)

This method is particularly useful for attributes that are frequently accessed and expensive to unmarshal. It provides the following benefits:

  1. Lazy Unmarshaling: Attributes are unmarshaled only when requested, saving processing time for unused attributes.
  2. Caching: Once unmarshaled, the value is retained in memory, improving performance for subsequent accesses.
  3. Type Safety: The method uses Go's type system to unmarshal into the correct type.
  4. Efficiency: For string attributes, it avoids unnecessary unmarshaling.
  5. Thread-Safety: The method is safe to use in concurrent environments.

Usage example:

var preferences struct {
    Theme string `json:"theme"`
    Language string `json:"language"`
}

attr, err := session.GetAttributeAndRetainUnmarshaled("preferences", &preferences)
if err != nil {
    log.Printf("Failed to get preferences: %v", err)
    return
}

log.Printf("User preferences: Theme=%s, Language=%s", preferences.Theme, preferences.Language)

// The unmarshaled value is now cached in the session for future use
Distributed Lock Methods
NewDistributedLock

Creates a new distributed lock for a given session and resource.

func (sm *SessionManager) NewDistributedLock(sessionID uuid.UUID, resourceName string, config *DistributedLockConfig) *DistributedLock
Lock

Attempts to acquire the distributed lock.

func (dl *DistributedLock) Lock(ctx context.Context) error
Unlock

Releases the distributed lock.

func (dl *DistributedLock) Unlock(ctx context.Context) error
ExtendLease

Extends the lease time of the lock.

func (dl *DistributedLock) ExtendLease(ctx context.Context, duration time.Duration) error

Contributing

Contributions to go-pg-session are welcome! Here are some ways you can contribute:

  1. Report bugs or request features by opening an issue.
  2. Improve documentation.
  3. Submit pull requests with bug fixes or new features.

Please ensure that your code adheres to the existing style and that all tests pass before submitting a pull request.

License

This project is licensed under the MIT License. See the LICENSE file for details.


Thank you for using go-pg-session! If you have any questions, suggestions, or encounter any issues, please don't hesitate to open an issue on the GitHub repository. Your feedback and contributions are greatly appreciated and help make this library better for everyone.

Documentation

Overview

Package gopgsession provides a distributed session management library using PostgreSQL.

It implements an eventually consistent memory caching strategy on each node, offering a hybrid solution that leverages the benefits of both cookie-based and server-side session management. This package is designed for high-performance, scalable applications that require robust session handling across multiple nodes.

Index

Constants

View Source
const (
	NotificationTypeSessionsRemovalFromCache     = "sessions_removal_from_cache"
	NotificationTypeUserSessionsRemovalFromCache = "user_sessions_removal_from_cache"
)

Variables

View Source
var (
	// ErrLockAlreadyHeld is returned when attempting to acquire a lock that is already held by another node.
	ErrLockAlreadyHeld = errors.New("lock is already held by another node")
	// ErrLockNotHeld is returned when attempting to perform an operation on a lock that is not held by the current node.
	ErrLockNotHeld = errors.New("lock is not held by this node")
	// ErrLockExpired is returned when attempting to perform an operation on a lock that has expired.
	ErrLockExpired = errors.New("lock has expired")
)
View Source
var DefaultDistributedLockConfig = DistributedLockConfig{
	MaxRetries:        2,
	RetryDelay:        1 * time.Millisecond,
	HeartbeatInterval: 10 * time.Second,
	LeaseTime:         60 * time.Second,
}

DefaultDistributedLockConfig provides default configuration values for DistributedLock.

View Source
var ErrAttributeNotFound = errors.New("attribute not found")
View Source
var ErrSessionVersionIsOutdated = errors.New("session version is outdated")

Functions

This section is empty.

Types

type Config

type Config struct {
	// MaxSessions is the maximum number of concurrent sessions allowed per user.
	// When this limit is reached, the oldest session will be removed.
	MaxSessions int `json:"maxSessions"`

	// MaxAttributeLength is the maximum length (in bytes) allowed for a single session attribute value.
	MaxAttributeLength int `json:"maxAttributeLength"`

	// SessionExpiration is the duration after which a session expires if not accessed.
	SessionExpiration time.Duration `json:"sessionExpiration"`

	// InactivityDuration is the duration of inactivity after which a session is considered expired.
	InactivityDuration time.Duration `json:"inactivityDuration"`

	// CleanupInterval is the time interval between cleanup operations for expired sessions.
	CleanupInterval time.Duration `json:"cleanupInterval"`

	// CacheSize is the maximum number of sessions to keep in the in-memory cache.
	CacheSize int `json:"cacheSize"`

	// TablePrefix is the prefix to be used for all database tables created by the SessionManager.
	// This allows multiple SessionManager instances to coexist in the same database.
	TablePrefix string `json:"tablePrefix"`

	// SchemaName is the name of the PostgreSQL schema to use for session tables.
	// If empty, the default schema (usually "public") will be used.
	SchemaName string `json:"schemaName"`

	// CreateSchemaIfMissing, if true, will create the specified schema if it doesn't exist.
	CreateSchemaIfMissing bool `json:"createSchemaIfMissing"`

	// LastAccessUpdateInterval is the time interval between batch updates of session last access times.
	LastAccessUpdateInterval time.Duration `json:"lastAccessUpdateInterval"`

	// LastAccessUpdateBatchSize is the maximum number of sessions to update in a single batch operation.
	LastAccessUpdateBatchSize int `json:"lastAccessUpdateBatchSize"`

	// NotifyOnUpdates determines whether to send notifications on session updates.
	// This is a noisier option (true by default) but safer if you do not use additional cookies to note the last version.
	// For dozens of nodes, you may want to turn it off.
	NotifyOnUpdates bool

	// NotifyOnFailedUpdates sends a removal notification when, for example, a version check fails. FALSE by default.
	NotifyOnFailedUpdates bool

	// CustomPGLN is an optional custom PGLN instance. If not supplied, a new one will be created with defaults.
	CustomPGLN *pgln.PGListenNotify `json:"-"`
}

Config holds the configuration options for the SessionManager.

func DefaultConfig

func DefaultConfig() *Config

DefaultConfig returns a Config struct with default values.

type DistributedLock added in v0.3.0

type DistributedLock struct {
	// contains filtered or unexported fields
}

DistributedLock represents a distributed lock implementation.

func (*DistributedLock) ExtendLease added in v0.3.0

func (dl *DistributedLock) ExtendLease(ctx context.Context, extension time.Duration) error

ExtendLease extends the lease time of the lock.

Parameters:

  • ctx: The context for the operation.
  • extension: The duration by which to extend the lease.

Returns:

  • An error if the lease cannot be extended, nil otherwise.

func (*DistributedLock) Lock added in v0.3.0

func (dl *DistributedLock) Lock(ctx context.Context) error

Lock attempts to acquire the distributed lock.

Parameters:

  • ctx: The context for the operation.

Returns:

  • An error if the lock cannot be acquired, nil otherwise.

func (*DistributedLock) Unlock added in v0.3.0

func (dl *DistributedLock) Unlock(ctx context.Context) error

Unlock releases the distributed lock.

Parameters:

  • ctx: The context for the operation.

Returns:

  • An error if the lock cannot be released, nil otherwise.

type DistributedLockConfig added in v0.3.0

type DistributedLockConfig struct {
	// MaxRetries is the maximum number of attempts to acquire the lock.
	MaxRetries int
	// RetryDelay is the duration to wait between lock acquisition attempts.
	RetryDelay time.Duration
	// HeartbeatInterval is the duration between heartbeats to maintain the lock.
	HeartbeatInterval time.Duration
	// LeaseTime is the duration for which the lock is considered valid.
	LeaseTime time.Duration
}

DistributedLockConfig holds the configuration options for a DistributedLock.

type GetSessionOptions added in v0.2.1

type GetSessionOptions struct {
	DoNotUpdateSessionLastAccess bool
	ForceRefresh                 bool
}

type HMACSHA256SignerPool added in v0.2.1

type HMACSHA256SignerPool struct {
	// contains filtered or unexported fields
}

HMACSHA256SignerPool represents a limited pool of HMAC-SHA256 signers. It provides thread-safe access to a pool of HMAC signers for efficient signing and verification.

func NewHMACSHA256SignerPool added in v0.2.1

func NewHMACSHA256SignerPool(secret []byte, maxPoolSize int) *HMACSHA256SignerPool

NewHMACSHA256SignerPool initializes a new limited pool of HMAC-SHA256 signers.

Parameters:

  • secret: A byte slice containing the secret key used for HMAC operations.
  • maxPoolSize: An integer specifying the maximum number of HMAC signers to keep in the pool.

Returns:

  • A pointer to a new HMACSHA256SignerPool instance.

func (*HMACSHA256SignerPool) Sign added in v0.2.1

func (s *HMACSHA256SignerPool) Sign(message []byte) ([]byte, error)

Sign signs a message using a pooled HMAC.

Parameters:

  • message: A byte slice containing the message to be signed.

Returns:

  • A byte slice containing the HMAC signature.
  • An error if the signing process fails.

func (*HMACSHA256SignerPool) SignAndEncode added in v0.3.3

func (s *HMACSHA256SignerPool) SignAndEncode(message string) (string, error)

SignAndEncode signs a message and encodes it with the signature.

Parameters:

  • message: A string containing the message to be signed.

Returns:

  • A string containing the base64-encoded message and HMAC signature, separated by a delimiter.
  • An error if the signing process fails.

func (*HMACSHA256SignerPool) Verify added in v0.2.1

func (s *HMACSHA256SignerPool) Verify(message, signature []byte) bool

Verify verifies a signed message using a pooled HMAC.

Parameters:

  • message: A byte slice containing the original message that was signed.
  • signature: A byte slice containing the HMAC signature to verify.

Returns:

  • A boolean indicating whether the signature is valid (true) or not (false).

func (*HMACSHA256SignerPool) VerifyAndDecode added in v0.3.3

func (s *HMACSHA256SignerPool) VerifyAndDecode(signedMessage string) (bool, string, error)

VerifyAndDecode verifies a signed and encoded message and returns the original message.

Parameters:

  • signedMessage: A string containing the base64-encoded message and HMAC signature.

Returns:

  • A boolean indicating whether the signature is valid (true) or not (false).
  • A string containing the original message.
  • An error if the verification process fails.

type LockInfo added in v0.3.0

type LockInfo struct {
	NodeID        uuid.UUID
	Resource      string
	ExpiresAt     time.Time
	LastHeartbeat time.Time
}

LockInfo holds information about a lock.

type Session

type Session struct {
	ID     uuid.UUID `db:"id"`
	UserID uuid.UUID `db:"user_id"`

	// These are not updated in the cache often, only the table is the source of truth.
	LastAccessed time.Time `db:"last_accessed"`
	ExpiresAt    time.Time `db:"expires_at"`
	UpdatedAt    time.Time `db:"updated_at"`
	Version      int       `db:"version"`
	// contains filtered or unexported fields
}

func (*Session) DeleteAttribute added in v0.2.1

func (s *Session) DeleteAttribute(key string)

DeleteAttribute removes an attribute from the session.

Parameters:

  • key: The key of the attribute to delete.

func (*Session) GetAttribute

func (s *Session) GetAttribute(key string) (SessionAttributeValue, bool)

GetAttribute retrieves a specific attribute from the session.

Parameters:

  • key: The key of the attribute to retrieve.

Returns:

  • The SessionAttributeValue for the given key and a boolean indicating whether the attribute was found.

func (*Session) GetAttributeAndRetainUnmarshaled added in v0.2.6

func (s *Session) GetAttributeAndRetainUnmarshaled(key string, v interface{}) (SessionAttributeValue, error)

GetAttributeAndRetainUnmarshaled retrieves a specific attribute, unmarshals it if necessary, and retains the unmarshaled value in memory for future use. This method is optimized to prevent repeated unmarshaling of the same attribute as long as the session remains in memory.

It's particularly beneficial for attributes that are frequently accessed and expensive to unmarshal. By retaining the unmarshaled value, subsequent calls to this method for the same attribute will return the cached unmarshaled value without the need for repeated unmarshaling operations.

The method also ensures thread-safety when updating the shared cache, only doing so if the cached value hasn't been modified by another goroutine.

Parameters:

  • key: The key of the attribute to retrieve and unmarshal.
  • v: A pointer to the struct where the unmarshaled value will be stored.

Returns:

  • A copy of the SessionAttributeValue (which may be newly unmarshaled or previously cached) and an error if any occurred during the retrieval or unmarshaling process.
  • If an attribute is not found it returns ErrAttributeNotFound

Usage:

var myStruct MyStructType
attr, err := session.GetAttributeAndRetainUnmarshaled("myKey", &myStruct)
if err != nil {
    // Handle error
}
// Use myStruct and attr as needed

func (*Session) GetAttributes

func (s *Session) GetAttributes() map[string]SessionAttributeValue

GetAttributes returns all attributes of the session.

Returns:

  • A map of all session attributes.

func (*Session) IsFromCache added in v0.2.7

func (s *Session) IsFromCache() bool

IsFromCache returns true if the session was loaded from the cache, and false if it was loaded from the database table.

func (*Session) UpdateAttribute

func (s *Session) UpdateAttribute(key string, value interface{}, expiresAt *time.Time) error

UpdateAttribute sets or updates an attribute for the session.

Parameters:

  • key: The key of the attribute to update.
  • value: The value to set for the attribute. This will be converted to a string.
  • expiresAt: An optional pointer to a time.Time value indicating when the attribute should expire. If nil, the attribute will not have an expiration time.

The method will return an error if:

  • The value cannot be converted to a string.
  • The resulting string exceeds the maximum allowed length for an attribute value.

Example usage:

// Set an attribute without expiration
err := session.UpdateAttribute("theme", "dark", nil)

// Set an attribute with expiration
expiresAt := time.Now().Add(24 * time.Hour)
err := session.UpdateAttribute("temporary_flag", true, &expiresAt)

type SessionAttributeRecord added in v0.2.1

type SessionAttributeRecord struct {
	SessionID uuid.UUID  `db:"session_id"`
	Key       string     `db:"key"`
	Value     string     `db:"value"`
	ExpiresAt *time.Time `db:"expires_at"`
	Version   int        `db:"version"`
}

type SessionAttributeValue added in v0.2.1

type SessionAttributeValue struct {
	Value     any
	Marshaled bool
	ExpiresAt *time.Time
	Version   int
}

type SessionManager

type SessionManager struct {
	Config *Config
	// contains filtered or unexported fields
}

SessionManager manages sessions in a PostgreSQL database with caching capabilities.

func NewSessionManager

func NewSessionManager(ctx context.Context, cfg *Config, db *sql.DB) (*SessionManager, error)

NewSessionManager creates a new SessionManager with the given configuration and connection string.

Parameters:

  • ctx: A long-running context that should hold up until shutdown
  • cfg: A pointer to a Config struct containing the configuration options for the SessionManager.
  • db: An pgx (v5) stdlib

Returns:

  • A pointer to the created SessionManager and an error if any occurred during initialization.

func (*SessionManager) CreateSession

func (sm *SessionManager) CreateSession(ctx context.Context, userID uuid.UUID, attributes map[string]SessionAttributeValue) (*Session, error)

CreateSession creates a new session for the given user with the provided attributes.

Parameters:

  • ctx: The context for the operation.
  • userID: The UUID of the user for whom the session is being created.
  • attributes: A map of initial attributes for the session.

Returns:

  • A pointer to the created Session and an error if any occurred during creation.

func (*SessionManager) DeleteAllSessions added in v0.2.1

func (sm *SessionManager) DeleteAllSessions(ctx context.Context) error

DeleteAllSessions deletes all sessions from the database and cache.

Parameters:

  • ctx: The context for the operation.

Returns:

  • An error if any occurred during the deletion.

func (*SessionManager) DeleteAllUserSessions

func (sm *SessionManager) DeleteAllUserSessions(ctx context.Context, userID uuid.UUID) error

DeleteAllUserSessions deletes all sessions for a given user.

Parameters:

  • ctx: The context for the operation.
  • userID: The UUID of the user whose sessions should be deleted.

Returns:

  • An error if any occurred during the deletion.

func (*SessionManager) DeleteSession

func (sm *SessionManager) DeleteSession(ctx context.Context, sessionID uuid.UUID) error

DeleteSession deletes a session by its ID.

Parameters:

  • ctx: The context for the operation.
  • sessionID: The UUID of the session to delete.

Returns:

  • An error if any occurred during the deletion.

func (*SessionManager) EncodeSessionIDAndVersion added in v0.2.1

func (sm *SessionManager) EncodeSessionIDAndVersion(sessionID uuid.UUID, version int) string

EncodeSessionIDAndVersion encodes a session ID and version into a single string.

Parameters:

  • sessionID: The UUID of the session.
  • version: The version of the session.

Returns:

  • A string containing the encoded session ID and version.

func (*SessionManager) GetSession

func (sm *SessionManager) GetSession(ctx context.Context, sessionID uuid.UUID, options ...SessionOption) (*Session, error)

GetSession retrieves a session by its ID with optional parameters.

Parameters:

  • ctx: The context for the operation.
  • sessionID: The UUID of the session to retrieve.
  • options: Variadic SessionOption parameters to customize the retrieval behavior.

Returns:

  • A pointer to the retrieved Session and an error if any occurred during retrieval.

func (*SessionManager) GetSessionWithVersion added in v0.2.1

func (sm *SessionManager) GetSessionWithVersion(ctx context.Context, sessionID uuid.UUID, version int, options ...SessionOption) (*Session, error)

GetSessionWithVersion retrieves a session by its ID and version with optional parameters.

Parameters:

  • ctx: The context for the operation.
  • sessionID: The UUID of the session to retrieve.
  • version: The version of the session to retrieve.
  • options: Variadic SessionOption parameters to customize the retrieval behavior.

Returns:

  • A pointer to the retrieved Session and an error if any occurred during retrieval.

func (*SessionManager) NewDistributedLock added in v0.3.0

func (sm *SessionManager) NewDistributedLock(sessionID uuid.UUID, resource string, config *DistributedLockConfig) *DistributedLock

NewDistributedLock creates a new DistributedLock instance.

Parameters:

  • sessionID: The UUID of the session associated with this lock.
  • resource: The name of the resource being locked.
  • config: Optional configuration for the lock. If nil, default configuration is used.

Returns:

  • A pointer to the newly created DistributedLock.

func (*SessionManager) ParseSessionIDAndVersion added in v0.2.1

func (sm *SessionManager) ParseSessionIDAndVersion(encodedData string) (uuid.UUID, int, error)

ParseSessionIDAndVersion parses an encoded session ID and version string.

Parameters:

  • encodedData: The string containing the encoded session ID and version.

Returns:

  • The parsed session UUID, version, and an error if any occurred during parsing.

func (*SessionManager) RemoveAllUserCachedSessionsFromAllNodes added in v0.2.1

func (sm *SessionManager) RemoveAllUserCachedSessionsFromAllNodes(userID uuid.UUID) error

RemoveAllUserCachedSessionsFromAllNodes removes all cached sessions for a given user from all nodes.

Parameters:

  • userID: The UUID of the user whose cached sessions should be removed.

Returns:

  • An error if any occurred during the removal process.

func (*SessionManager) Shutdown

func (sm *SessionManager) Shutdown(ctx context.Context) error

Shutdown gracefully shuts down the SessionManager.

Parameters:

  • ctx: The context for the shutdown operation.

Returns:

  • An error if any occurred during the shutdown process.

func (*SessionManager) UpdateSession

func (sm *SessionManager) UpdateSession(ctx context.Context, session *Session, options ...UpdateSessionOption) (*Session, error)

UpdateSession updates the session in the database with any changes made to its attributes.

Parameters:

  • ctx: The context for the operation.
  • session: A pointer to the Session to be updated.
  • options: Variadic UpdateSessionOption parameters to customize the update behavior.

Returns:

  • A pointer to the updated Session and an error if any occurred during the update.

type SessionOption added in v0.2.7

type SessionOption func(*GetSessionOptions)

SessionOption is a function type that modifies GetSessionOptions

func WithDoNotUpdateSessionLastAccess added in v0.2.7

func WithDoNotUpdateSessionLastAccess() SessionOption

WithDoNotUpdateSessionLastAccess sets the DoNotUpdateSessionLastAccess option

func WithForceRefresh added in v0.2.7

func WithForceRefresh() SessionOption

WithForceRefresh sets the ForceRefresh option

type UpdateSessionOption added in v0.2.7

type UpdateSessionOption func(*UpdateSessionOptions)

UpdateSessionOption is a function type that modifies UpdateSessionOptions

func WithCheckAttributeVersion added in v0.3.0

func WithCheckAttributeVersion() UpdateSessionOption

WithCheckAttributeVersion sets the CheckAttributeVersion option

func WithCheckVersion added in v0.2.7

func WithCheckVersion() UpdateSessionOption

WithCheckVersion sets the CheckVersion option to true

func WithDoNotNotify added in v0.2.7

func WithDoNotNotify() UpdateSessionOption

WithDoNotNotify sets the DoNotNotify option

type UpdateSessionOptions added in v0.2.7

type UpdateSessionOptions struct {
	CheckVersion          bool
	CheckAttributeVersion bool
	DoNotNotify           bool
}

UpdateSessionOptions holds the options for updating a session

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL