Capstone: Log Parser
The second capstone, and the one that looks most like work. A log file arrives as lines of text; you have to turn it into records, survive the lines that are not records, aggregate what is left, and do it on more than one core without the answer changing.
That is structs, interfaces, errors, sync and goroutines in one program,
which is exactly the point: those five things were separate lessons, and
nothing real ever uses them separately.
The format
LEVEL|service|duration_ms|message
INFO|auth|12|login ok
ERROR|auth|145|token expired for user 91
WARN|search|310|slow query: SELECT | FROM docs
Four fields, pipe separated, every field trimmed. The message is the rest of
the line, pipes and all, which is why it is
strings.SplitN(raw, "|", 4) and not
strings.Split. Split on the third line
above gives you six fields and a truncated message; SplitN with a limit of 4
stops cutting once it has the first three.
A bad line is data, not a crash
Real logs contain rotated fragments, a truncated last line, a level someone invented on a Friday. One bad line must not stop the other ten thousand, so parsing returns an error rather than panicking, and the caller collects the errors and carries on.
The error has to say which line and what was wrong, and both of those want to be machine readable:
type ParseError struct {
Line int
Raw string
Err error
}
func (e *ParseError) Unwrap() error { return e.Err }
Err is one of three sentinels. Unwrap is what lets errors.Is(err, ErrBadLevel) see through the wrapper, and the struct is what lets
errors.As(err, &pe) get the line number back out. Those two are the pair:
errors.Is for "what kind of failure",
errors.As for "give me the details".
Note the pointer receiver. Error, Unwrap and errors.As(&pe) all agree
that the error value is *ParseError, so return &ParseError{...}, never
ParseError{...}.
The sink is an interface so the parser can forget about counting
type Sink interface {
Add(e Entry)
}
ParseAll does not know what happens to an entry. It parses, it calls Add,
it is finished. That is what lets the tests hand it a recorder instead of the
real aggregator, and it is what would let you add a second aggregator later
without touching the parser.
One line of that interface's documentation is load-bearing: Add is called
from several goroutines at once. There is no way to say that in a signature,
so it is said in the comment, and every implementation owes it a mutex.
Concurrency you can check
ParseAll takes a worker count and spreads the lines across that many
goroutines. The standard shape:
jobs := make(chan job)
var wg sync.WaitGroup
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
for j := range jobs { // ends when jobs is closed
...
}
}()
}
for i, raw := range lines { jobs <- job{i + 1, raw} }
close(jobs)
wg.Wait()
The workers share one channel and take the next line whenever they are free,
so a slow line does not hold up a worker with nothing to do. close is what
ends the range, and wg.Wait is what makes "ParseAll returned" mean "every
line was handled".
Determinism is the hard part
Concurrency reorders things, and a report that reorders itself is a report nobody can diff:
- Errors come back sorted by line number, not in the order the goroutines
happened to finish. The number is inside the
*ParseError, so sorting meanserrors.Asfirst. - Services come out sorted by total time, and ties break on the name.
- The slowest entry for a service ties on the smaller message, not on whichever goroutine won the race.
Every one of those is a rule that exists only because the work is concurrent. Write them down and the output is a function of the input again.
Snapshot hands out copies
Snapshot builds a Summary while holding the lock, and it copies: a fresh
map, a fresh slice, the ServiceStat values dereferenced out of whatever the
Stats keeps them in. Return the internal map and the caller is reading shared
state without a lock, which is a data race with no code in sight to blame.
Task
Implement ParseError's two methods, ParseLine, Stats with NewStats,
Add and Snapshot, then ParseAll and Summarize, to the contracts in
their doc comments.
Stats has no fields yet. Choose them: the counters you need, and the lock
that makes Add safe to call from four goroutines at once.
Hints
strings.SplitN(raw, "|", 4)and checklen(fields) < 4before you index anything.- A tiny
fail := func(err error) (Entry, error)closure at the top ofParseLinekeeps the four failure paths to one line each. strconv.Atoirejects"fast"and""; you reject a negative yourself.- Keep services as a
map[string]*ServiceStatand you can update a service in place rather than reading, editing and writing back a value. Addis two lines of bookkeeping betweens.mu.Lock()and adefer s.mu.Unlock(). Snapshot takes the same lock: reading anintwhile another goroutine writes it is a race even when the answer looks right.- Collect the errors into a shared slice under a second, separate mutex, then
sort once at the end, after
wg.Wait. for range workersis Go 1.22's range-over-int.for i := 0; i < workers; i++is the same thing.