Skip to content

Level 2: Concurrent Log Analyzer (Concurrency & Context)

Welcome to the second level! Here, you'll tackle one of Go's greatest strengths: concurrency.

In Python, achieving true multi-core concurrency is difficult due to the Global Interpreter Lock (GIL), requiring complex multiprocessing setups. In Go, we use lightweight Goroutines (green threads managed by the Go runtime) and Channels (typed pipes to communicate between goroutines safely).

Feature Requirements

Your goal is to build a log analyzer that: 1. Receives a slice of file paths to log files. 2. Reads and parses all files concurrently. 3. Counts the occurrences of log levels: INFO, WARNING, and ERROR. 4. Accepts a context.Context to handle timeouts. If processing takes too long, it must exit gracefully and return a timeout error. 5. Aggregates and returns a single unified LogResult count.

Concurrency Concept Cheat Sheet

  • Goroutines: Prefix a function call with the go keyword. It runs asynchronously in the background.
    go parseFile(path, resultsChan)
    
  • Channels: Channels are used to pass data between goroutines safely.
    // Create a channel that passes LogResult structs
    ch := make(chan LogResult)
    
    // Write to a channel (inside a goroutine)
    ch <- result
    
    // Read from a channel (in the main routine)
    res := <-ch
    
  • WaitGroup: Used to wait for a collection of goroutines to finish.
    var wg sync.WaitGroup
    wg.Add(1) // Call before starting a goroutine
    go func() {
        defer wg.Done() // Call when done
        // Do work...
    }()
    wg.Wait() // Blocks until all done
    
  • Context: Used for deadlines, cancellations, and metadata propagation.
    select {
    case res := <-ch:
        // successfully got result
    case <-ctx.Done():
        // context timeout/cancellation occurred
        return ctx.Err()
    }
    

Getting Started

  1. Look at analyzer.go to see the stubs and comments.
  2. Look at analyzer_test.go for the test cases.
  3. Run the tests (they will fail initially):
    /usr/local/go/bin/go test -v ./level2_log_analyzer/...
    
  4. Implement the concurrent parser in analyzer.go.

Complete Implementation (analyzer.go)

package loganalyzer

import (
    "bufio"
    "context"
    "os"
    "strings"
    "sync"
)

// LogResult holds the count of log messages grouped by level.
type LogResult struct {
    InfoCount    int
    WarningCount int
    ErrorCount   int
}

func parseFile(file string )( LogResult, error) {
    // Simulate parsing
    fileOpen , err := os.Open(file)
    if err!=nil{
        return LogResult{}, err
    }
    defer fileOpen.Close()

    result := LogResult{}

    scanner := bufio.NewScanner(fileOpen)

    for scanner.Scan(){
        line:= strings.ToLower(scanner.Text())

        if strings.Contains(line,"info"){
            result.InfoCount++
        } else if strings.Contains(line,"warning"){
            result.WarningCount++
        } else if strings.Contains(line,"error"){
            result.ErrorCount++
        }
    }

    if err :=scanner.Err(); err != nil{
        return LogResult{} , err
    }

    return result, nil
}
// AnalyzeLogs reads and parses the provided files concurrently.
// It counts lines containing "INFO", "WARNING", and "ERROR".
// If the ctx is cancelled or times out, the function should return immediately with ctx.Err().
func AnalyzeLogs(ctx context.Context, filePaths []string) (LogResult, error) {
    // TODO: Implement concurrent log analysis.
    //
    files := filePaths

    resultsChan := make(chan LogResult , len(filePaths))
    var wg sync.WaitGroup

    for _,file :=range files{
        wg.Add(1)
        go func(file string){
            defer wg.Done()
            res ,err := parseFile(file)
            if err != nil{
                resultsChan <- LogResult{}
            }else{
                resultsChan <- res
            }
        }(file)
    }

    go func(){
        wg.Wait()
        close(resultsChan)
    }()
    total := LogResult{}
    // 3. Loop for each file and use select to handle context timeouts
    for i := 0; i < len(filePaths); i++ {
        select {
            case res, ok := <-resultsChan:
                if !ok {
                    break
                }
                // 4. Aggregate the counts
                total.InfoCount += res.InfoCount
                total.WarningCount += res.WarningCount
                total.ErrorCount += res.ErrorCount
            case <-ctx.Done():
                // Exit immediately if the context is cancelled or timed out
                return LogResult{}, ctx.Err()
            }
        }
    return total, nil
    // Recommended Plan:
    // 1. Create a channel to receive results (or errors) from each file parsing goroutine.
    // 2. Spawn a goroutine for each file in filePaths.
    // 3. Inside each goroutine:
    //    - Open the file.
    //    - Read line-by-line (hint: use bufio.NewScanner).
    //    - For each line, check if it contains "INFO", "WARNING", or "ERROR" (hint: strings.Contains).
    //    - Crucially, check if the context is cancelled periodically or check ctx.Done() during work.
    //    - Send the file's LogResult back on the channel.
    // 4. In the main routine, collect results from the channel.
    //    - Use a select statement to either read a result from the channel or handle context cancellation:
    //      select {
    //      case res := <-resultsChan:
    //          // Add to running totals
    //      case <-ctx.Done():
    //          return LogResult{}, ctx.Err()
    //      }
    // 5. Aggregate and return the total LogResult.

}