Skip to main content
The state package provides generic, type-safe file-backed JSON state persistence with exclusive file locking to prevent concurrent access from multiple processes.

Installation

import "github.com/aarock1234/go-template/pkg/state"

Quick Start

Persist state across application restarts:
package main

import (
    "fmt"
    "log"
    
    "github.com/aarock1234/go-template/pkg/state"
)

type AppState struct {
    Counter int      `json:"counter"`
    Users   []string `json:"users"`
}

func main() {
    // Open state file (creates if doesn't exist)
    sf, err := state.Open[AppState]("app-state.json")
    if err != nil {
        log.Fatal(err)
    }
    defer sf.Close()
    
    // Load existing state
    s, err := sf.Load()
    if err != nil {
        log.Fatal(err)
    }
    
    if s == nil {
        // First run - initialize state
        s = &AppState{Counter: 0, Users: []string{}}
    }
    
    // Modify state
    s.Counter++
    s.Users = append(s.Users, "alice")
    
    // Save state
    if err := sf.Save(s); err != nil {
        log.Fatal(err)
    }
    
    fmt.Printf("Counter: %d, Users: %v\n", s.Counter, s.Users)
}

Core Type

File

Manages a JSON file that persists state of type T.
type File[T any] struct {
    // private fields
}
The File holds an exclusive lock on the underlying file for its entire lifetime to prevent concurrent access from multiple process instances.

Functions

Open

Creates or opens a state file with exclusive locking.
func Open[T any](path string) (*File[T], error)
path
string
Path to the state file. Created if it doesn’t exist.
Behavior:
  • Creates the file if it doesn’t exist with 0644 permissions
  • Acquires a non-blocking exclusive lock
  • Returns error immediately if another process holds the lock
  • Must call Close() when done to release the lock
Example:
type Config struct {
    APIKey string `json:"api_key"`
    Debug  bool   `json:"debug"`
}

sf, err := state.Open[Config]("config.json")
if err != nil {
    return fmt.Errorf("opening state: %w", err)
}
defer sf.Close()

Load

Reads and unmarshals the persisted state from disk.
func (f *File[T]) Load() (*T, error)
Returns:
  • (*T, nil) on success with the unmarshaled state
  • (nil, nil) if the file is empty (first run)
  • (nil, error) on error
Example:
state, err := sf.Load()
if err != nil {
    return fmt.Errorf("loading state: %w", err)
}

if state == nil {
    // First run - initialize with defaults
    state = &Config{
        APIKey: "default-key",
        Debug:  false,
    }
}

Save

Marshals and persists the state to disk.
func (f *File[T]) Save(v *T) error
v
*T
State to persist
Behavior:
  • Marshals state to JSON
  • Truncates the file
  • Writes new content
  • Flushes to disk (fsync)
  • Atomically replaces old content
Example:
config.Debug = true
config.APIKey = "new-key"

if err := sf.Save(config); err != nil {
    return fmt.Errorf("saving state: %w", err)
}

Close

Releases the exclusive lock and closes the file.
func (f *File[T]) Close() error
Always call Close() when done, typically with defer:
sf, err := state.Open[MyState]("state.json")
if err != nil {
    return err
}
defer sf.Close() // Ensures lock is released

File Locking

The package uses cross-platform exclusive file locking: Unix/Linux/macOS:
  • Uses flock(LOCK_EX | LOCK_NB)
  • Non-blocking exclusive lock
Windows:
  • Uses LockFileEx with LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY
  • Non-blocking exclusive lock
Behavior:
  • Only one process can hold the lock at a time
  • Open() fails immediately if lock is held by another process
  • Lock is automatically released when process exits
  • Lock is held for the entire lifetime of the File[T]

Usage Examples

Application Configuration

package main

import (
    "log"
    
    "github.com/aarock1234/go-template/pkg/state"
)

type Config struct {
    ServerPort int      `json:"server_port"`
    LogLevel   string   `json:"log_level"`
    Features   []string `json:"features"`
}

func loadConfig() (*Config, error) {
    sf, err := state.Open[Config]("app.config.json")
    if err != nil {
        return nil, err
    }
    defer sf.Close()
    
    cfg, err := sf.Load()
    if err != nil {
        return nil, err
    }
    
    if cfg == nil {
        // First run - use defaults
        cfg = &Config{
            ServerPort: 8080,
            LogLevel:   "info",
            Features:   []string{"auth", "api"},
        }
        
        // Save defaults
        if err := sf.Save(cfg); err != nil {
            return nil, err
        }
    }
    
    return cfg, nil
}

Progress Tracking

import (
    "fmt"
    "time"
    
    "github.com/aarock1234/go-template/pkg/state"
)

type Progress struct {
    ProcessedItems int       `json:"processed_items"`
    LastRun        time.Time `json:"last_run"`
    Errors         []string  `json:"errors"`
}

func trackProgress(items []string) error {
    sf, err := state.Open[Progress]("progress.json")
    if err != nil {
        return err
    }
    defer sf.Close()
    
    progress, err := sf.Load()
    if err != nil {
        return err
    }
    
    if progress == nil {
        progress = &Progress{
            ProcessedItems: 0,
            Errors:         []string{},
        }
    }
    
    // Process items
    for _, item := range items {
        if err := processItem(item); err != nil {
            progress.Errors = append(progress.Errors, err.Error())
            continue
        }
        progress.ProcessedItems++
    }
    
    progress.LastRun = time.Now()
    
    return sf.Save(progress)
}

Cache Management

import (
    "time"
    
    "github.com/aarock1234/go-template/pkg/state"
)

type Cache struct {
    Data      map[string]string `json:"data"`
    ExpiresAt time.Time         `json:"expires_at"`
}

func getCachedData(key string) (string, error) {
    sf, err := state.Open[Cache]("cache.json")
    if err != nil {
        return "", err
    }
    defer sf.Close()
    
    cache, err := sf.Load()
    if err != nil {
        return "", err
    }
    
    // Check if cache exists and is valid
    if cache != nil && time.Now().Before(cache.ExpiresAt) {
        if value, ok := cache.Data[key]; ok {
            return value, nil
        }
    }
    
    // Cache miss or expired - fetch fresh data
    value := fetchFreshData(key)
    
    // Update cache
    if cache == nil {
        cache = &Cache{
            Data: make(map[string]string),
        }
    }
    
    cache.Data[key] = value
    cache.ExpiresAt = time.Now().Add(1 * time.Hour)
    
    if err := sf.Save(cache); err != nil {
        return value, err // Return value even if save fails
    }
    
    return value, nil
}

Preventing Concurrent Runs

import (
    "fmt"
    "time"
    
    "github.com/aarock1234/go-template/pkg/state"
)

type LockState struct {
    StartedAt time.Time `json:"started_at"`
    PID       int       `json:"pid"`
}

func runExclusively() error {
    // Try to acquire lock
    sf, err := state.Open[LockState]("app.lock")
    if err != nil {
        return fmt.Errorf("another instance is already running")
    }
    defer sf.Close()
    
    // Save lock info
    lockInfo := &LockState{
        StartedAt: time.Now(),
        PID:       os.Getpid(),
    }
    
    if err := sf.Save(lockInfo); err != nil {
        return err
    }
    
    // Do work - lock is held until Close() is called
    return doWork()
}

Job Queue State

import (
    "github.com/aarock1234/go-template/pkg/state"
)

type Job struct {
    ID     string `json:"id"`
    Status string `json:"status"`
    Data   string `json:"data"`
}

type Queue struct {
    Pending   []Job `json:"pending"`
    Completed []Job `json:"completed"`
}

func processQueue() error {
    sf, err := state.Open[Queue]("queue.json")
    if err != nil {
        return err
    }
    defer sf.Close()
    
    queue, err := sf.Load()
    if err != nil {
        return err
    }
    
    if queue == nil {
        queue = &Queue{
            Pending:   []Job{},
            Completed: []Job{},
        }
    }
    
    // Process pending jobs
    for len(queue.Pending) > 0 {
        job := queue.Pending[0]
        queue.Pending = queue.Pending[1:]
        
        // Process job
        if err := processJob(job); err != nil {
            // Re-add to pending on error
            queue.Pending = append(queue.Pending, job)
            break
        }
        
        job.Status = "completed"
        queue.Completed = append(queue.Completed, job)
        
        // Save after each job
        if err := sf.Save(queue); err != nil {
            return err
        }
    }
    
    return nil
}

Error Handling

Lock Already Held

sf, err := state.Open[MyState]("state.json")
if err != nil {
    // Another process is using the file
    return fmt.Errorf("state file is locked by another process: %w", err)
}
defer sf.Close()

Empty File Handling

state, err := sf.Load()
if err != nil {
    return fmt.Errorf("loading state: %w", err)
}

if state == nil {
    // File was empty - first run
    state = &MyState{} // Initialize with defaults
}

Save Errors

if err := sf.Save(state); err != nil {
    return fmt.Errorf("persisting state: %w", err)
}

Best Practices

Always use defer Close(): Ensures the lock is released even if your function panics or returns early.
Check for nil after Load(): A nil return means the file was empty (first run). Initialize with defaults.
Keep lock duration short: The file is locked for the entire lifetime of the File[T]. Open, load/save, and close as quickly as possible.
Use for single-instance enforcement: The exclusive lock makes it perfect for preventing multiple instances of an application from running.
JSON-serializable types only: Your state type T must be serializable to JSON. Use struct tags to control field names.

Platform Support

The package works identically on:
  • Linux
  • macOS
  • Windows
  • Other Unix-like systems
Locking is implemented using platform-specific APIs but provides a consistent interface.

Complete Example

package main

import (
    "fmt"
    "log"
    "time"
    
    "github.com/aarock1234/go-template/pkg/state"
)

type AppState struct {
    RunCount    int       `json:"run_count"`
    LastRun     time.Time `json:"last_run"`
    TotalErrors int       `json:"total_errors"`
    Config      struct {
        MaxRetries int  `json:"max_retries"`
        Debug      bool `json:"debug"`
    } `json:"config"`
}

func main() {
    // Try to acquire state file lock
    sf, err := state.Open[AppState]("app-state.json")
    if err != nil {
        log.Fatalf("Failed to open state file (another instance running?): %v", err)
    }
    defer sf.Close()
    
    // Load existing state
    appState, err := sf.Load()
    if err != nil {
        log.Fatalf("Failed to load state: %v", err)
    }
    
    // Initialize if first run
    if appState == nil {
        log.Println("First run - initializing state")
        appState = &AppState{
            RunCount:    0,
            TotalErrors: 0,
        }
        appState.Config.MaxRetries = 3
        appState.Config.Debug = false
    }
    
    // Update state
    appState.RunCount++
    appState.LastRun = time.Now()
    
    log.Printf("Run #%d (last run: %v)", appState.RunCount, appState.LastRun)
    
    // Do some work
    if err := doWork(appState.Config.MaxRetries); err != nil {
        appState.TotalErrors++
        log.Printf("Error occurred (total: %d): %v", appState.TotalErrors, err)
    }
    
    // Save updated state
    if err := sf.Save(appState); err != nil {
        log.Fatalf("Failed to save state: %v", err)
    }
    
    log.Println("State saved successfully")
}

func doWork(maxRetries int) error {
    // Your application logic here
    return nil
}

Build docs developers (and LLMs) love