feat(session): add automatic session cleanup on startup
- Add max_exited_session_age_days config (default: 7 days) - Increase max_exited_sessions default from 10 to 50 - Create CleanupSessionsUseCase for age and count-based cleanup - Add DeleteOldestExited and DeleteExitedBefore repository methods - Run cleanup on startup after marking stale sessions ended Closes #28, closes #29
- commit
- cd6e6000eb545fee78011f85f47b2ad53f77ffb3
- author
- bnema · · 2025-12-25 18:17 UTC
- parent
- 000ca5b
- signature
- GPG Unverified
2B82207D370127A0The key is unknown
12 files · +362 -11
A internal/application/usecase/cleanup_sessions.go +98 -0
| @@ -0,0 +1,98 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/domain/repository" | |
| 8 | + | "github.com/bnema/dumber/internal/logging" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // CleanupSessionsUseCase handles automatic cleanup of old exited sessions. | |
| 12 | + | type CleanupSessionsUseCase struct { | |
| 13 | + | sessionRepo repository.SessionRepository | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // NewCleanupSessionsUseCase creates a new CleanupSessionsUseCase. | |
| 17 | + | func NewCleanupSessionsUseCase(sessionRepo repository.SessionRepository) *CleanupSessionsUseCase { | |
| 18 | + | return &CleanupSessionsUseCase{ | |
| 19 | + | sessionRepo: sessionRepo, | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // CleanupSessionsInput contains the cleanup configuration. | |
| 24 | + | type CleanupSessionsInput struct { | |
| 25 | + | // MaxExitedSessions is the maximum number of exited sessions to keep. | |
| 26 | + | // Sessions beyond this count will be deleted (oldest first). | |
| 27 | + | MaxExitedSessions int | |
| 28 | + | ||
| 29 | + | // MaxExitedSessionAgeDays is the maximum age in days for exited sessions. | |
| 30 | + | // Sessions older than this will be deleted. | |
| 31 | + | // A value of 0 disables age-based cleanup. | |
| 32 | + | MaxExitedSessionAgeDays int | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // CleanupSessionsOutput contains the cleanup results. | |
| 36 | + | type CleanupSessionsOutput struct { | |
| 37 | + | // DeletedByAge is the number of sessions deleted due to age. | |
| 38 | + | DeletedByAge int64 | |
| 39 | + | ||
| 40 | + | // DeletedByCount is the number of sessions deleted due to count limit. | |
| 41 | + | DeletedByCount int64 | |
| 42 | + | ||
| 43 | + | // TotalDeleted is the total number of sessions deleted. | |
| 44 | + | TotalDeleted int64 | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // Execute cleans up old exited sessions based on the provided configuration. | |
| 48 | + | // It first deletes sessions older than MaxExitedSessionAgeDays, | |
| 49 | + | // then enforces the MaxExitedSessions count limit. | |
| 50 | + | func (uc *CleanupSessionsUseCase) Execute(ctx context.Context, input CleanupSessionsInput) (CleanupSessionsOutput, error) { | |
| 51 | + | log := logging.FromContext(ctx) | |
| 52 | + | output := CleanupSessionsOutput{} | |
| 53 | + | ||
| 54 | + | // 1. Delete by age first (if enabled) | |
| 55 | + | if input.MaxExitedSessionAgeDays > 0 { | |
| 56 | + | cutoff := time.Now().AddDate(0, 0, -input.MaxExitedSessionAgeDays) | |
| 57 | + | deleted, err := uc.sessionRepo.DeleteExitedBefore(ctx, cutoff) | |
| 58 | + | if err != nil { | |
| 59 | + | log.Warn().Err(err).Msg("failed to delete sessions by age") | |
| 60 | + | } else { | |
| 61 | + | output.DeletedByAge = deleted | |
| 62 | + | if deleted > 0 { | |
| 63 | + | log.Info(). | |
| 64 | + | Int64("deleted", deleted). | |
| 65 | + | Int("max_age_days", input.MaxExitedSessionAgeDays). | |
| 66 | + | Msg("cleaned up old sessions by age") | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // 2. Enforce count limit on remaining sessions | |
| 72 | + | if input.MaxExitedSessions >= 0 { | |
| 73 | + | deleted, err := uc.sessionRepo.DeleteOldestExited(ctx, input.MaxExitedSessions) | |
| 74 | + | if err != nil { | |
| 75 | + | log.Warn().Err(err).Msg("failed to delete sessions by count") | |
| 76 | + | } else { | |
| 77 | + | output.DeletedByCount = deleted | |
| 78 | + | if deleted > 0 { | |
| 79 | + | log.Info(). | |
| 80 | + | Int64("deleted", deleted). | |
| 81 | + | Int("max_count", input.MaxExitedSessions). | |
| 82 | + | Msg("cleaned up old sessions by count") | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | output.TotalDeleted = output.DeletedByAge + output.DeletedByCount | |
| 88 | + | ||
| 89 | + | if output.TotalDeleted > 0 { | |
| 90 | + | log.Info(). | |
| 91 | + | Int64("total_deleted", output.TotalDeleted). | |
| 92 | + | Int64("by_age", output.DeletedByAge). | |
| 93 | + | Int64("by_count", output.DeletedByCount). | |
| 94 | + | Msg("session cleanup completed") | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | return output, nil | |
| 98 | + | } | |
| @@ -0,0 +1,98 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/domain/repository" | |||
| 8 | + | "github.com/bnema/dumber/internal/logging" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // CleanupSessionsUseCase handles automatic cleanup of old exited sessions. | |||
| 12 | + | type CleanupSessionsUseCase struct { | |||
| 13 | + | sessionRepo repository.SessionRepository | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | // NewCleanupSessionsUseCase creates a new CleanupSessionsUseCase. | |||
| 17 | + | func NewCleanupSessionsUseCase(sessionRepo repository.SessionRepository) *CleanupSessionsUseCase { | |||
| 18 | + | return &CleanupSessionsUseCase{ | |||
| 19 | + | sessionRepo: sessionRepo, | |||
| 20 | + | } | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // CleanupSessionsInput contains the cleanup configuration. | |||
| 24 | + | type CleanupSessionsInput struct { | |||
| 25 | + | // MaxExitedSessions is the maximum number of exited sessions to keep. | |||
| 26 | + | // Sessions beyond this count will be deleted (oldest first). | |||
| 27 | + | MaxExitedSessions int | |||
| 28 | + | ||||
| 29 | + | // MaxExitedSessionAgeDays is the maximum age in days for exited sessions. | |||
| 30 | + | // Sessions older than this will be deleted. | |||
| 31 | + | // A value of 0 disables age-based cleanup. | |||
| 32 | + | MaxExitedSessionAgeDays int | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // CleanupSessionsOutput contains the cleanup results. | |||
| 36 | + | type CleanupSessionsOutput struct { | |||
| 37 | + | // DeletedByAge is the number of sessions deleted due to age. | |||
| 38 | + | DeletedByAge int64 | |||
| 39 | + | ||||
| 40 | + | // DeletedByCount is the number of sessions deleted due to count limit. | |||
| 41 | + | DeletedByCount int64 | |||
| 42 | + | ||||
| 43 | + | // TotalDeleted is the total number of sessions deleted. | |||
| 44 | + | TotalDeleted int64 | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // Execute cleans up old exited sessions based on the provided configuration. | |||
| 48 | + | // It first deletes sessions older than MaxExitedSessionAgeDays, | |||
| 49 | + | // then enforces the MaxExitedSessions count limit. | |||
| 50 | + | func (uc *CleanupSessionsUseCase) Execute(ctx context.Context, input CleanupSessionsInput) (CleanupSessionsOutput, error) { | |||
| 51 | + | log := logging.FromContext(ctx) | |||
| 52 | + | output := CleanupSessionsOutput{} | |||
| 53 | + | ||||
| 54 | + | // 1. Delete by age first (if enabled) | |||
| 55 | + | if input.MaxExitedSessionAgeDays > 0 { | |||
| 56 | + | cutoff := time.Now().AddDate(0, 0, -input.MaxExitedSessionAgeDays) | |||
| 57 | + | deleted, err := uc.sessionRepo.DeleteExitedBefore(ctx, cutoff) | |||
| 58 | + | if err != nil { | |||
| 59 | + | log.Warn().Err(err).Msg("failed to delete sessions by age") | |||
| 60 | + | } else { | |||
| 61 | + | output.DeletedByAge = deleted | |||
| 62 | + | if deleted > 0 { | |||
| 63 | + | log.Info(). | |||
| 64 | + | Int64("deleted", deleted). | |||
| 65 | + | Int("max_age_days", input.MaxExitedSessionAgeDays). | |||
| 66 | + | Msg("cleaned up old sessions by age") | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // 2. Enforce count limit on remaining sessions | |||
| 72 | + | if input.MaxExitedSessions >= 0 { | |||
| 73 | + | deleted, err := uc.sessionRepo.DeleteOldestExited(ctx, input.MaxExitedSessions) | |||
| 74 | + | if err != nil { | |||
| 75 | + | log.Warn().Err(err).Msg("failed to delete sessions by count") | |||
| 76 | + | } else { | |||
| 77 | + | output.DeletedByCount = deleted | |||
| 78 | + | if deleted > 0 { | |||
| 79 | + | log.Info(). | |||
| 80 | + | Int64("deleted", deleted). | |||
| 81 | + | Int("max_count", input.MaxExitedSessions). | |||
| 82 | + | Msg("cleaned up old sessions by count") | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | output.TotalDeleted = output.DeletedByAge + output.DeletedByCount | |||
| 88 | + | ||||
| 89 | + | if output.TotalDeleted > 0 { | |||
| 90 | + | log.Info(). | |||
| 91 | + | Int64("total_deleted", output.TotalDeleted). | |||
| 92 | + | Int64("by_age", output.DeletedByAge). | |||
| 93 | + | Int64("by_count", output.DeletedByCount). | |||
| 94 | + | Msg("session cleanup completed") | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | return output, nil | |||
| 98 | + | } | |||
M internal/bootstrap/session.go +14 -0
| @@ -56,6 +56,20 @@ func StartBrowserSession(ctx context.Context, cfg *config.Config, db *sql.DB) (* | |||
| 56 | 56 | } | |
| 57 | 57 | } | |
| 58 | 58 | ||
| 59 | + | // Clean up old exited sessions based on config limits | |
| 60 | + | cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo) | |
| 61 | + | cleanupOutput, err := cleanupUC.Execute(ctx, usecase.CleanupSessionsInput{ | |
| 62 | + | MaxExitedSessions: cfg.Session.MaxExitedSessions, | |
| 63 | + | MaxExitedSessionAgeDays: cfg.Session.MaxExitedSessionAgeDays, | |
| 64 | + | }) | |
| 65 | + | if err != nil { | |
| 66 | + | log.Warn().Err(err).Msg("failed to cleanup old sessions") | |
| 67 | + | } else if cleanupOutput.TotalDeleted > 0 { | |
| 68 | + | log.Info(). | |
| 69 | + | Int64("deleted", cleanupOutput.TotalDeleted). | |
| 70 | + | Msg("cleaned up old sessions on startup") | |
| 71 | + | } | |
| 72 | + | ||
| 59 | 73 | now := time.Now() | |
| 60 | 74 | out, err := sessionUC.StartSession(ctx, usecase.StartSessionInput{ | |
| 61 | 75 | Type: entity.SessionTypeBrowser, | |
| @@ -56,6 +56,20 @@ func StartBrowserSession(ctx context.Context, cfg *config.Config, db *sql.DB) (* | |||||
| 56 | } | 56 | } | ||
| 57 | } | 57 | } | ||
| 58 | 58 | ||||
| 59 | + | // Clean up old exited sessions based on config limits | |||
| 60 | + | cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo) | |||
| 61 | + | cleanupOutput, err := cleanupUC.Execute(ctx, usecase.CleanupSessionsInput{ | |||
| 62 | + | MaxExitedSessions: cfg.Session.MaxExitedSessions, | |||
| 63 | + | MaxExitedSessionAgeDays: cfg.Session.MaxExitedSessionAgeDays, | |||
| 64 | + | }) | |||
| 65 | + | if err != nil { | |||
| 66 | + | log.Warn().Err(err).Msg("failed to cleanup old sessions") | |||
| 67 | + | } else if cleanupOutput.TotalDeleted > 0 { | |||
| 68 | + | log.Info(). | |||
| 69 | + | Int64("deleted", cleanupOutput.TotalDeleted). | |||
| 70 | + | Msg("cleaned up old sessions on startup") | |||
| 71 | + | } | |||
| 72 | + | ||||
| 59 | now := time.Now() | 73 | now := time.Now() | ||
| 60 | out, err := sessionUC.StartSession(ctx, usecase.StartSessionInput{ | 74 | out, err := sessionUC.StartSession(ctx, usecase.StartSessionInput{ | ||
| 61 | Type: entity.SessionTypeBrowser, | 75 | Type: entity.SessionTypeBrowser, | ||
M internal/domain/repository/mocks/mocks.go +132 -0
| @@ -2803,6 +2803,138 @@ func (_c *MockSessionRepository_Delete_Call) RunAndReturn(run func(ctx context.C | |||
| 2803 | 2803 | return _c | |
| 2804 | 2804 | } | |
| 2805 | 2805 | ||
| 2806 | + | // DeleteExitedBefore provides a mock function for the type MockSessionRepository | |
| 2807 | + | func (_mock *MockSessionRepository) DeleteExitedBefore(ctx context.Context, cutoff time.Time) (int64, error) { | |
| 2808 | + | ret := _mock.Called(ctx, cutoff) | |
| 2809 | + | ||
| 2810 | + | if len(ret) == 0 { | |
| 2811 | + | panic("no return value specified for DeleteExitedBefore") | |
| 2812 | + | } | |
| 2813 | + | ||
| 2814 | + | var r0 int64 | |
| 2815 | + | var r1 error | |
| 2816 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) (int64, error)); ok { | |
| 2817 | + | return returnFunc(ctx, cutoff) | |
| 2818 | + | } | |
| 2819 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) int64); ok { | |
| 2820 | + | r0 = returnFunc(ctx, cutoff) | |
| 2821 | + | } else { | |
| 2822 | + | r0 = ret.Get(0).(int64) | |
| 2823 | + | } | |
| 2824 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time) error); ok { | |
| 2825 | + | r1 = returnFunc(ctx, cutoff) | |
| 2826 | + | } else { | |
| 2827 | + | r1 = ret.Error(1) | |
| 2828 | + | } | |
| 2829 | + | return r0, r1 | |
| 2830 | + | } | |
| 2831 | + | ||
| 2832 | + | // MockSessionRepository_DeleteExitedBefore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteExitedBefore' | |
| 2833 | + | type MockSessionRepository_DeleteExitedBefore_Call struct { | |
| 2834 | + | *mock.Call | |
| 2835 | + | } | |
| 2836 | + | ||
| 2837 | + | // DeleteExitedBefore is a helper method to define mock.On call | |
| 2838 | + | // - ctx context.Context | |
| 2839 | + | // - cutoff time.Time | |
| 2840 | + | func (_e *MockSessionRepository_Expecter) DeleteExitedBefore(ctx interface{}, cutoff interface{}) *MockSessionRepository_DeleteExitedBefore_Call { | |
| 2841 | + | return &MockSessionRepository_DeleteExitedBefore_Call{Call: _e.mock.On("DeleteExitedBefore", ctx, cutoff)} | |
| 2842 | + | } | |
| 2843 | + | ||
| 2844 | + | func (_c *MockSessionRepository_DeleteExitedBefore_Call) Run(run func(ctx context.Context, cutoff time.Time)) *MockSessionRepository_DeleteExitedBefore_Call { | |
| 2845 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 2846 | + | var arg0 context.Context | |
| 2847 | + | if args[0] != nil { | |
| 2848 | + | arg0 = args[0].(context.Context) | |
| 2849 | + | } | |
| 2850 | + | var arg1 time.Time | |
| 2851 | + | if args[1] != nil { | |
| 2852 | + | arg1 = args[1].(time.Time) | |
| 2853 | + | } | |
| 2854 | + | run( | |
| 2855 | + | arg0, | |
| 2856 | + | arg1, | |
| 2857 | + | ) | |
| 2858 | + | }) | |
| 2859 | + | return _c | |
| 2860 | + | } | |
| 2861 | + | ||
| 2862 | + | func (_c *MockSessionRepository_DeleteExitedBefore_Call) Return(n int64, err error) *MockSessionRepository_DeleteExitedBefore_Call { | |
| 2863 | + | _c.Call.Return(n, err) | |
| 2864 | + | return _c | |
| 2865 | + | } | |
| 2866 | + | ||
| 2867 | + | func (_c *MockSessionRepository_DeleteExitedBefore_Call) RunAndReturn(run func(ctx context.Context, cutoff time.Time) (int64, error)) *MockSessionRepository_DeleteExitedBefore_Call { | |
| 2868 | + | _c.Call.Return(run) | |
| 2869 | + | return _c | |
| 2870 | + | } | |
| 2871 | + | ||
| 2872 | + | // DeleteOldestExited provides a mock function for the type MockSessionRepository | |
| 2873 | + | func (_mock *MockSessionRepository) DeleteOldestExited(ctx context.Context, keepCount int) (int64, error) { | |
| 2874 | + | ret := _mock.Called(ctx, keepCount) | |
| 2875 | + | ||
| 2876 | + | if len(ret) == 0 { | |
| 2877 | + | panic("no return value specified for DeleteOldestExited") | |
| 2878 | + | } | |
| 2879 | + | ||
| 2880 | + | var r0 int64 | |
| 2881 | + | var r1 error | |
| 2882 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int) (int64, error)); ok { | |
| 2883 | + | return returnFunc(ctx, keepCount) | |
| 2884 | + | } | |
| 2885 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int) int64); ok { | |
| 2886 | + | r0 = returnFunc(ctx, keepCount) | |
| 2887 | + | } else { | |
| 2888 | + | r0 = ret.Get(0).(int64) | |
| 2889 | + | } | |
| 2890 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok { | |
| 2891 | + | r1 = returnFunc(ctx, keepCount) | |
| 2892 | + | } else { | |
| 2893 | + | r1 = ret.Error(1) | |
| 2894 | + | } | |
| 2895 | + | return r0, r1 | |
| 2896 | + | } | |
| 2897 | + | ||
| 2898 | + | // MockSessionRepository_DeleteOldestExited_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteOldestExited' | |
| 2899 | + | type MockSessionRepository_DeleteOldestExited_Call struct { | |
| 2900 | + | *mock.Call | |
| 2901 | + | } | |
| 2902 | + | ||
| 2903 | + | // DeleteOldestExited is a helper method to define mock.On call | |
| 2904 | + | // - ctx context.Context | |
| 2905 | + | // - keepCount int | |
| 2906 | + | func (_e *MockSessionRepository_Expecter) DeleteOldestExited(ctx interface{}, keepCount interface{}) *MockSessionRepository_DeleteOldestExited_Call { | |
| 2907 | + | return &MockSessionRepository_DeleteOldestExited_Call{Call: _e.mock.On("DeleteOldestExited", ctx, keepCount)} | |
| 2908 | + | } | |
| 2909 | + | ||
| 2910 | + | func (_c *MockSessionRepository_DeleteOldestExited_Call) Run(run func(ctx context.Context, keepCount int)) *MockSessionRepository_DeleteOldestExited_Call { | |
| 2911 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 2912 | + | var arg0 context.Context | |
| 2913 | + | if args[0] != nil { | |
| 2914 | + | arg0 = args[0].(context.Context) | |
| 2915 | + | } | |
| 2916 | + | var arg1 int | |
| 2917 | + | if args[1] != nil { | |
| 2918 | + | arg1 = args[1].(int) | |
| 2919 | + | } | |
| 2920 | + | run( | |
| 2921 | + | arg0, | |
| 2922 | + | arg1, | |
| 2923 | + | ) | |
| 2924 | + | }) | |
| 2925 | + | return _c | |
| 2926 | + | } | |
| 2927 | + | ||
| 2928 | + | func (_c *MockSessionRepository_DeleteOldestExited_Call) Return(n int64, err error) *MockSessionRepository_DeleteOldestExited_Call { | |
| 2929 | + | _c.Call.Return(n, err) | |
| 2930 | + | return _c | |
| 2931 | + | } | |
| 2932 | + | ||
| 2933 | + | func (_c *MockSessionRepository_DeleteOldestExited_Call) RunAndReturn(run func(ctx context.Context, keepCount int) (int64, error)) *MockSessionRepository_DeleteOldestExited_Call { | |
| 2934 | + | _c.Call.Return(run) | |
| 2935 | + | return _c | |
| 2936 | + | } | |
| 2937 | + | ||
| 2806 | 2938 | // FindByID provides a mock function for the type MockSessionRepository | |
| 2807 | 2939 | func (_mock *MockSessionRepository) FindByID(ctx context.Context, id entity.SessionID) (*entity.Session, error) { | |
| 2808 | 2940 | ret := _mock.Called(ctx, id) | |
| @@ -2803,6 +2803,138 @@ func (_c *MockSessionRepository_Delete_Call) RunAndReturn(run func(ctx context.C | |||||
| 2803 | return _c | 2803 | return _c | ||
| 2804 | } | 2804 | } | ||
| 2805 | 2805 | ||||
| 2806 | + | // DeleteExitedBefore provides a mock function for the type MockSessionRepository | |||
| 2807 | + | func (_mock *MockSessionRepository) DeleteExitedBefore(ctx context.Context, cutoff time.Time) (int64, error) { | |||
| 2808 | + | ret := _mock.Called(ctx, cutoff) | |||
| 2809 | + | ||||
| 2810 | + | if len(ret) == 0 { | |||
| 2811 | + | panic("no return value specified for DeleteExitedBefore") | |||
| 2812 | + | } | |||
| 2813 | + | ||||
| 2814 | + | var r0 int64 | |||
| 2815 | + | var r1 error | |||
| 2816 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) (int64, error)); ok { | |||
| 2817 | + | return returnFunc(ctx, cutoff) | |||
| 2818 | + | } | |||
| 2819 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) int64); ok { | |||
| 2820 | + | r0 = returnFunc(ctx, cutoff) | |||
| 2821 | + | } else { | |||
| 2822 | + | r0 = ret.Get(0).(int64) | |||
| 2823 | + | } | |||
| 2824 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time) error); ok { | |||
| 2825 | + | r1 = returnFunc(ctx, cutoff) | |||
| 2826 | + | } else { | |||
| 2827 | + | r1 = ret.Error(1) | |||
| 2828 | + | } | |||
| 2829 | + | return r0, r1 | |||
| 2830 | + | } | |||
| 2831 | + | ||||
| 2832 | + | // MockSessionRepository_DeleteExitedBefore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteExitedBefore' | |||
| 2833 | + | type MockSessionRepository_DeleteExitedBefore_Call struct { | |||
| 2834 | + | *mock.Call | |||
| 2835 | + | } | |||
| 2836 | + | ||||
| 2837 | + | // DeleteExitedBefore is a helper method to define mock.On call | |||
| 2838 | + | // - ctx context.Context | |||
| 2839 | + | // - cutoff time.Time | |||
| 2840 | + | func (_e *MockSessionRepository_Expecter) DeleteExitedBefore(ctx interface{}, cutoff interface{}) *MockSessionRepository_DeleteExitedBefore_Call { | |||
| 2841 | + | return &MockSessionRepository_DeleteExitedBefore_Call{Call: _e.mock.On("DeleteExitedBefore", ctx, cutoff)} | |||
| 2842 | + | } | |||
| 2843 | + | ||||
| 2844 | + | func (_c *MockSessionRepository_DeleteExitedBefore_Call) Run(run func(ctx context.Context, cutoff time.Time)) *MockSessionRepository_DeleteExitedBefore_Call { | |||
| 2845 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 2846 | + | var arg0 context.Context | |||
| 2847 | + | if args[0] != nil { | |||
| 2848 | + | arg0 = args[0].(context.Context) | |||
| 2849 | + | } | |||
| 2850 | + | var arg1 time.Time | |||
| 2851 | + | if args[1] != nil { | |||
| 2852 | + | arg1 = args[1].(time.Time) | |||
| 2853 | + | } | |||
| 2854 | + | run( | |||
| 2855 | + | arg0, | |||
| 2856 | + | arg1, | |||
| 2857 | + | ) | |||
| 2858 | + | }) | |||
| 2859 | + | return _c | |||
| 2860 | + | } | |||
| 2861 | + | ||||
| 2862 | + | func (_c *MockSessionRepository_DeleteExitedBefore_Call) Return(n int64, err error) *MockSessionRepository_DeleteExitedBefore_Call { | |||
| 2863 | + | _c.Call.Return(n, err) | |||
| 2864 | + | return _c | |||
| 2865 | + | } | |||
| 2866 | + | ||||
| 2867 | + | func (_c *MockSessionRepository_DeleteExitedBefore_Call) RunAndReturn(run func(ctx context.Context, cutoff time.Time) (int64, error)) *MockSessionRepository_DeleteExitedBefore_Call { | |||
| 2868 | + | _c.Call.Return(run) | |||
| 2869 | + | return _c | |||
| 2870 | + | } | |||
| 2871 | + | ||||
| 2872 | + | // DeleteOldestExited provides a mock function for the type MockSessionRepository | |||
| 2873 | + | func (_mock *MockSessionRepository) DeleteOldestExited(ctx context.Context, keepCount int) (int64, error) { | |||
| 2874 | + | ret := _mock.Called(ctx, keepCount) | |||
| 2875 | + | ||||
| 2876 | + | if len(ret) == 0 { | |||
| 2877 | + | panic("no return value specified for DeleteOldestExited") | |||
| 2878 | + | } | |||
| 2879 | + | ||||
| 2880 | + | var r0 int64 | |||
| 2881 | + | var r1 error | |||
| 2882 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int) (int64, error)); ok { | |||
| 2883 | + | return returnFunc(ctx, keepCount) | |||
| 2884 | + | } | |||
| 2885 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int) int64); ok { | |||
| 2886 | + | r0 = returnFunc(ctx, keepCount) | |||
| 2887 | + | } else { | |||
| 2888 | + | r0 = ret.Get(0).(int64) | |||
| 2889 | + | } | |||
| 2890 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok { | |||
| 2891 | + | r1 = returnFunc(ctx, keepCount) | |||
| 2892 | + | } else { | |||
| 2893 | + | r1 = ret.Error(1) | |||
| 2894 | + | } | |||
| 2895 | + | return r0, r1 | |||
| 2896 | + | } | |||
| 2897 | + | ||||
| 2898 | + | // MockSessionRepository_DeleteOldestExited_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteOldestExited' | |||
| 2899 | + | type MockSessionRepository_DeleteOldestExited_Call struct { | |||
| 2900 | + | *mock.Call | |||
| 2901 | + | } | |||
| 2902 | + | ||||
| 2903 | + | // DeleteOldestExited is a helper method to define mock.On call | |||
| 2904 | + | // - ctx context.Context | |||
| 2905 | + | // - keepCount int | |||
| 2906 | + | func (_e *MockSessionRepository_Expecter) DeleteOldestExited(ctx interface{}, keepCount interface{}) *MockSessionRepository_DeleteOldestExited_Call { | |||
| 2907 | + | return &MockSessionRepository_DeleteOldestExited_Call{Call: _e.mock.On("DeleteOldestExited", ctx, keepCount)} | |||
| 2908 | + | } | |||
| 2909 | + | ||||
| 2910 | + | func (_c *MockSessionRepository_DeleteOldestExited_Call) Run(run func(ctx context.Context, keepCount int)) *MockSessionRepository_DeleteOldestExited_Call { | |||
| 2911 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 2912 | + | var arg0 context.Context | |||
| 2913 | + | if args[0] != nil { | |||
| 2914 | + | arg0 = args[0].(context.Context) | |||
| 2915 | + | } | |||
| 2916 | + | var arg1 int | |||
| 2917 | + | if args[1] != nil { | |||
| 2918 | + | arg1 = args[1].(int) | |||
| 2919 | + | } | |||
| 2920 | + | run( | |||
| 2921 | + | arg0, | |||
| 2922 | + | arg1, | |||
| 2923 | + | ) | |||
| 2924 | + | }) | |||
| 2925 | + | return _c | |||
| 2926 | + | } | |||
| 2927 | + | ||||
| 2928 | + | func (_c *MockSessionRepository_DeleteOldestExited_Call) Return(n int64, err error) *MockSessionRepository_DeleteOldestExited_Call { | |||
| 2929 | + | _c.Call.Return(n, err) | |||
| 2930 | + | return _c | |||
| 2931 | + | } | |||
| 2932 | + | ||||
| 2933 | + | func (_c *MockSessionRepository_DeleteOldestExited_Call) RunAndReturn(run func(ctx context.Context, keepCount int) (int64, error)) *MockSessionRepository_DeleteOldestExited_Call { | |||
| 2934 | + | _c.Call.Return(run) | |||
| 2935 | + | return _c | |||
| 2936 | + | } | |||
| 2937 | + | ||||
| 2806 | // FindByID provides a mock function for the type MockSessionRepository | 2938 | // FindByID provides a mock function for the type MockSessionRepository | ||
| 2807 | func (_mock *MockSessionRepository) FindByID(ctx context.Context, id entity.SessionID) (*entity.Session, error) { | 2939 | func (_mock *MockSessionRepository) FindByID(ctx context.Context, id entity.SessionID) (*entity.Session, error) { | ||
| 2808 | ret := _mock.Called(ctx, id) | 2940 | ret := _mock.Called(ctx, id) | ||
M internal/domain/repository/session.go +8 -0
| @@ -19,4 +19,12 @@ type SessionRepository interface { | |||
| 19 | 19 | ||
| 20 | 20 | // Delete removes a session record. | |
| 21 | 21 | Delete(ctx context.Context, id entity.SessionID) error | |
| 22 | + | ||
| 23 | + | // DeleteOldestExited deletes exited sessions beyond the keep limit. | |
| 24 | + | // Returns number of deleted sessions. | |
| 25 | + | DeleteOldestExited(ctx context.Context, keepCount int) (int64, error) | |
| 26 | + | ||
| 27 | + | // DeleteExitedBefore deletes exited sessions older than the cutoff time. | |
| 28 | + | // Returns number of deleted sessions. | |
| 29 | + | DeleteExitedBefore(ctx context.Context, cutoff time.Time) (int64, error) | |
| 22 | 30 | } | |
| @@ -19,4 +19,12 @@ type SessionRepository interface { | |||||
| 19 | 19 | ||||
| 20 | // Delete removes a session record. | 20 | // Delete removes a session record. | ||
| 21 | Delete(ctx context.Context, id entity.SessionID) error | 21 | Delete(ctx context.Context, id entity.SessionID) error | ||
| 22 | + | ||||
| 23 | + | // DeleteOldestExited deletes exited sessions beyond the keep limit. | |||
| 24 | + | // Returns number of deleted sessions. | |||
| 25 | + | DeleteOldestExited(ctx context.Context, keepCount int) (int64, error) | |||
| 26 | + | ||||
| 27 | + | // DeleteExitedBefore deletes exited sessions older than the cutoff time. | |||
| 28 | + | // Returns number of deleted sessions. | |||
| 29 | + | DeleteExitedBefore(ctx context.Context, cutoff time.Time) (int64, error) | |||
| 22 | } | 30 | } | ||
M internal/infrastructure/config/defaults.go +6 -6
| @@ -30,8 +30,8 @@ const ( | |||
| 30 | 30 | defaultSessionActivationShortcut = "ctrl+o" | |
| 31 | 31 | defaultSessionTimeoutMilliseconds = 3000 | |
| 32 | 32 | defaultSnapshotIntervalMs = 5000 | |
| 33 | - | defaultMaxExitedSessions = 10 | |
| 34 | - | defaultMaxListedSessions = 50 | |
| 33 | + | defaultMaxExitedSessions = 50 | |
| 34 | + | defaultMaxExitedSessionAgeDays = 7 | |
| 35 | 35 | ||
| 36 | 36 | // Workspace styling defaults | |
| 37 | 37 | // Active pane border (overlay) | |
| @@ -210,10 +210,10 @@ func DefaultConfig() *Config { | |||
| 210 | 210 | InitialBehavior: defaultOmniboxInitialBehavior, | |
| 211 | 211 | }, | |
| 212 | 212 | Session: SessionConfig{ | |
| 213 | - | AutoRestore: false, | |
| 214 | - | SnapshotIntervalMs: defaultSnapshotIntervalMs, | |
| 215 | - | MaxExitedSessions: defaultMaxExitedSessions, | |
| 216 | - | MaxListedSessions: defaultMaxListedSessions, | |
| 213 | + | AutoRestore: false, | |
| 214 | + | SnapshotIntervalMs: defaultSnapshotIntervalMs, | |
| 215 | + | MaxExitedSessions: defaultMaxExitedSessions, | |
| 216 | + | MaxExitedSessionAgeDays: defaultMaxExitedSessionAgeDays, | |
| 217 | 217 | SessionMode: SessionModeConfig{ | |
| 218 | 218 | ActivationShortcut: defaultSessionActivationShortcut, | |
| 219 | 219 | TimeoutMilliseconds: defaultSessionTimeoutMilliseconds, | |
| @@ -30,8 +30,8 @@ const ( | |||||
| 30 | defaultSessionActivationShortcut = "ctrl+o" | 30 | defaultSessionActivationShortcut = "ctrl+o" | ||
| 31 | defaultSessionTimeoutMilliseconds = 3000 | 31 | defaultSessionTimeoutMilliseconds = 3000 | ||
| 32 | defaultSnapshotIntervalMs = 5000 | 32 | defaultSnapshotIntervalMs = 5000 | ||
| 33 | - | defaultMaxExitedSessions = 10 | 33 | + | defaultMaxExitedSessions = 50 |
| 34 | - | defaultMaxListedSessions = 50 | 34 | + | defaultMaxExitedSessionAgeDays = 7 |
| 35 | 35 | ||||
| 36 | // Workspace styling defaults | 36 | // Workspace styling defaults | ||
| 37 | // Active pane border (overlay) | 37 | // Active pane border (overlay) | ||
| @@ -210,10 +210,10 @@ func DefaultConfig() *Config { | |||||
| 210 | InitialBehavior: defaultOmniboxInitialBehavior, | 210 | InitialBehavior: defaultOmniboxInitialBehavior, | ||
| 211 | }, | 211 | }, | ||
| 212 | Session: SessionConfig{ | 212 | Session: SessionConfig{ | ||
| 213 | - | AutoRestore: false, | 213 | + | AutoRestore: false, |
| 214 | - | SnapshotIntervalMs: defaultSnapshotIntervalMs, | 214 | + | SnapshotIntervalMs: defaultSnapshotIntervalMs, |
| 215 | - | MaxExitedSessions: defaultMaxExitedSessions, | 215 | + | MaxExitedSessions: defaultMaxExitedSessions, |
| 216 | - | MaxListedSessions: defaultMaxListedSessions, | 216 | + | MaxExitedSessionAgeDays: defaultMaxExitedSessionAgeDays, |
| 217 | SessionMode: SessionModeConfig{ | 217 | SessionMode: SessionModeConfig{ | ||
| 218 | ActivationShortcut: defaultSessionActivationShortcut, | 218 | ActivationShortcut: defaultSessionActivationShortcut, | ||
| 219 | TimeoutMilliseconds: defaultSessionTimeoutMilliseconds, | 219 | TimeoutMilliseconds: defaultSessionTimeoutMilliseconds, | ||
M internal/infrastructure/config/loader.go +1 -1
| @@ -437,7 +437,7 @@ func (m *Manager) setSessionDefaults(defaults *Config) { | |||
| 437 | 437 | m.viper.SetDefault("session.auto_restore", defaults.Session.AutoRestore) | |
| 438 | 438 | m.viper.SetDefault("session.snapshot_interval_ms", defaults.Session.SnapshotIntervalMs) | |
| 439 | 439 | m.viper.SetDefault("session.max_exited_sessions", defaults.Session.MaxExitedSessions) | |
| 440 | - | m.viper.SetDefault("session.max_listed_sessions", defaults.Session.MaxListedSessions) | |
| 440 | + | m.viper.SetDefault("session.max_exited_session_age_days", defaults.Session.MaxExitedSessionAgeDays) | |
| 441 | 441 | m.viper.SetDefault("session.session_mode.activation_shortcut", defaults.Session.SessionMode.ActivationShortcut) | |
| 442 | 442 | m.viper.SetDefault("session.session_mode.timeout_ms", defaults.Session.SessionMode.TimeoutMilliseconds) | |
| 443 | 443 | m.viper.SetDefault("session.session_mode.actions", defaults.Session.SessionMode.Actions) | |
| @@ -437,7 +437,7 @@ func (m *Manager) setSessionDefaults(defaults *Config) { | |||||
| 437 | m.viper.SetDefault("session.auto_restore", defaults.Session.AutoRestore) | 437 | m.viper.SetDefault("session.auto_restore", defaults.Session.AutoRestore) | ||
| 438 | m.viper.SetDefault("session.snapshot_interval_ms", defaults.Session.SnapshotIntervalMs) | 438 | m.viper.SetDefault("session.snapshot_interval_ms", defaults.Session.SnapshotIntervalMs) | ||
| 439 | m.viper.SetDefault("session.max_exited_sessions", defaults.Session.MaxExitedSessions) | 439 | m.viper.SetDefault("session.max_exited_sessions", defaults.Session.MaxExitedSessions) | ||
| 440 | - | m.viper.SetDefault("session.max_listed_sessions", defaults.Session.MaxListedSessions) | 440 | + | m.viper.SetDefault("session.max_exited_session_age_days", defaults.Session.MaxExitedSessionAgeDays) |
| 441 | m.viper.SetDefault("session.session_mode.activation_shortcut", defaults.Session.SessionMode.ActivationShortcut) | 441 | m.viper.SetDefault("session.session_mode.activation_shortcut", defaults.Session.SessionMode.ActivationShortcut) | ||
| 442 | m.viper.SetDefault("session.session_mode.timeout_ms", defaults.Session.SessionMode.TimeoutMilliseconds) | 442 | m.viper.SetDefault("session.session_mode.timeout_ms", defaults.Session.SessionMode.TimeoutMilliseconds) | ||
| 443 | m.viper.SetDefault("session.session_mode.actions", defaults.Session.SessionMode.Actions) | 443 | m.viper.SetDefault("session.session_mode.actions", defaults.Session.SessionMode.Actions) | ||
M internal/infrastructure/config/schema.go +5 -4
| @@ -437,12 +437,13 @@ type SessionConfig struct { | |||
| 437 | 437 | SnapshotIntervalMs int `mapstructure:"snapshot_interval_ms" yaml:"snapshot_interval_ms" toml:"snapshot_interval_ms" json:"snapshot_interval_ms"` //nolint:lll // struct tags must stay on one line | |
| 438 | 438 | ||
| 439 | 439 | // MaxExitedSessions is the maximum number of exited sessions to keep. | |
| 440 | - | // Default: 10 | |
| 440 | + | // Default: 50 | |
| 441 | 441 | MaxExitedSessions int `mapstructure:"max_exited_sessions" yaml:"max_exited_sessions" toml:"max_exited_sessions" json:"max_exited_sessions"` //nolint:lll // struct tags must stay on one line | |
| 442 | 442 | ||
| 443 | - | // MaxListedSessions is the maximum number of sessions to list in CLI/TUI. | |
| 444 | - | // Default: 50 | |
| 445 | - | MaxListedSessions int `mapstructure:"max_listed_sessions" yaml:"max_listed_sessions" toml:"max_listed_sessions" json:"max_listed_sessions"` //nolint:lll // struct tags must stay on one line | |
| 443 | + | // MaxExitedSessionAgeDays is the maximum age in days for exited sessions. | |
| 444 | + | // Sessions older than this will be automatically deleted on startup. | |
| 445 | + | // Default: 7 | |
| 446 | + | MaxExitedSessionAgeDays int `mapstructure:"max_exited_session_age_days" yaml:"max_exited_session_age_days" toml:"max_exited_session_age_days" json:"max_exited_session_age_days"` //nolint:lll // struct tags must stay on one line | |
| 446 | 447 | ||
| 447 | 448 | // SessionMode defines modal behavior for session management (Ctrl+O). | |
| 448 | 449 | SessionMode SessionModeConfig `mapstructure:"session_mode" yaml:"session_mode" toml:"session_mode" json:"session_mode"` | |
| @@ -437,12 +437,13 @@ type SessionConfig struct { | |||||
| 437 | SnapshotIntervalMs int `mapstructure:"snapshot_interval_ms" yaml:"snapshot_interval_ms" toml:"snapshot_interval_ms" json:"snapshot_interval_ms"` //nolint:lll // struct tags must stay on one line | 437 | SnapshotIntervalMs int `mapstructure:"snapshot_interval_ms" yaml:"snapshot_interval_ms" toml:"snapshot_interval_ms" json:"snapshot_interval_ms"` //nolint:lll // struct tags must stay on one line | ||
| 438 | 438 | ||||
| 439 | // MaxExitedSessions is the maximum number of exited sessions to keep. | 439 | // MaxExitedSessions is the maximum number of exited sessions to keep. | ||
| 440 | - | // Default: 10 | 440 | + | // Default: 50 |
| 441 | MaxExitedSessions int `mapstructure:"max_exited_sessions" yaml:"max_exited_sessions" toml:"max_exited_sessions" json:"max_exited_sessions"` //nolint:lll // struct tags must stay on one line | 441 | MaxExitedSessions int `mapstructure:"max_exited_sessions" yaml:"max_exited_sessions" toml:"max_exited_sessions" json:"max_exited_sessions"` //nolint:lll // struct tags must stay on one line | ||
| 442 | 442 | ||||
| 443 | - | // MaxListedSessions is the maximum number of sessions to list in CLI/TUI. | 443 | + | // MaxExitedSessionAgeDays is the maximum age in days for exited sessions. |
| 444 | - | // Default: 50 | 444 | + | // Sessions older than this will be automatically deleted on startup. |
| 445 | - | MaxListedSessions int `mapstructure:"max_listed_sessions" yaml:"max_listed_sessions" toml:"max_listed_sessions" json:"max_listed_sessions"` //nolint:lll // struct tags must stay on one line | 445 | + | // Default: 7 |
| 446 | + | MaxExitedSessionAgeDays int `mapstructure:"max_exited_session_age_days" yaml:"max_exited_session_age_days" toml:"max_exited_session_age_days" json:"max_exited_session_age_days"` //nolint:lll // struct tags must stay on one line | |||
| 446 | 447 | ||||
| 447 | // SessionMode defines modal behavior for session management (Ctrl+O). | 448 | // SessionMode defines modal behavior for session management (Ctrl+O). | ||
| 448 | SessionMode SessionModeConfig `mapstructure:"session_mode" yaml:"session_mode" toml:"session_mode" json:"session_mode"` | 449 | SessionMode SessionModeConfig `mapstructure:"session_mode" yaml:"session_mode" toml:"session_mode" json:"session_mode"` | ||
M internal/infrastructure/config/validation.go +15 -0
| @@ -24,6 +24,7 @@ func validateConfig(config *Config) error { | |||
| 24 | 24 | validationErrors = append(validationErrors, validateOmnibox(config)...) | |
| 25 | 25 | validationErrors = append(validationErrors, validateRendering(config)...) | |
| 26 | 26 | validationErrors = append(validationErrors, validateColorScheme(config)...) | |
| 27 | + | validationErrors = append(validationErrors, validateSession(config)...) | |
| 27 | 28 | ||
| 28 | 29 | // If there are validation errors, return them | |
| 29 | 30 | if len(validationErrors) > 0 { | |
| @@ -301,3 +302,17 @@ func validateColorScheme(config *Config) []string { | |||
| 301 | 302 | )} | |
| 302 | 303 | } | |
| 303 | 304 | } | |
| 305 | + | ||
| 306 | + | func validateSession(config *Config) []string { | |
| 307 | + | var validationErrors []string | |
| 308 | + | if config.Session.MaxExitedSessions < 0 { | |
| 309 | + | validationErrors = append(validationErrors, "session.max_exited_sessions must be non-negative") | |
| 310 | + | } | |
| 311 | + | if config.Session.MaxExitedSessionAgeDays < 0 { | |
| 312 | + | validationErrors = append(validationErrors, "session.max_exited_session_age_days must be non-negative") | |
| 313 | + | } | |
| 314 | + | if config.Session.SnapshotIntervalMs < 0 { | |
| 315 | + | validationErrors = append(validationErrors, "session.snapshot_interval_ms must be non-negative") | |
| 316 | + | } | |
| 317 | + | return validationErrors | |
| 318 | + | } | |
| @@ -24,6 +24,7 @@ func validateConfig(config *Config) error { | |||||
| 24 | validationErrors = append(validationErrors, validateOmnibox(config)...) | 24 | validationErrors = append(validationErrors, validateOmnibox(config)...) | ||
| 25 | validationErrors = append(validationErrors, validateRendering(config)...) | 25 | validationErrors = append(validationErrors, validateRendering(config)...) | ||
| 26 | validationErrors = append(validationErrors, validateColorScheme(config)...) | 26 | validationErrors = append(validationErrors, validateColorScheme(config)...) | ||
| 27 | + | validationErrors = append(validationErrors, validateSession(config)...) | |||
| 27 | 28 | ||||
| 28 | // If there are validation errors, return them | 29 | // If there are validation errors, return them | ||
| 29 | if len(validationErrors) > 0 { | 30 | if len(validationErrors) > 0 { | ||
| @@ -301,3 +302,17 @@ func validateColorScheme(config *Config) []string { | |||||
| 301 | )} | 302 | )} | ||
| 302 | } | 303 | } | ||
| 303 | } | 304 | } | ||
| 305 | + | ||||
| 306 | + | func validateSession(config *Config) []string { | |||
| 307 | + | var validationErrors []string | |||
| 308 | + | if config.Session.MaxExitedSessions < 0 { | |||
| 309 | + | validationErrors = append(validationErrors, "session.max_exited_sessions must be non-negative") | |||
| 310 | + | } | |||
| 311 | + | if config.Session.MaxExitedSessionAgeDays < 0 { | |||
| 312 | + | validationErrors = append(validationErrors, "session.max_exited_session_age_days must be non-negative") | |||
| 313 | + | } | |||
| 314 | + | if config.Session.SnapshotIntervalMs < 0 { | |||
| 315 | + | validationErrors = append(validationErrors, "session.snapshot_interval_ms must be non-negative") | |||
| 316 | + | } | |||
| 317 | + | return validationErrors | |||
| 318 | + | } | |||
M internal/infrastructure/persistence/sqlite/queries/sessions.sql +17 -0
| @@ -23,3 +23,20 @@ WHERE id = ?; | |||
| 23 | 23 | ||
| 24 | 24 | -- name: DeleteSession :exec | |
| 25 | 25 | DELETE FROM sessions WHERE id = ?; | |
| 26 | + | ||
| 27 | + | -- name: DeleteOldestExitedSessions :execrows | |
| 28 | + | -- Deletes exited browser sessions beyond the keep limit, keeping the most recent ones. | |
| 29 | + | DELETE FROM sessions | |
| 30 | + | WHERE id IN ( | |
| 31 | + | SELECT id FROM sessions | |
| 32 | + | WHERE type = 'browser' AND ended_at IS NOT NULL | |
| 33 | + | ORDER BY ended_at DESC | |
| 34 | + | LIMIT -1 OFFSET ? | |
| 35 | + | ); | |
| 36 | + | ||
| 37 | + | -- name: DeleteExitedSessionsBefore :execrows | |
| 38 | + | -- Deletes exited browser sessions older than the given cutoff time. | |
| 39 | + | DELETE FROM sessions | |
| 40 | + | WHERE type = 'browser' | |
| 41 | + | AND ended_at IS NOT NULL | |
| 42 | + | AND ended_at < ?; | |
| @@ -23,3 +23,20 @@ WHERE id = ?; | |||||
| 23 | 23 | ||||
| 24 | -- name: DeleteSession :exec | 24 | -- name: DeleteSession :exec | ||
| 25 | DELETE FROM sessions WHERE id = ?; | 25 | DELETE FROM sessions WHERE id = ?; | ||
| 26 | + | ||||
| 27 | + | -- name: DeleteOldestExitedSessions :execrows | |||
| 28 | + | -- Deletes exited browser sessions beyond the keep limit, keeping the most recent ones. | |||
| 29 | + | DELETE FROM sessions | |||
| 30 | + | WHERE id IN ( | |||
| 31 | + | SELECT id FROM sessions | |||
| 32 | + | WHERE type = 'browser' AND ended_at IS NOT NULL | |||
| 33 | + | ORDER BY ended_at DESC | |||
| 34 | + | LIMIT -1 OFFSET ? | |||
| 35 | + | ); | |||
| 36 | + | ||||
| 37 | + | -- name: DeleteExitedSessionsBefore :execrows | |||
| 38 | + | -- Deletes exited browser sessions older than the given cutoff time. | |||
| 39 | + | DELETE FROM sessions | |||
| 40 | + | WHERE type = 'browser' | |||
| 41 | + | AND ended_at IS NOT NULL | |||
| 42 | + | AND ended_at < ?; | |||
M internal/infrastructure/persistence/sqlite/session_repo.go +27 -0
| @@ -93,6 +93,33 @@ func (r *sessionRepo) Delete(ctx context.Context, id entity.SessionID) error { | |||
| 93 | 93 | return r.queries.DeleteSession(ctx, string(id)) | |
| 94 | 94 | } | |
| 95 | 95 | ||
| 96 | + | func (r *sessionRepo) DeleteOldestExited(ctx context.Context, keepCount int) (int64, error) { | |
| 97 | + | log := logging.FromContext(ctx) | |
| 98 | + | if keepCount < 0 { | |
| 99 | + | keepCount = 0 | |
| 100 | + | } | |
| 101 | + | deleted, err := r.queries.DeleteOldestExitedSessions(ctx, int64(keepCount)) | |
| 102 | + | if err != nil { | |
| 103 | + | return 0, err | |
| 104 | + | } | |
| 105 | + | if deleted > 0 { | |
| 106 | + | log.Debug().Int64("deleted", deleted).Int("keep_count", keepCount).Msg("deleted oldest exited sessions") | |
| 107 | + | } | |
| 108 | + | return deleted, nil | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func (r *sessionRepo) DeleteExitedBefore(ctx context.Context, cutoff time.Time) (int64, error) { | |
| 112 | + | log := logging.FromContext(ctx) | |
| 113 | + | deleted, err := r.queries.DeleteExitedSessionsBefore(ctx, sql.NullTime{Time: cutoff.UTC(), Valid: true}) | |
| 114 | + | if err != nil { | |
| 115 | + | return 0, err | |
| 116 | + | } | |
| 117 | + | if deleted > 0 { | |
| 118 | + | log.Debug().Int64("deleted", deleted).Time("cutoff", cutoff).Msg("deleted exited sessions before cutoff") | |
| 119 | + | } | |
| 120 | + | return deleted, nil | |
| 121 | + | } | |
| 122 | + | ||
| 96 | 123 | func sessionFromRow(row sqlc.Session) *entity.Session { | |
| 97 | 124 | var endedAt *time.Time | |
| 98 | 125 | if row.EndedAt.Valid { | |
| @@ -93,6 +93,33 @@ func (r *sessionRepo) Delete(ctx context.Context, id entity.SessionID) error { | |||||
| 93 | return r.queries.DeleteSession(ctx, string(id)) | 93 | return r.queries.DeleteSession(ctx, string(id)) | ||
| 94 | } | 94 | } | ||
| 95 | 95 | ||||
| 96 | + | func (r *sessionRepo) DeleteOldestExited(ctx context.Context, keepCount int) (int64, error) { | |||
| 97 | + | log := logging.FromContext(ctx) | |||
| 98 | + | if keepCount < 0 { | |||
| 99 | + | keepCount = 0 | |||
| 100 | + | } | |||
| 101 | + | deleted, err := r.queries.DeleteOldestExitedSessions(ctx, int64(keepCount)) | |||
| 102 | + | if err != nil { | |||
| 103 | + | return 0, err | |||
| 104 | + | } | |||
| 105 | + | if deleted > 0 { | |||
| 106 | + | log.Debug().Int64("deleted", deleted).Int("keep_count", keepCount).Msg("deleted oldest exited sessions") | |||
| 107 | + | } | |||
| 108 | + | return deleted, nil | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func (r *sessionRepo) DeleteExitedBefore(ctx context.Context, cutoff time.Time) (int64, error) { | |||
| 112 | + | log := logging.FromContext(ctx) | |||
| 113 | + | deleted, err := r.queries.DeleteExitedSessionsBefore(ctx, sql.NullTime{Time: cutoff.UTC(), Valid: true}) | |||
| 114 | + | if err != nil { | |||
| 115 | + | return 0, err | |||
| 116 | + | } | |||
| 117 | + | if deleted > 0 { | |||
| 118 | + | log.Debug().Int64("deleted", deleted).Time("cutoff", cutoff).Msg("deleted exited sessions before cutoff") | |||
| 119 | + | } | |||
| 120 | + | return deleted, nil | |||
| 121 | + | } | |||
| 122 | + | ||||
| 96 | func sessionFromRow(row sqlc.Session) *entity.Session { | 123 | func sessionFromRow(row sqlc.Session) *entity.Session { | ||
| 97 | var endedAt *time.Time | 124 | var endedAt *time.Time | ||
| 98 | if row.EndedAt.Valid { | 125 | if row.EndedAt.Valid { | ||
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +4 -0
| @@ -16,11 +16,15 @@ type Querier interface { | |||
| 16 | 16 | CreateFolder(ctx context.Context, arg CreateFolderParams) (FavoriteFolder, error) | |
| 17 | 17 | CreateTag(ctx context.Context, arg CreateTagParams) (FavoriteTag, error) | |
| 18 | 18 | DeleteAllHistory(ctx context.Context) error | |
| 19 | + | // Deletes exited browser sessions older than the given cutoff time. | |
| 20 | + | DeleteExitedSessionsBefore(ctx context.Context, endedAt sql.NullTime) (int64, error) | |
| 19 | 21 | DeleteFavorite(ctx context.Context, id int64) error | |
| 20 | 22 | DeleteFolder(ctx context.Context, id int64) error | |
| 21 | 23 | DeleteHistoryByDomain(ctx context.Context, arg DeleteHistoryByDomainParams) error | |
| 22 | 24 | DeleteHistoryByID(ctx context.Context, id int64) error | |
| 23 | 25 | DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error | |
| 26 | + | // Deletes exited browser sessions beyond the keep limit, keeping the most recent ones. | |
| 27 | + | DeleteOldestExitedSessions(ctx context.Context, offset int64) (int64, error) | |
| 24 | 28 | DeleteSession(ctx context.Context, id string) error | |
| 25 | 29 | DeleteSessionState(ctx context.Context, sessionID string) error | |
| 26 | 30 | DeleteTag(ctx context.Context, id int64) error | |
| @@ -16,11 +16,15 @@ type Querier interface { | |||||
| 16 | CreateFolder(ctx context.Context, arg CreateFolderParams) (FavoriteFolder, error) | 16 | CreateFolder(ctx context.Context, arg CreateFolderParams) (FavoriteFolder, error) | ||
| 17 | CreateTag(ctx context.Context, arg CreateTagParams) (FavoriteTag, error) | 17 | CreateTag(ctx context.Context, arg CreateTagParams) (FavoriteTag, error) | ||
| 18 | DeleteAllHistory(ctx context.Context) error | 18 | DeleteAllHistory(ctx context.Context) error | ||
| 19 | + | // Deletes exited browser sessions older than the given cutoff time. | |||
| 20 | + | DeleteExitedSessionsBefore(ctx context.Context, endedAt sql.NullTime) (int64, error) | |||
| 19 | DeleteFavorite(ctx context.Context, id int64) error | 21 | DeleteFavorite(ctx context.Context, id int64) error | ||
| 20 | DeleteFolder(ctx context.Context, id int64) error | 22 | DeleteFolder(ctx context.Context, id int64) error | ||
| 21 | DeleteHistoryByDomain(ctx context.Context, arg DeleteHistoryByDomainParams) error | 23 | DeleteHistoryByDomain(ctx context.Context, arg DeleteHistoryByDomainParams) error | ||
| 22 | DeleteHistoryByID(ctx context.Context, id int64) error | 24 | DeleteHistoryByID(ctx context.Context, id int64) error | ||
| 23 | DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error | 25 | DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error | ||
| 26 | + | // Deletes exited browser sessions beyond the keep limit, keeping the most recent ones. | |||
| 27 | + | DeleteOldestExitedSessions(ctx context.Context, offset int64) (int64, error) | |||
| 24 | DeleteSession(ctx context.Context, id string) error | 28 | DeleteSession(ctx context.Context, id string) error | ||
| 25 | DeleteSessionState(ctx context.Context, sessionID string) error | 29 | DeleteSessionState(ctx context.Context, sessionID string) error | ||
| 26 | DeleteTag(ctx context.Context, id int64) error | 30 | DeleteTag(ctx context.Context, id int64) error | ||
M internal/infrastructure/persistence/sqlite/sqlc/sessions.sql.go +35 -0
| @@ -11,6 +11,41 @@ import ( | |||
| 11 | 11 | "time" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | + | const DeleteExitedSessionsBefore = `-- name: DeleteExitedSessionsBefore :execrows | |
| 15 | + | DELETE FROM sessions | |
| 16 | + | WHERE type = 'browser' | |
| 17 | + | AND ended_at IS NOT NULL | |
| 18 | + | AND ended_at < ? | |
| 19 | + | ` | |
| 20 | + | ||
| 21 | + | // Deletes exited browser sessions older than the given cutoff time. | |
| 22 | + | func (q *Queries) DeleteExitedSessionsBefore(ctx context.Context, endedAt sql.NullTime) (int64, error) { | |
| 23 | + | result, err := q.db.ExecContext(ctx, DeleteExitedSessionsBefore, endedAt) | |
| 24 | + | if err != nil { | |
| 25 | + | return 0, err | |
| 26 | + | } | |
| 27 | + | return result.RowsAffected() | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | const DeleteOldestExitedSessions = `-- name: DeleteOldestExitedSessions :execrows | |
| 31 | + | DELETE FROM sessions | |
| 32 | + | WHERE id IN ( | |
| 33 | + | SELECT id FROM sessions | |
| 34 | + | WHERE type = 'browser' AND ended_at IS NOT NULL | |
| 35 | + | ORDER BY ended_at DESC | |
| 36 | + | LIMIT -1 OFFSET ? | |
| 37 | + | ) | |
| 38 | + | ` | |
| 39 | + | ||
| 40 | + | // Deletes exited browser sessions beyond the keep limit, keeping the most recent ones. | |
| 41 | + | func (q *Queries) DeleteOldestExitedSessions(ctx context.Context, offset int64) (int64, error) { | |
| 42 | + | result, err := q.db.ExecContext(ctx, DeleteOldestExitedSessions, offset) | |
| 43 | + | if err != nil { | |
| 44 | + | return 0, err | |
| 45 | + | } | |
| 46 | + | return result.RowsAffected() | |
| 47 | + | } | |
| 48 | + | ||
| 14 | 49 | const DeleteSession = `-- name: DeleteSession :exec | |
| 15 | 50 | DELETE FROM sessions WHERE id = ? | |
| 16 | 51 | ` | |
| @@ -11,6 +11,41 @@ import ( | |||||
| 11 | "time" | 11 | "time" | ||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | + | const DeleteExitedSessionsBefore = `-- name: DeleteExitedSessionsBefore :execrows | |||
| 15 | + | DELETE FROM sessions | |||
| 16 | + | WHERE type = 'browser' | |||
| 17 | + | AND ended_at IS NOT NULL | |||
| 18 | + | AND ended_at < ? | |||
| 19 | + | ` | |||
| 20 | + | ||||
| 21 | + | // Deletes exited browser sessions older than the given cutoff time. | |||
| 22 | + | func (q *Queries) DeleteExitedSessionsBefore(ctx context.Context, endedAt sql.NullTime) (int64, error) { | |||
| 23 | + | result, err := q.db.ExecContext(ctx, DeleteExitedSessionsBefore, endedAt) | |||
| 24 | + | if err != nil { | |||
| 25 | + | return 0, err | |||
| 26 | + | } | |||
| 27 | + | return result.RowsAffected() | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | const DeleteOldestExitedSessions = `-- name: DeleteOldestExitedSessions :execrows | |||
| 31 | + | DELETE FROM sessions | |||
| 32 | + | WHERE id IN ( | |||
| 33 | + | SELECT id FROM sessions | |||
| 34 | + | WHERE type = 'browser' AND ended_at IS NOT NULL | |||
| 35 | + | ORDER BY ended_at DESC | |||
| 36 | + | LIMIT -1 OFFSET ? | |||
| 37 | + | ) | |||
| 38 | + | ` | |||
| 39 | + | ||||
| 40 | + | // Deletes exited browser sessions beyond the keep limit, keeping the most recent ones. | |||
| 41 | + | func (q *Queries) DeleteOldestExitedSessions(ctx context.Context, offset int64) (int64, error) { | |||
| 42 | + | result, err := q.db.ExecContext(ctx, DeleteOldestExitedSessions, offset) | |||
| 43 | + | if err != nil { | |||
| 44 | + | return 0, err | |||
| 45 | + | } | |||
| 46 | + | return result.RowsAffected() | |||
| 47 | + | } | |||
| 48 | + | ||||
| 14 | const DeleteSession = `-- name: DeleteSession :exec | 49 | const DeleteSession = `-- name: DeleteSession :exec | ||
| 15 | DELETE FROM sessions WHERE id = ? | 50 | DELETE FROM sessions WHERE id = ? | ||
| 16 | ` | 51 | ` | ||