Practical Applications of Go's Time Package 10/10
Rez Moss

Rez Moss @rezmoss

About: I’m a Golang & Node.js Developer with 10+ years of experience in cloud and server architecture, specializing in AWS and DevOps

Location:
Canada
Joined:
Apr 19, 2024

Practical Applications of Go's Time Package 10/10

Publish Date: May 24
4 0

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. We use Go's duration types (time.Duration) to handle time calculations cleanly
  2. The select statement provides non-blocking channel operations, perfect for handling multiple signals
  3. The ticker ensures regular updates at precise intervals
  4. 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)
}
Enter fullscreen mode Exit fullscreen mode

This scheduler implementation demonstrates several important Go patterns:

  1. Encapsulation: The scheduler encapsulates the complexities of managing multiple concurrent tasks.
  2. Concurrency: Each task runs in its own goroutine, allowing tasks to execute independently.
  3. Synchronization: Mutex locks prevent race conditions when modifying the task collection.
  4. 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])
}
Enter fullscreen mode Exit fullscreen mode

When implementing a scheduler in production systems, consider these additional points:

  1. Jitter: For distributed systems, add slight randomness to intervals to prevent thundering herd problems.
  2. Error Handling: Wrap task executions in recovery blocks to prevent a failing task from crashing the scheduler.
  3. Persistence: For critical tasks, you might want to persist task schedules to restart them after application restarts.
  4. 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)
}
Enter fullscreen mode Exit fullscreen mode

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"))
}
Enter fullscreen mode Exit fullscreen mode

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))
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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))
    }
}
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

Best Practices for Logging and Timestamping

When implementing logging in Go applications, follow these best practices:

  1. 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)
Enter fullscreen mode Exit fullscreen mode
  1. 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")
Enter fullscreen mode Exit fullscreen mode
  1. 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")
Enter fullscreen mode Exit fullscreen mode
  1. 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)
Enter fullscreen mode Exit fullscreen mode
  1. 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...
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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")
    }
}
Enter fullscreen mode Exit fullscreen mode

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))
}
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Best Practices for Time-Based Caching

  1. 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
  2. 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
  3. 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
  4. Use caching with idempotent operations to avoid consistency issues:

    • Cache GET responses more aggressively
    • Be cautious with POST/PUT/DELETE caching
  5. Implement stale-while-revalidate patterns for better performance:

    • Continue serving stale content while fetching fresh data
    • Update the cache in the background
  6. 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.

Comments 0 total

    Add comment