Skip to content

Thread Safety

What Is Thread-Safe

The following objects are designed to be shared safely across multiple goroutines:

Compiled Expression (*Expression)

A compiled *Expression is immutable after creation. It holds only the parsed AST and the original expression string. You can call Evaluate() or EvaluateWithOptions() from any number of goroutines simultaneously:

// Safe: one expression, many goroutines.
expr := fhirpath.MustCompile("Patient.name.family")

var wg sync.WaitGroup
for i := 0; i < 100; i++ {
    wg.Add(1)
    go func(resource []byte) {
        defer wg.Done()
        result, err := expr.Evaluate(resource)
        // handle result...
    }(resources[i])
}
wg.Wait()

Expression Cache (*ExpressionCache)

The ExpressionCache (including the global DefaultCache) uses a sync.RWMutex internally. All methods – Get(), MustGet(), Clear(), Size(), Stats(), and HitRate() – are safe for concurrent use:

// Safe: concurrent cache access from multiple goroutines.
cache := fhirpath.NewExpressionCache(500)

var wg sync.WaitGroup
for _, exprStr := range expressions {
    wg.Add(1)
    go func(e string) {
        defer wg.Done()
        compiled, err := cache.Get(e)
        // use compiled...
    }(exprStr)
}
wg.Wait()

Convenience Functions

The top-level functions EvaluateCached(), GetCached(), and MustGetCached() all delegate to DefaultCache and are therefore safe for concurrent use.

What Is Not Shared

Evaluation Context (eval.Context)

Each call to Evaluate() or EvaluateWithOptions() creates a new eval.Context internally. The context holds mutable evaluation state: the current $this value, $index, variables, and limits. It must never be shared between concurrent evaluations.

You normally do not need to worry about this because the public API creates a fresh context per call. However, if you create an eval.Context manually, do not reuse it across goroutines:

// WRONG -- sharing a context between goroutines.
ctx := eval.NewContext(resource)
go func() { expr1.EvaluateWithContext(ctx) }() // DATA RACE
go func() { expr2.EvaluateWithContext(ctx) }() // DATA RACE
// CORRECT -- each goroutine gets its own context.
go func() {
    ctx := eval.NewContext(resource)
    expr1.EvaluateWithContext(ctx)
}()
go func() {
    ctx := eval.NewContext(resource)
    expr2.EvaluateWithContext(ctx)
}()

Resource Byte Slices

The []byte resource data passed to Evaluate() is read-only during evaluation. It is safe to pass the same byte slice to multiple concurrent evaluations as long as no goroutine mutates it during evaluation.

Concurrent Evaluation Pattern

The most common concurrent pattern is a fan-out where multiple resources are evaluated in parallel using the same compiled expression:

package main

import (
    "fmt"
    "sync"

    "github.com/gofhir/fhirpath"
)

func main() {
    // Compile once.
    expr := fhirpath.MustCompile("Patient.name.family")

    resources := [][]byte{
        []byte(`{"resourceType":"Patient","name":[{"family":"Smith"}]}`),
        []byte(`{"resourceType":"Patient","name":[{"family":"Johnson"}]}`),
        []byte(`{"resourceType":"Patient","name":[{"family":"Williams"}]}`),
        []byte(`{"resourceType":"Patient","name":[{"family":"Brown"}]}`),
    }

    results := make([]fhirpath.Collection, len(resources))
    errors := make([]error, len(resources))

    var wg sync.WaitGroup
    for i, res := range resources {
        wg.Add(1)
        go func(idx int, resource []byte) {
            defer wg.Done()
            results[idx], errors[idx] = expr.Evaluate(resource)
        }(i, res)
    }
    wg.Wait()

    for i, result := range results {
        if errors[i] != nil {
            fmt.Printf("resource %d: error: %v\n", i, errors[i])
        } else {
            fmt.Printf("resource %d: %v\n", i, result)
        }
    }
}

Production Patterns

HTTP Handler

In an HTTP server, each request handler runs in its own goroutine. Compiled expressions and the expression cache can be shared across all handlers:

package main

import (
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
    "time"

    "github.com/gofhir/fhirpath"
)

// expressionCache is shared across all request handlers.
var expressionCache = fhirpath.NewExpressionCache(1000)

// Pre-compiled expressions for known operations.
var (
    exprFamilyName = fhirpath.MustCompile("Patient.name.family")
    exprBirthDate  = fhirpath.MustCompile("Patient.birthDate")
    exprActive     = fhirpath.MustCompile("Patient.active")
)

func handleExtract(w http.ResponseWriter, r *http.Request) {
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "failed to read body", http.StatusBadRequest)
        return
    }

    // Each evaluation creates its own internal eval.Context --
    // no shared mutable state between requests.
    family, err := exprFamilyName.EvaluateWithOptions(body,
        fhirpath.WithContext(r.Context()),
        fhirpath.WithTimeout(2*time.Second),
    )
    if err != nil {
        http.Error(w, fmt.Sprintf("evaluation error: %v", err),
            http.StatusInternalServerError)
        return
    }

    resp := map[string]interface{}{
        "familyName": family.String(),
    }
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(resp)
}

func handleDynamic(w http.ResponseWriter, r *http.Request) {
    // For user-supplied expressions, use the cache.
    exprStr := r.URL.Query().Get("expression")
    if exprStr == "" {
        http.Error(w, "missing expression parameter", http.StatusBadRequest)
        return
    }

    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "failed to read body", http.StatusBadRequest)
        return
    }

    // GetCached is safe for concurrent use.
    compiled, err := expressionCache.Get(exprStr)
    if err != nil {
        http.Error(w, fmt.Sprintf("invalid expression: %v", err),
            http.StatusBadRequest)
        return
    }

    result, err := compiled.EvaluateWithOptions(body,
        fhirpath.WithContext(r.Context()),
        fhirpath.WithTimeout(2*time.Second),
        fhirpath.WithMaxCollectionSize(1000),
    )
    if err != nil {
        http.Error(w, fmt.Sprintf("evaluation error: %v", err),
            http.StatusInternalServerError)
        return
    }

    resp := map[string]interface{}{
        "result": result.String(),
    }
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(resp)
}

func main() {
    http.HandleFunc("/extract", handleExtract)
    http.HandleFunc("/evaluate", handleDynamic)
    log.Println("listening on :8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

Worker Pool

For batch processing (for example, validating all resources in a database), use a worker pool to bound concurrency:

package main

import (
    "fmt"
    "sync"
    "time"

    "github.com/gofhir/fhirpath"
)

// ValidationResult holds the outcome for one resource.
type ValidationResult struct {
    Index int
    Valid bool
    Error error
}

func validateBatch(
    resources [][]byte,
    expression *fhirpath.Expression,
    workers int,
) []ValidationResult {
    jobs := make(chan int, len(resources))
    results := make([]ValidationResult, len(resources))

    var wg sync.WaitGroup
    for w := 0; w < workers; w++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for idx := range jobs {
                result, err := expression.EvaluateWithOptions(resources[idx],
                    fhirpath.WithTimeout(2*time.Second),
                    fhirpath.WithMaxCollectionSize(5000),
                )
                if err != nil {
                    results[idx] = ValidationResult{Index: idx, Error: err}
                    continue
                }

                // Check if the expression returned true (valid).
                valid := false
                if !result.Empty() {
                    valid = result.String() == "[true]"
                }
                results[idx] = ValidationResult{Index: idx, Valid: valid}
            }
        }()
    }

    // Enqueue all jobs.
    for i := range resources {
        jobs <- i
    }
    close(jobs)

    wg.Wait()
    return results
}

func main() {
    // Compile the validation expression once.
    expr := fhirpath.MustCompile("Patient.name.exists() and Patient.birthDate.exists()")

    resources := [][]byte{
        []byte(`{"resourceType":"Patient","name":[{"family":"Doe"}],"birthDate":"1990-01-15"}`),
        []byte(`{"resourceType":"Patient","name":[{"family":"Smith"}]}`),
        []byte(`{"resourceType":"Patient","birthDate":"1985-03-22"}`),
        []byte(`{"resourceType":"Patient","name":[{"family":"Lee"}],"birthDate":"2000-07-04"}`),
    }

    // Process with 4 worker goroutines.
    results := validateBatch(resources, expr, 4)

    for _, r := range results {
        if r.Error != nil {
            fmt.Printf("resource %d: error: %v\n", r.Index, r.Error)
        } else {
            fmt.Printf("resource %d: valid=%v\n", r.Index, r.Valid)
        }
    }
    // Output:
    // resource 0: valid=true
    // resource 1: valid=false
    // resource 2: valid=false
    // resource 3: valid=true
}

Summary

ObjectThread-Safe?Notes
*ExpressionYesImmutable after creation; share freely
*ExpressionCacheYesUses sync.RWMutex internally
DefaultCacheYesGlobal *ExpressionCache
EvaluateCached(), GetCached()YesDelegate to DefaultCache
eval.ContextNoCreated per-evaluation; never share between goroutines
[]byte resourceRead-onlySafe if no goroutine mutates it during evaluation
ReferenceResolverDependsYour implementation must be safe for concurrent use
TerminologyServiceDependsYour implementation must be safe for concurrent use
ProfileValidatorDependsYour implementation must be safe for concurrent use
Last updated on