Implementing a countdown timer
When working with time-based operations in Go, the time
package offers powerful functionality that can simplify complex timing requirements. Let's look at how to implement a practical countdown timer.
A countdown timer is essential for scenarios ranging from limiting the duration of operations to creating time-bound challenges in applications. Here's how you can implement one using Go's time package:
package main
import (
"fmt"
"time"
)
func countdownTimer(duration time.Duration) {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
done := make(chan bool)
go func() {
time.Sleep(duration)
done <- true
}()
remaining := duration
fmt.Printf("Countdown started: %s remaining\n", remaining.Round(time.Second))
for {
select {
case <-done:
fmt.Println("Countdown finished!")
return
case <-ticker.C:
remaining -= time.Second
fmt.Printf("Time remaining: %s\n", remaining.Round(time.Second))
}
}
}
func main() {
// 10-second countdown
countdownTimer(10 * time.Second)
}
This implementation leverages goroutines and channels, two powerful Go features that make concurrent operations clean and straightforward. The countdown function uses a ticker to update and display the remaining time every second, while a separate goroutine monitors when the total duration has elapsed.
For more complex scenarios, you might want to add the ability to pause, resume, or cancel the countdown. Here's how you can extend the implementation:
package main
import (
"fmt"
"time"
)
type CountdownTimer struct {
duration time.Duration
remaining time.Duration
ticker *time.Ticker
pauseCh chan bool
resumeCh chan bool
stopCh chan bool
isPaused bool
}
func NewCountdownTimer(duration time.Duration) *CountdownTimer {
return &CountdownTimer{
duration: duration,
remaining: duration,
pauseCh: make(chan bool),
resumeCh: make(chan bool),
stopCh: make(chan bool),
isPaused: false,
}
}
func (c *CountdownTimer) Start() {
c.ticker = time.NewTicker(1 * time.Second)
go func() {
for {
select {
case <-c.ticker.C:
if !c.isPaused {
c.remaining -= time.Second
fmt.Printf("Time remaining: %s\n", c.remaining.Round(time.Second))
if c.remaining <= 0 {
fmt.Println("Countdown finished!")
c.ticker.Stop()
return
}
}
case <-c.pauseCh:
c.isPaused = true
fmt.Println("Countdown paused")
case <-c.resumeCh:
c.isPaused = false
fmt.Println("Countdown resumed")
case <-c.stopCh:
fmt.Println("Countdown stopped")
c.ticker.Stop()
return
}
}
}()
}
func (c *CountdownTimer) Pause() {
c.pauseCh <- true
}
func (c *CountdownTimer) Resume() {
c.resumeCh <- true
}
func (c *CountdownTimer) Stop() {
c.stopCh <- true
}
func main() {
timer := NewCountdownTimer(10 * time.Second)
timer.Start()
// Simulate pausing after 3 seconds
time.Sleep(3 * time.Second)
timer.Pause()
// Simulate resuming after 2 seconds
time.Sleep(2 * time.Second)
timer.Resume()
// Let it run to completion
time.Sleep(10 * time.Second)
}
This enhanced implementation shows how to build a more flexible countdown timer with control mechanisms. The struct-based approach encapsulates the timer's state and behaviors, making it easier to integrate into larger applications.
A few key points about this countdown timer implementation:
- We use Go's duration types (
time.Duration
) to handle time calculations cleanly - The select statement provides non-blocking channel operations, perfect for handling multiple signals
- The ticker ensures regular updates at precise intervals
- Goroutines allow the timer to run concurrently with other code
This pattern can be adapted for many time-sensitive applications, from cooking timers to rate limiting and timeout handling in network applications.
Building a task scheduler using time.Ticker
Scheduling recurring tasks is a common requirement in many applications, from periodic data processing to regular health checks. Go's time.Ticker
provides an elegant solution for implementing task schedulers with precise timing.
Unlike a simple countdown timer, a task scheduler needs to execute actions at regular intervals, potentially indefinitely. Here's how to build a basic task scheduler using Go's time package:
package main
import (
"fmt"
"sync"
"time"
)
type Task struct {
ID string
Interval time.Duration
Action func()
}
type Scheduler struct {
tasks map[string]*Task
stop map[string]chan bool
mutex sync.RWMutex
}
func NewScheduler() *Scheduler {
return &Scheduler{
tasks: make(map[string]*Task),
stop: make(map[string]chan bool),
}
}
func (s *Scheduler) AddTask(task *Task) {
s.mutex.Lock()
defer s.mutex.Unlock()
s.tasks[task.ID] = task
s.stop[task.ID] = make(chan bool)
go func(t *Task, stop chan bool) {
ticker := time.NewTicker(t.Interval)
defer ticker.Stop()
// Execute immediately on start
t.Action()
for {
select {
case <-ticker.C:
t.Action()
case <-stop:
fmt.Printf("Task %s stopped\n", t.ID)
return
}
}
}(task, s.stop[task.ID])
fmt.Printf("Task %s added with interval %s\n", task.ID, task.Interval)
}
func (s *Scheduler) RemoveTask(id string) bool {
s.mutex.Lock()
defer s.mutex.Unlock()
if stopCh, exists := s.stop[id]; exists {
stopCh <- true
delete(s.stop, id)
delete(s.tasks, id)
return true
}
return false
}
func (s *Scheduler) GetTasks() []*Task {
s.mutex.RLock()
defer s.mutex.RUnlock()
taskList := make([]*Task, 0, len(s.tasks))
for _, task := range s.tasks {
taskList = append(taskList, task)
}
return taskList
}
func main() {
scheduler := NewScheduler()
// Add a task that runs every 2 seconds
scheduler.AddTask(&Task{
ID: "healthcheck",
Interval: 2 * time.Second,
Action: func() {
fmt.Printf("[%s] Running health check...\n", time.Now().Format(time.RFC3339))
},
})
// Add a task that runs every 5 seconds
scheduler.AddTask(&Task{
ID: "cleanup",
Interval: 5 * time.Second,
Action: func() {
fmt.Printf("[%s] Performing cleanup...\n", time.Now().Format(time.RFC3339))
},
})
// Let tasks run for 12 seconds
time.Sleep(12 * time.Second)
// Remove the health check task
scheduler.RemoveTask("healthcheck")
// Let the remaining task run for a bit longer
time.Sleep(6 * time.Second)
}
This scheduler implementation demonstrates several important Go patterns:
- Encapsulation: The scheduler encapsulates the complexities of managing multiple concurrent tasks.
- Concurrency: Each task runs in its own goroutine, allowing tasks to execute independently.
- Synchronization: Mutex locks prevent race conditions when modifying the task collection.
- Resource Management: Proper cleanup occurs when tasks are removed.
For real-world applications, you might want to enhance this scheduler with features like:
// Add to the Scheduler struct
type Scheduler struct {
// existing fields...
taskStats map[string]TaskStats
}
type TaskStats struct {
LastRun time.Time
ExecutionCount int
AverageRuntime time.Duration
TotalRuntime time.Duration
}
// Then modify the AddTask method to track stats
func (s *Scheduler) AddTask(task *Task) {
// existing code...
go func(t *Task, stop chan bool) {
ticker := time.NewTicker(t.Interval)
defer ticker.Stop()
s.taskStats[t.ID] = TaskStats{}
// Execute immediately on start
startTime := time.Now()
t.Action()
runTime := time.Since(startTime)
s.mutex.Lock()
stats := s.taskStats[t.ID]
stats.LastRun = startTime
stats.ExecutionCount = 1
stats.AverageRuntime = runTime
stats.TotalRuntime = runTime
s.taskStats[t.ID] = stats
s.mutex.Unlock()
for {
select {
case <-ticker.C:
startTime := time.Now()
t.Action()
runTime := time.Since(startTime)
s.mutex.Lock()
stats := s.taskStats[t.ID]
stats.LastRun = startTime
stats.ExecutionCount++
stats.TotalRuntime += runTime
stats.AverageRuntime = stats.TotalRuntime / time.Duration(stats.ExecutionCount)
s.taskStats[t.ID] = stats
s.mutex.Unlock()
case <-stop:
fmt.Printf("Task %s stopped\n", t.ID)
return
}
}
}(task, s.stop[task.ID])
}
When implementing a scheduler in production systems, consider these additional points:
- Jitter: For distributed systems, add slight randomness to intervals to prevent thundering herd problems.
- Error Handling: Wrap task executions in recovery blocks to prevent a failing task from crashing the scheduler.
- Persistence: For critical tasks, you might want to persist task schedules to restart them after application restarts.
- Dynamic Intervals: Allow tasks to change their execution intervals at runtime based on conditions.
The time.Ticker
approach works well for most scheduling needs, but for more complex scenarios, you might consider using a cron-like library that supports calendar-based scheduling.
Converting and displaying timestamps in human-readable formats
Working with timestamps is a common requirement in Go applications, whether you're logging events, displaying user activity, or managing time-sensitive data. Go's time package provides robust tools for formatting and parsing timestamps in various formats.
Parsing Time Strings
Let's start with parsing time strings into Go's time.Time
objects:
package main
import (
"fmt"
"time"
)
func main() {
// Parse a timestamp from a standard format
timestamp, err := time.Parse(time.RFC3339, "2023-10-15T14:30:45Z")
if err != nil {
fmt.Println("Error parsing time:", err)
return
}
fmt.Println("Parsed time:", timestamp)
// Parse a custom format
// The reference time is: Mon Jan 2 15:04:05 MST 2006
customTime, err := time.Parse("2006-01-02 15:04:05", "2023-10-15 14:30:45")
if err != nil {
fmt.Println("Error parsing custom time:", err)
return
}
fmt.Println("Custom parsed time:", customTime)
// Parse time with timezone
loc, err := time.LoadLocation("America/New_York")
if err != nil {
fmt.Println("Error loading location:", err)
return
}
nyTime, err := time.ParseInLocation("2006-01-02 15:04:05", "2023-10-15 14:30:45", loc)
if err != nil {
fmt.Println("Error parsing time with location:", err)
return
}
fmt.Println("New York time:", nyTime)
}
Formatting Time Objects
Once you have a time.Time
object, you can format it in various human-readable ways:
package main
import (
"fmt"
"time"
)
func main() {
// Get current time
now := time.Now()
// Standard formats
fmt.Println("RFC3339:", now.Format(time.RFC3339))
fmt.Println("RFC822:", now.Format(time.RFC822))
fmt.Println("Kitchen time:", now.Format(time.Kitchen))
// Custom formats
fmt.Println("Custom date:", now.Format("Monday, January 2, 2006"))
fmt.Println("Short date:", now.Format("2006-01-02"))
fmt.Println("Custom time:", now.Format("15:04:05"))
fmt.Println("With timezone:", now.Format("2006-01-02 15:04:05 MST"))
// Format with different timezone
loc, _ := time.LoadLocation("Europe/Paris")
parisTime := now.In(loc)
fmt.Println("Paris time:", parisTime.Format("2006-01-02 15:04:05 MST"))
}
Creating User-Friendly Relative Times
Often, displaying relative times like "5 minutes ago" or "2 days ago" provides a better user experience:
package main
import (
"fmt"
"math"
"time"
)
func timeAgo(t time.Time) string {
now := time.Now()
duration := now.Sub(t)
seconds := int(duration.Seconds())
minutes := int(duration.Minutes())
hours := int(duration.Hours())
days := int(hours / 24)
if seconds < 60 {
return fmt.Sprintf("%d seconds ago", seconds)
} else if minutes < 60 {
return fmt.Sprintf("%d minutes ago", minutes)
} else if hours < 24 {
return fmt.Sprintf("%d hours ago", hours)
} else if days < 30 {
return fmt.Sprintf("%d days ago", days)
} else if days < 365 {
months := int(math.Floor(float64(days) / 30))
return fmt.Sprintf("%d months ago", months)
}
years := int(math.Floor(float64(days) / 365))
return fmt.Sprintf("%d years ago", years)
}
func main() {
times := []time.Time{
time.Now().Add(-30 * time.Second),
time.Now().Add(-10 * time.Minute),
time.Now().Add(-5 * time.Hour),
time.Now().Add(-3 * 24 * time.Hour),
time.Now().Add(-60 * 24 * time.Hour),
time.Now().Add(-400 * 24 * time.Hour),
}
for _, t := range times {
fmt.Printf("Time: %s -> %s\n",
t.Format(time.RFC3339),
timeAgo(t))
}
}
Working with Different Time Zones
Time zone handling is often tricky but essential for global applications:
package main
import (
"fmt"
"time"
)
func displayTimeInDifferentTimezones(t time.Time) {
locations := []string{
"UTC",
"America/New_York",
"Europe/London",
"Asia/Tokyo",
"Australia/Sydney",
}
fmt.Printf("Original time: %s\n", t.Format(time.RFC3339))
for _, locName := range locations {
loc, err := time.LoadLocation(locName)
if err != nil {
fmt.Printf("Error loading location %s: %v\n", locName, err)
continue
}
localTime := t.In(loc)
fmt.Printf("%15s: %s\n", locName, localTime.Format("2006-01-02 15:04:05 MST"))
}
}
func main() {
// Current time
now := time.Now()
displayTimeInDifferentTimezones(now)
// Meeting time
meetingTime := time.Date(2023, 10, 20, 15, 0, 0, 0, time.UTC)
fmt.Println("\nMeeting time in different timezones:")
displayTimeInDifferentTimezones(meetingTime)
}
Practical Example: Formatting Database Timestamps
When retrieving timestamps from databases, you often need to convert them to appropriate formats:
package main
import (
"fmt"
"time"
)
// Simulate a database record
type UserActivity struct {
UserID int
ActivityType string
Timestamp time.Time
}
func formatActivityTimestamp(activity UserActivity) string {
now := time.Now()
activityTime := activity.Timestamp
// If it happened today, show the time
if now.Year() == activityTime.Year() &&
now.Month() == activityTime.Month() &&
now.Day() == activityTime.Day() {
return fmt.Sprintf("Today at %s", activityTime.Format("3:04 PM"))
}
// If it happened yesterday, say "Yesterday"
yesterday := now.AddDate(0, 0, -1)
if yesterday.Year() == activityTime.Year() &&
yesterday.Month() == activityTime.Month() &&
yesterday.Day() == activityTime.Day() {
return fmt.Sprintf("Yesterday at %s", activityTime.Format("3:04 PM"))
}
// If it happened this year, show month and day
if now.Year() == activityTime.Year() {
return activityTime.Format("Jan 2 at 3:04 PM")
}
// Otherwise show the full date
return activityTime.Format("Jan 2, 2006 at 3:04 PM")
}
func main() {
activities := []UserActivity{
{1, "login", time.Now()},
{2, "purchase", time.Now().Add(-24 * time.Hour)},
{3, "signup", time.Now().Add(-5 * 24 * time.Hour)},
{4, "login", time.Now().AddDate(-1, 0, 0)},
}
for _, activity := range activities {
fmt.Printf("User %d %s %s\n",
activity.UserID,
activity.ActivityType,
formatActivityTimestamp(activity))
}
}
These examples demonstrate the versatility of Go's time package for parsing, formatting, and displaying timestamps in human-readable formats. By leveraging these functions, you can create more intuitive time representations in your applications, enhancing user experience and making temporal data more accessible.
Logging and timestamping events correctly
Proper logging and timestamping are essential for debugging, monitoring, and auditing applications. Go's time package provides the necessary tools to ensure accurate and consistent timestamps in your log entries.
Basic Event Logging with Timestamps
Let's start with a simple logging utility that includes proper timestamps:
package main
import (
"fmt"
"log"
"os"
"time"
)
// LogLevel represents the severity of a log entry
type LogLevel int
const (
DEBUG LogLevel = iota
INFO
WARNING
ERROR
FATAL
)
// String representation of log levels
func (l LogLevel) String() string {
return [...]string{"DEBUG", "INFO", "WARNING", "ERROR", "FATAL"}[l]
}
// Logger is a simple logging utility with timestamps
type Logger struct {
level LogLevel
logger *log.Logger
}
// NewLogger creates a new Logger instance
func NewLogger(level LogLevel) *Logger {
return &Logger{
level: level,
logger: log.New(os.Stdout, "", 0), // No prefix or flags, we'll format ourselves
}
}
// Log logs a message with the specified level
func (l *Logger) Log(level LogLevel, format string, args ...interface{}) {
if level < l.level {
return // Skip messages below the configured level
}
// Get current time with microsecond precision
now := time.Now()
timestamp := now.Format("2006-01-02T15:04:05.000000Z07:00")
// Format the message
message := fmt.Sprintf(format, args...)
// Log the timestamped message
l.logger.Printf("[%s] [%s] %s", timestamp, level, message)
}
// Convenience methods for different log levels
func (l *Logger) Debug(format string, args ...interface{}) {
l.Log(DEBUG, format, args...)
}
func (l *Logger) Info(format string, args ...interface{}) {
l.Log(INFO, format, args...)
}
func (l *Logger) Warning(format string, args ...interface{}) {
l.Log(WARNING, format, args...)
}
func (l *Logger) Error(format string, args ...interface{}) {
l.Log(ERROR, format, args...)
}
func (l *Logger) Fatal(format string, args ...interface{}) {
l.Log(FATAL, format, args...)
os.Exit(1)
}
func main() {
logger := NewLogger(DEBUG)
// Simulate application events
logger.Info("Application started")
// Simulate processing with timing
start := time.Now()
time.Sleep(100 * time.Millisecond) // Simulate work
logger.Debug("Processing took %v", time.Since(start))
// Log various events
logger.Info("User login successful: user_id=%d", 1234)
logger.Warning("Database connection pool reaching limit: %d/%d", 8, 10)
logger.Error("Failed to process payment: %s", "insufficient funds")
// Don't actually call Fatal in this example
// logger.Fatal("Unrecoverable error: %s", "database connection lost")
}
Logging Across Time Zones
For distributed systems or applications serving global users, handling time zones correctly is crucial:
package main
import (
"fmt"
"log"
"os"
"time"
)
// TimeFormat defines different timestamp formats for logs
type TimeFormat int
const (
UTC TimeFormat = iota
Local
ISO8601
RFC3339Nano
)
type TimestampedLogger struct {
logger *log.Logger
format TimeFormat
loc *time.Location
}
func NewTimestampedLogger(format TimeFormat, location *time.Location) *TimestampedLogger {
return &TimestampedLogger{
logger: log.New(os.Stdout, "", 0),
format: format,
loc: location,
}
}
func (t *TimestampedLogger) formatTime(tm time.Time) string {
// Convert to the desired time zone
zonedTime := tm
if t.loc != nil {
zonedTime = tm.In(t.loc)
}
// Format according to the specified format
switch t.format {
case UTC:
return zonedTime.In(time.UTC).Format("2006-01-02 15:04:05.000000 UTC")
case Local:
return zonedTime.Format("2006-01-02 15:04:05.000000 MST")
case ISO8601:
return zonedTime.Format("2006-01-02T15:04:05.000000-07:00")
case RFC3339Nano:
return zonedTime.Format(time.RFC3339Nano)
default:
return zonedTime.Format(time.RFC3339)
}
}
func (t *TimestampedLogger) Log(message string) {
timestamp := t.formatTime(time.Now())
t.logger.Printf("[%s] %s", timestamp, message)
}
func main() {
// Create loggers with different time formats
utcLogger := NewTimestampedLogger(UTC, nil)
localLogger := NewTimestampedLogger(Local, nil)
// Load Tokyo time zone
tokyo, _ := time.LoadLocation("Asia/Tokyo")
tokyoLogger := NewTimestampedLogger(ISO8601, tokyo)
// Log the same event with different time formats
utcLogger.Log("Event logged in UTC time")
localLogger.Log("Same event logged in local time")
tokyoLogger.Log("Same event logged in Tokyo time")
}
Creating a Structured Logger with Performance Metrics
For production systems, structured logging with performance metrics can be invaluable:
package main
import (
"encoding/json"
"fmt"
"os"
"runtime"
"time"
)
type LogEntry struct {
Timestamp string `json:"timestamp"`
Level string `json:"level"`
Message string `json:"message"`
Context map[string]interface{} `json:"context,omitempty"`
Performance *PerformanceMetrics `json:"performance,omitempty"`
}
type PerformanceMetrics struct {
Duration string `json:"duration,omitempty"`
MemoryUsage string `json:"memory_usage,omitempty"`
GoRoutines int `json:"goroutines,omitempty"`
}
type StructuredLogger struct {
AppName string
}
func NewStructuredLogger(appName string) *StructuredLogger {
return &StructuredLogger{AppName: appName}
}
func (s *StructuredLogger) Log(level, message string, context map[string]interface{}, includePerf bool) {
entry := LogEntry{
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
Level: level,
Message: message,
Context: context,
}
// Add app name to context
if entry.Context == nil {
entry.Context = make(map[string]interface{})
}
entry.Context["app"] = s.AppName
// Include performance metrics if requested
if includePerf {
var mem runtime.MemStats
runtime.ReadMemStats(&mem)
entry.Performance = &PerformanceMetrics{
GoRoutines: runtime.NumGoroutine(),
MemoryUsage: fmt.Sprintf("%.2f MB", float64(mem.Alloc)/1024/1024),
}
}
// Marshal to JSON and write to stdout
jsonData, _ := json.Marshal(entry)
fmt.Fprintln(os.Stdout, string(jsonData))
}
func (s *StructuredLogger) LogWithDuration(level, message string, context map[string]interface{}, start time.Time) {
duration := time.Since(start)
if context == nil {
context = make(map[string]interface{})
}
// Include performance metrics with duration
var mem runtime.MemStats
runtime.ReadMemStats(&mem)
entry := LogEntry{
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
Level: level,
Message: message,
Context: context,
Performance: &PerformanceMetrics{
Duration: duration.String(),
GoRoutines: runtime.NumGoroutine(),
MemoryUsage: fmt.Sprintf("%.2f MB", float64(mem.Alloc)/1024/1024),
},
}
// Add app name to context
entry.Context["app"] = s.AppName
// Marshal to JSON and write to stdout
jsonData, _ := json.Marshal(entry)
fmt.Fprintln(os.Stdout, string(jsonData))
}
func main() {
logger := NewStructuredLogger("demo-app")
// Simple log
logger.Log("INFO", "Application started", nil, false)
// Log with context
userContext := map[string]interface{}{
"user_id": 12345,
"action": "login",
"source": "api",
}
logger.Log("INFO", "User logged in", userContext, true)
// Measure operation duration
start := time.Now()
time.Sleep(150 * time.Millisecond) // Simulate work
processContext := map[string]interface{}{
"items_processed": 1000,
"batch_id": "batch-2023-10",
}
logger.LogWithDuration("INFO", "Batch processing completed", processContext, start)
}
Best Practices for Logging and Timestamping
When implementing logging in Go applications, follow these best practices:
- Use UTC for server-side logs: This ensures consistency across distributed services and avoids daylight saving time complications.
// Always log in UTC for backend services
timestamp := time.Now().UTC().Format(time.RFC3339Nano)
- Include timezone information in timestamps: This helps avoid ambiguity.
// Include timezone offset in logs
timestamp := time.Now().Format("2006-01-02T15:04:05-07:00")
- Maintain precision: Include milliseconds or microseconds for performance-sensitive applications.
// High precision timestamp for performance logging
timestamp := time.Now().Format("2006-01-02T15:04:05.000000Z07:00")
- Be consistent: Use the same timestamp format across your application.
// Define a constant for your timestamp format
const TimeFormat = "2006-01-02T15:04:05.000Z07:00"
// Use it consistently
timestamp := time.Now().Format(TimeFormat)
- Context is key: Include relevant context with your timestamps, such as request IDs.
type LogContext struct {
Timestamp string `json:"timestamp"`
RequestID string `json:"request_id"`
UserID int `json:"user_id,omitempty"`
// other fields...
}
By implementing these patterns and best practices, you can create a robust logging system that accurately timestamps events, facilitating debugging, monitoring, and auditing of your Go applications.
Managing time-based expiration and caching
Time-based expiration and caching mechanisms are essential components of many applications, from API rate limiting to session management. Go's time package provides the tools needed to implement effective caching strategies with precise expiration control.
Building a Simple In-Memory Cache with Expiration
Let's start with a basic in-memory cache that automatically expires items after a specified duration:
package main
import (
"fmt"
"sync"
"time"
)
// CacheItem represents an item in the cache with expiration time
type CacheItem struct {
Value interface{}
Expiration time.Time
}
// Cache is a simple in-memory cache with expiration
type Cache struct {
items map[string]CacheItem
mu sync.RWMutex
}
// NewCache creates a new cache with automatic cleanup
func NewCache(cleanupInterval time.Duration) *Cache {
cache := &Cache{
items: make(map[string]CacheItem),
}
// Start the cleanup routine if a positive interval is provided
if cleanupInterval > 0 {
go cache.startCleanupTimer(cleanupInterval)
}
return cache
}
// Set adds an item to the cache with a specified expiration duration
func (c *Cache) Set(key string, value interface{}, duration time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
var expiration time.Time
if duration > 0 {
expiration = time.Now().Add(duration)
}
c.items[key] = CacheItem{
Value: value,
Expiration: expiration,
}
}
// Get retrieves an item from the cache
// Returns the item and a bool indicating if the item was found
func (c *Cache) Get(key string) (interface{}, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
item, found := c.items[key]
if !found {
return nil, false
}
// Check if the item has expired
if !item.Expiration.IsZero() && time.Now().After(item.Expiration) {
return nil, false
}
return item.Value, true
}
// Delete removes an item from the cache
func (c *Cache) Delete(key string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.items, key)
}
// startCleanupTimer starts a timer to periodically clean up expired items
func (c *Cache) startCleanupTimer(interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
c.cleanup()
}
}
}
// cleanup removes expired items from the cache
func (c *Cache) cleanup() {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
for key, item := range c.items {
if !item.Expiration.IsZero() && now.After(item.Expiration) {
delete(c.items, key)
}
}
}
func main() {
// Create a cache with cleanup every 5 seconds
cache := NewCache(5 * time.Second)
// Add items with different expiration times
cache.Set("short-lived", "I expire quickly", 2*time.Second)
cache.Set("long-lived", "I stay around longer", 10*time.Second)
cache.Set("immortal", "I live forever", 0) // 0 means no expiration
// Check initial values
fmt.Println("--- Initial state ---")
printCacheItem(cache, "short-lived")
printCacheItem(cache, "long-lived")
printCacheItem(cache, "immortal")
// Wait for the short-lived item to expire
fmt.Println("\n--- After 3 seconds ---")
time.Sleep(3 * time.Second)
printCacheItem(cache, "short-lived") // Should be expired
printCacheItem(cache, "long-lived") // Should still exist
printCacheItem(cache, "immortal") // Should still exist
// Wait for the long-lived item to expire
fmt.Println("\n--- After 8 more seconds ---")
time.Sleep(8 * time.Second)
printCacheItem(cache, "short-lived") // Should be expired
printCacheItem(cache, "long-lived") // Should be expired
printCacheItem(cache, "immortal") // Should still exist
}
func printCacheItem(cache *Cache, key string) {
value, found := cache.Get(key)
if found {
fmt.Printf("Key: %s, Value: %v\n", key, value)
} else {
fmt.Printf("Key: %s not found or expired\n", key)
}
}
Implementing Cache with Sliding Expiration
For many applications, it's useful to implement a sliding expiration strategy, where the expiration time is reset on each access:
package main
import (
"fmt"
"sync"
"time"
)
// SlidingCacheItem represents an item with sliding expiration
type SlidingCacheItem struct {
Value interface{}
Duration time.Duration // Store the original duration
Expiration time.Time
}
// SlidingCache implements a cache with sliding expiration
type SlidingCache struct {
items map[string]SlidingCacheItem
mu sync.RWMutex
}
// NewSlidingCache creates a new cache with sliding expiration
func NewSlidingCache(cleanupInterval time.Duration) *SlidingCache {
cache := &SlidingCache{
items: make(map[string]SlidingCacheItem),
}
// Start the cleanup routine if a positive interval is provided
if cleanupInterval > 0 {
go cache.startCleanupTimer(cleanupInterval)
}
return cache
}
// Set adds an item to the cache with the specified duration
func (c *SlidingCache) Set(key string, value interface{}, duration time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
expiration := time.Now().Add(duration)
c.items[key] = SlidingCacheItem{
Value: value,
Duration: duration,
Expiration: expiration,
}
}
// Get retrieves an item and resets its expiration time
func (c *SlidingCache) Get(key string) (interface{}, bool) {
c.mu.Lock()
defer c.mu.Unlock()
item, found := c.items[key]
if !found {
return nil, false
}
// Check if the item has expired
if time.Now().After(item.Expiration) {
delete(c.items, key)
return nil, false
}
// Reset expiration time (sliding window)
if item.Duration > 0 {
item.Expiration = time.Now().Add(item.Duration)
c.items[key] = item
}
return item.Value, true
}
// Rest of the implementation (Delete, cleanup, etc.) is similar to the basic cache
// ...
func (c *SlidingCache) startCleanupTimer(interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
c.cleanup()
}
}
}
func (c *SlidingCache) cleanup() {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
for key, item := range c.items {
if now.After(item.Expiration) {
delete(c.items, key)
}
}
}
func main() {
// Create a sliding cache with cleanup every 1 second
cache := NewSlidingCache(1 * time.Second)
// Add an item with 5-second expiration
cache.Set("session", "user-123", 5*time.Second)
fmt.Println("Added session to cache at", time.Now().Format("15:04:05"))
// Wait 3 seconds and access the item
time.Sleep(3 * time.Second)
if value, found := cache.Get("session"); found {
fmt.Println("At", time.Now().Format("15:04:05"), "- Found session:", value)
fmt.Println("Expiration extended by 5 seconds")
}
// Wait 3 more seconds - the item should still be there
time.Sleep(3 * time.Second)
if value, found := cache.Get("session"); found {
fmt.Println("At", time.Now().Format("15:04:05"), "- Found session:", value)
fmt.Println("Expiration extended again by 5 seconds")
} else {
fmt.Println("At", time.Now().Format("15:04:05"), "- Session expired")
}
// Wait 6 seconds without accessing - should expire
time.Sleep(6 * time.Second)
if _, found := cache.Get("session"); found {
fmt.Println("At", time.Now().Format("15:04:05"), "- Session still active")
} else {
fmt.Println("At", time.Now().Format("15:04:05"), "- Session expired")
}
}
TTL Map for API Rate Limiting
A common use case for time-based expiration is API rate limiting. Here's a simple implementation:
package main
import (
"fmt"
"sync"
"time"
)
// RateLimiter implements a simple token bucket rate limiter
type RateLimiter struct {
limits map[string]Limit
mu sync.RWMutex
cleanupInt time.Duration
}
// Limit represents rate limit information for a key
type Limit struct {
Count int // Current count of requests
ResetAt time.Time // When the limit resets
MaxCount int // Maximum allowed requests per window
WindowSize time.Duration // Time window for rate limiting
}
// NewRateLimiter creates a new rate limiter with the specified cleanup interval
func NewRateLimiter(cleanupInterval time.Duration) *RateLimiter {
limiter := &RateLimiter{
limits: make(map[string]Limit),
cleanupInt: cleanupInterval,
}
// Start background cleanup
go limiter.startCleanup()
return limiter
}
// IsAllowed checks if a request is allowed for the given key
func (rl *RateLimiter) IsAllowed(key string, maxCount int, windowSize time.Duration) (bool, Limit) {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
// Get existing limit or create a new one
limit, exists := rl.limits[key]
if !exists || now.After(limit.ResetAt) {
// Create a new limit
limit = Limit{
Count: 1, // This request counts as the first
ResetAt: now.Add(windowSize),
MaxCount: maxCount,
WindowSize: windowSize,
}
rl.limits[key] = limit
return true, limit
}
// Increment counter and check if allowed
if limit.Count < limit.MaxCount {
limit.Count++
rl.limits[key] = limit
return true, limit
}
return false, limit
}
// GetRateLimit returns the current rate limit for a key
func (rl *RateLimiter) GetRateLimit(key string) (Limit, bool) {
rl.mu.RLock()
defer rl.mu.RUnlock()
limit, exists := rl.limits[key]
return limit, exists
}
// startCleanup periodically removes expired rate limits
func (rl *RateLimiter) startCleanup() {
ticker := time.NewTicker(rl.cleanupInt)
defer ticker.Stop()
for {
select {
case <-ticker.C:
rl.cleanup()
}
}
}
// cleanup removes expired rate limits
func (rl *RateLimiter) cleanup() {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
for key, limit := range rl.limits {
if now.After(limit.ResetAt) {
delete(rl.limits, key)
}
}
}
func main() {
// Create a rate limiter with cleanup every 5 seconds
limiter := NewRateLimiter(5 * time.Second)
// Simulate API requests for user1
userKey := "user1"
// Allow 5 requests per 10 seconds
maxRequests := 5
windowSize := 10 * time.Second
fmt.Println("Simulating API requests for", userKey)
// Make 7 requests (exceeding the limit)
for i := 1; i <= 7; i++ {
allowed, limit := limiter.IsAllowed(userKey, maxRequests, windowSize)
fmt.Printf("Request %d: Allowed=%v, Count=%d/%d, Reset in %v\n",
i, allowed, limit.Count, limit.MaxCount,
time.Until(limit.ResetAt).Round(time.Second))
time.Sleep(1 * time.Second)
}
// Wait for rate limit to reset
fmt.Println("\nWaiting for rate limit to reset...")
time.Sleep(10 * time.Second)
// Make another request after reset
allowed, limit := limiter.IsAllowed(userKey, maxRequests, windowSize)
fmt.Printf("After reset: Allowed=%v, Count=%d/%d, Reset in %v\n",
allowed, limit.Count, limit.MaxCount,
time.Until(limit.ResetAt).Round(time.Second))
}
Advanced Caching with Time-Based Eviction Policies
For production systems, you might need more sophisticated caching strategies:
package main
import (
"fmt"
"sync"
"time"
)
// EvictionPolicy defines how items are evicted from the cache
type EvictionPolicy int
const (
LRU EvictionPolicy = iota // Least Recently Used
LFU // Least Frequently Used
FIFO // First In First Out
)
// AdvancedCacheItem represents an item in the advanced cache
type AdvancedCacheItem struct {
Key string
Value interface{}
Expiration time.Time
Created time.Time
LastAccess time.Time
AccessCount int
}
// AdvancedCache implements a cache with multiple eviction policies
type AdvancedCache struct {
items map[string]*AdvancedCacheItem
mu sync.RWMutex
maxItems int
policy EvictionPolicy
itemsList []*AdvancedCacheItem // For FIFO ordering
}
// NewAdvancedCache creates a new advanced cache
func NewAdvancedCache(maxItems int, policy EvictionPolicy, cleanupInterval time.Duration) *AdvancedCache {
cache := &AdvancedCache{
items: make(map[string]*AdvancedCacheItem),
maxItems: maxItems,
policy: policy,
itemsList: make([]*AdvancedCacheItem, 0, maxItems),
}
// Start the cleanup routine
go cache.startCleanupTimer(cleanupInterval)
return cache
}
// Set adds or updates an item in the cache
func (c *AdvancedCache) Set(key string, value interface{}, duration time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
var expiration time.Time
if duration > 0 {
expiration = now.Add(duration)
}
// Check if the item already exists
if item, found := c.items[key]; found {
item.Value = value
item.Expiration = expiration
item.LastAccess = now
item.AccessCount++
return
}
// Create a new item
item := &AdvancedCacheItem{
Key: key,
Value: value,
Expiration: expiration,
Created: now,
LastAccess: now,
AccessCount: 1,
}
// Check if we need to evict an item
if len(c.items) >= c.maxItems {
c.evict()
}
// Add the new item
c.items[key] = item
c.itemsList = append(c.itemsList, item)
}
// Get retrieves an item from the cache
func (c *AdvancedCache) Get(key string) (interface{}, bool) {
c.mu.Lock()
defer c.mu.Unlock()
item, found := c.items[key]
if !found {
return nil, false
}
// Check if the item has expired
if !item.Expiration.IsZero() && time.Now().After(item.Expiration) {
c.deleteItem(key)
return nil, false
}
// Update access metrics
item.LastAccess = time.Now()
item.AccessCount++
return item.Value, true
}
// deleteItem removes an item from the cache (must be called with lock held)
func (c *AdvancedCache) deleteItem(key string) {
delete(c.items, key)
// Remove from items list (for FIFO)
for i, item := range c.itemsList {
if item.Key == key {
c.itemsList = append(c.itemsList[:i], c.itemsList[i+1:]...)
break
}
}
}
// evict removes an item based on the eviction policy
func (c *AdvancedCache) evict() {
if len(c.items) == 0 {
return
}
var keyToEvict string
switch c.policy {
case LRU:
// Find least recently used item
var oldest time.Time
first := true
for k, item := range c.items {
if first || item.LastAccess.Before(oldest) {
oldest = item.LastAccess
keyToEvict = k
first = false
}
}
case LFU:
// Find least frequently used item
var minCount int
first := true
for k, item := range c.items {
if first || item.AccessCount < minCount {
minCount = item.AccessCount
keyToEvict = k
first = false
}
}
case FIFO:
// First in, first out
if len(c.itemsList) > 0 {
keyToEvict = c.itemsList[0].Key
}
}
// Evict the selected item
if keyToEvict != "" {
c.deleteItem(keyToEvict)
}
}
// startCleanupTimer starts a timer to periodically clean up expired items
func (c *AdvancedCache) startCleanupTimer(interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
c.cleanup()
}
}
}
// cleanup removes expired items from the cache
func (c *AdvancedCache) cleanup() {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
for key, item := range c.items {
if !item.Expiration.IsZero() && now.After(item.Expiration) {
c.deleteItem(key)
}
}
}
func main() {
// Create an LRU cache with max 3 items
cache := NewAdvancedCache(3, LRU, 5*time.Second)
// Add items
cache.Set("a", "Value A", 10*time.Second)
cache.Set("b", "Value B", 10*time.Second)
cache.Set("c", "Value C", 10*time.Second)
// Access some items to change their LRU status
cache.Get("a")
cache.Get("b")
cache.Get("b")
// Add a new item, should evict "c" (least recently used)
cache.Set("d", "Value D", 10*time.Second)
// Check which items remain
fmt.Println("After eviction:")
checkKeys := []string{"a", "b", "c", "d"}
for _, key := range checkKeys {
if val, found := cache.Get(key); found {
fmt.Printf("Key %s: %v (found)\n", key, val)
} else {
fmt.Printf("Key %s: not found\n", key)
}
}
}
Best Practices for Time-Based Caching
-
Select the right eviction policy for your workload:
- LRU works well for most general-purpose caches
- LFU might be better for slowly changing popularity patterns
- FIFO is simpler but less adaptive to usage patterns
-
Consider memory usage when implementing caches:
- Set reasonable maximum item counts or sizes
- Implement periodic cleanup to prevent memory leaks
- Profile memory usage under load
-
Choose appropriate TTLs for different types of data:
- Short TTLs for frequently changing data
- Longer TTLs for relatively static data
- Consider sliding expiration for session data
-
Use caching with idempotent operations to avoid consistency issues:
- Cache GET responses more aggressively
- Be cautious with POST/PUT/DELETE caching
-
Implement stale-while-revalidate patterns for better performance:
- Continue serving stale content while fetching fresh data
- Update the cache in the background
-
Add jitter to expiration times to prevent thundering herd problems:
- Slightly randomize TTLs to distribute cache refreshes
- Implement cache stampede protection
By following these best practices and leveraging Go's time package, you can build robust caching mechanisms that optimize performance while ensuring freshness of your application's data.