pipeline

package
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 23, 2026 License: MIT Imports: 34 Imported by: 0

Documentation

Overview

Package pipeline implements a streaming ETL for EPO and HUPD patent archives, wired with github.com/destel/rill.

The five stages are:

Source -> Opener -> Extractor -> Batch -> Sink

Stage contracts live in types.go; implementations sit alongside (HTTPOpener, LocalFileOpener, XMLStreamExtractor, ParquetSink, BoltCheckpointer, ...). Cancellation and backpressure flow through every stage via context.Context and rill's bounded channels.

Usage

p, err := pipeline.New(
    pipeline.WithSource(src),
    pipeline.WithOpener(opener),
    pipeline.WithExtractor(pipeline.NewXMLStreamExtractor()),
    pipeline.WithSink(sink),
)
if err != nil {
    return err
}
return p.Run(ctx)

Concurrency

  • ArchiveConcurrency: parallel archives in flight (default 4).
  • ExtractorConcurrency: parallel XML decoders (default 4).
  • Sink writes are serialised (concurrency 1).

Resumability

Set pipeline.checkpoint_db (cmd config) to enable bbolt-backed resumption. Disabled by default via NoopCheckpointer.

Index

Examples

Constants

View Source
const DefaultRowGroupSize = 50_000

DefaultRowGroupSize is the default record count threshold at which ParquetSink flushes the current row group to disk.

Variables

This section is empty.

Functions

func IsXML

func IsXML(name string) bool

IsXML returns true for entries that should be parsed.

Example
package main

import (
	"fmt"

	"github.com/Qubut/epo-processor/internal/pipeline"
)

func main() {
	fmt.Println(pipeline.IsXML("EP123456.xml"))
	fmt.Println(pipeline.IsXML("EP123456.XML")) // case-insensitive
	fmt.Println(pipeline.IsXML("data.tar.gz"))
}
Output:
true
true
false

func PlanReaderConcurrency

func PlanReaderConcurrency(requested, numCPU int, memBudgetBytes, perEntryBytes int64) int

PlanReaderConcurrency resolves the number of concurrent XML readers (in-flight entries) to run.

Each reader tokenizes one entry sequentially and is CPU-bound, so the number of readers is what saturates cores. An explicit positive request always wins. When requested <= 0 ("auto"), it auto-sizes to numCPU but clamps by a memory budget, since every in-flight reader buffers a decompressed XML stream plus the current DOM node (~perEntryBytes).

The result is always at least 1.

Types

type ArchiveJob

type ArchiveJob struct {
	Name         string // human-readable label used in log messages and spans
	URL          string // HTTP download URL; empty for local-file sources
	LocalPath    string // path on disk; empty for HTTP sources
	ExpectedSize int64  // byte count from the catalogue; 0 if unknown
	Checksum     string // SHA-1 hex as listed in the catalogue; empty disables verification
}

ArchiveJob describes a single archive to process.

type ArchiveKind

type ArchiveKind uint8

ArchiveKind enumerates the supported container formats.

const (
	// KindUnknown indicates an unrecognised archive format.
	KindUnknown ArchiveKind = iota
	// KindTar is a plain .tar archive.
	KindTar
	// KindTarGz is a gzip-compressed .tar archive.
	KindTarGz
	// KindZip is a .zip archive.
	KindZip
)

func DetectKind

func DetectKind(name string) ArchiveKind

DetectKind returns the ArchiveKind inferred from name's extension.

type ArchiveOpener

type ArchiveOpener interface {
	Stream(ctx context.Context, job ArchiveJob) rill.Stream[XMLEntry]
}

ArchiveOpener turns one ArchiveJob into a stream of XMLEntry values, recursing into nested archives. It owns all I/O, retry, checksum verification and temp-file lifecycle.

type ArchiveSource

type ArchiveSource interface {
	Stream(ctx context.Context) rill.Stream[ArchiveJob]
}

ArchiveSource produces a stream of jobs to process.

type BoltCheckpointer

type BoltCheckpointer struct {
	// contains filtered or unexported fields
}

BoltCheckpointer is an embedded, ACID, single-file Checkpointer backed by bbolt (https://github.com/etcd-io/bbolt). Goroutine-safe.

func OpenBoltCheckpointer

func OpenBoltCheckpointer(path string) (*BoltCheckpointer, error)

OpenBoltCheckpointer opens or creates a checkpoint database file at path. The caller must Close it.

func (*BoltCheckpointer) Close

func (b *BoltCheckpointer) Close() error

Close flushes and closes the underlying bbolt database.

func (*BoltCheckpointer) HasAny

func (b *BoltCheckpointer) HasAny() (bool, error)

HasAny returns true when the checkpoint bucket contains at least one entry.

func (*BoltCheckpointer) IsCompleted

func (b *BoltCheckpointer) IsCompleted(id string) (bool, error)

IsCompleted reports whether id has been marked completed.

func (*BoltCheckpointer) MarkCompleted

func (b *BoltCheckpointer) MarkCompleted(id string, meta map[string]string) error

MarkCompleted persists id and its metadata as a completed entry.

type CheckpointJanitor

type CheckpointJanitor struct {
	CP     Checkpointer
	Logger *slog.Logger
}

CheckpointJanitor marks an archive completed in CP after the opener finishes draining it without error.

Completion means all entries were enqueued downstream, not that every record was persisted. With a single-file Parquet sink an interrupted run may leave a truncated file; on resume the cmd layer routes new output to a fresh shard so previous output is preserved.

func (CheckpointJanitor) OnArchiveDone

func (c CheckpointJanitor) OnArchiveDone(_ context.Context, j ArchiveJob, _ string, _ bool)

OnArchiveDone marks j completed in CP after the opener finishes draining it.

func (CheckpointJanitor) OnEntryDone

OnEntryDone is a no-op; entry-level checkpointing is not required.

type CheckpointSource

type CheckpointSource struct {
	Inner  ArchiveSource
	CP     Checkpointer
	Logger *slog.Logger
}

CheckpointSource decorates an ArchiveSource, filtering out jobs whose stable ID is already marked completed by CP. Read errors from CP are logged and the job is forwarded (best-effort — prefer reprocess to halt).

func NewCheckpointSource

func NewCheckpointSource(inner ArchiveSource, cp Checkpointer, log *slog.Logger) *CheckpointSource

NewCheckpointSource constructs a CheckpointSource.

func (*CheckpointSource) Stream

func (c *CheckpointSource) Stream(ctx context.Context) <-chan rill.Try[ArchiveJob]

Stream returns the filtered job channel, skipping already-completed archives.

type Checkpointer

type Checkpointer interface {
	// IsCompleted reports whether the job with the given stable ID has
	// already been processed in a previous run.
	IsCompleted(jobID string) (bool, error)
	// MarkCompleted atomically and durably records that the job has been
	// fully drained and its records flushed to the sink.
	MarkCompleted(jobID string, meta map[string]string) error
	// HasAny reports whether at least one job has been recorded as completed.
	HasAny() (bool, error)
	// Close releases any underlying resources.
	Close() error
}

Checkpointer records which archive jobs have been fully processed so they can be skipped on resume. Implementations must be goroutine-safe.

type EPOProductSource

type EPOProductSource struct {
	BaseURL   string
	ProductID int
	Client    *http.Client
}

EPOProductSource lists items from an EPO product manifest and emits one ArchiveJob per item. Network failure aborts the whole stream.

func NewEPOProductSource

func NewEPOProductSource(baseURL string, productID int, client *http.Client) *EPOProductSource

NewEPOProductSource constructs a source. A nil client uses http.DefaultClient.

func (*EPOProductSource) Stream

Stream fetches the manifest, then emits one ArchiveJob per item until ctx is cancelled.

type HTTPOpener

type HTTPOpener struct {
	Client     *http.Client
	MaxRetries uint
	VerifySHA1 bool
	Walk       WalkConfig

	// KeepArchive, when true, tees the HTTP response body to a file under
	// ArchiveDir while the walker consumes it. Orthogonal to
	// Walk.KeepExtracted.
	KeepArchive bool
	ArchiveDir  string

	// Logger, when non-nil, receives structured warnings (e.g. for
	// unsupported archive kinds).
	Logger *slog.Logger

	// Janitor receives a per-archive completion notification after the
	// stream is fully drained without error. Defaults to NoopJanitor.
	Janitor Janitor

	// OnBytes, when non-nil, receives throttled byte-level progress for
	// the current download (downloaded, total). total is -1 when the
	// server omits Content-Length. Called from the producer goroutine;
	// must not block.
	OnBytes func(job ArchiveJob, downloaded, total int64)

	// OnArchiveSettled, when non-nil, is invoked exactly once per archive
	// after it terminally succeeds (err == nil) or fails after retries
	// (err != nil). Lets a progress display retire the archive's bar and
	// the summary collector tally failures. Called from the producer
	// goroutine; must not block.
	OnArchiveSettled func(job ArchiveJob, err error)

	// ProgressInterval throttles OnBytes calls. Defaults to 100ms.
	ProgressInterval time.Duration
}

HTTPOpener fetches each archive over HTTP and feeds it to the shared archive walker. Tar/tar.gz are processed without touching disk; zip is spooled to Walk.SpoolDir. Retry, optional SHA-1 verification, and resource cleanup are layered via fp-go ioresult Bracket+Retrying.

func NewHTTPOpener

func NewHTTPOpener(client *http.Client, maxRetries uint, verifySHA1 bool, walk WalkConfig) *HTTPOpener

NewHTTPOpener returns an HTTPOpener with sensible defaults. Set KeepArchive and ArchiveDir on the result to enable archive-level retention.

func (*HTTPOpener) Stream

func (o *HTTPOpener) Stream(ctx context.Context, job ArchiveJob) rill.Stream[XMLEntry]

Stream issues the GET, runs the archive walk, retries on error, and notifies the Janitor on success.

type Janitor

type Janitor interface {
	OnArchiveDone(ctx context.Context, job ArchiveJob, localPath string, kept bool)
	OnEntryDone(ctx context.Context, entry XMLEntry, localPath string)
}

Janitor receives lifecycle notifications so retention policy lives outside openers and sinks.

type LocalFileOpener

type LocalFileOpener struct {
	Walk    WalkConfig
	Logger  *slog.Logger
	Janitor Janitor
}

LocalFileOpener streams archives from the local filesystem using the same walker as HTTPOpener. Useful for re-runs without re-downloading.

func NewLocalFileOpener

func NewLocalFileOpener(walk WalkConfig) *LocalFileOpener

NewLocalFileOpener creates a LocalFileOpener that reads archives from local paths.

func (*LocalFileOpener) Stream

Stream opens the local file specified by job and emits its XML entries.

type MemorySink

type MemorySink struct {
	Records []PatentRecord
	// contains filtered or unexported fields
}

MemorySink is an in-process RecordSink for tests. Goroutine-safe.

func (*MemorySink) Close

func (m *MemorySink) Close() error

Close is a no-op for MemorySink.

func (*MemorySink) Write

func (m *MemorySink) Write(_ context.Context, batch []PatentRecord) error

type NoopCheckpointer

type NoopCheckpointer struct{}

NoopCheckpointer is a no-op Checkpointer used when the feature is disabled. IsCompleted always returns false; MarkCompleted is a no-op.

func (NoopCheckpointer) Close

func (NoopCheckpointer) Close() error

Close is a no-op.

func (NoopCheckpointer) HasAny

func (NoopCheckpointer) HasAny() (bool, error)

HasAny always returns false for a no-op checkpointer.

func (NoopCheckpointer) IsCompleted

func (NoopCheckpointer) IsCompleted(string) (bool, error)

IsCompleted always returns false for a no-op checkpointer.

func (NoopCheckpointer) MarkCompleted

func (NoopCheckpointer) MarkCompleted(string, map[string]string) error

MarkCompleted is a no-op.

type NoopExtractor

type NoopExtractor struct{}

NoopExtractor reads each entry to EOF and closes it without producing any PatentRecord. Use it when the goal is on-disk extraction only: reading the entry is what flushes any TeeReader installed by the walker.

func (NoopExtractor) Stream

Stream drains the entry to EOF without emitting any records.

type NoopJanitor

type NoopJanitor struct{}

NoopJanitor is the default Janitor; it does nothing.

func (NoopJanitor) OnArchiveDone

func (NoopJanitor) OnArchiveDone(context.Context, ArchiveJob, string, bool)

OnArchiveDone is a no-op.

func (NoopJanitor) OnEntryDone

func (NoopJanitor) OnEntryDone(context.Context, XMLEntry, string)

OnEntryDone is a no-op.

type NoopSink

type NoopSink struct{}

NoopSink discards record batches. Pair with NoopExtractor when the pipeline's job is only to persist archives / extracted files to disk.

func (NoopSink) Close

func (NoopSink) Close() error

Close is a no-op.

func (NoopSink) Write

func (NoopSink) Write(_ context.Context, _ []PatentRecord) error

Write discards the batch.

type Option

type Option func(*Options)

Option mutates Options.

func WithArchiveConcurrency

func WithArchiveConcurrency(n int) Option

WithArchiveConcurrency sets the number of archives processed in parallel. Values ≤ 0 are treated as 4.

func WithBatchSize

func WithBatchSize(n int) Option

WithBatchSize sets the target number of records per sink Write call. Values ≤ 0 are treated as 1000.

func WithBatchTimeout

func WithBatchTimeout(d time.Duration) Option

WithBatchTimeout sets the idle flush period for partial batches. A zero value is treated as 2s.

func WithExtractor

func WithExtractor(e RecordExtractor) Option

WithExtractor sets the XML record extractor. Required.

func WithExtractorConcurrency

func WithExtractorConcurrency(n int) Option

WithExtractorConcurrency sets the number of in-flight entries decoded in parallel. Auto-sizing is a caller concern (see PlanReaderConcurrency); this only applies a minimal safe fallback for values ≤ 0.

func WithJanitor

func WithJanitor(j Janitor) Option

WithJanitor sets the post-archive janitor. Default: NoopJanitor.

func WithLogger

func WithLogger(l *slog.Logger) Option

WithLogger sets the structured logger. Default: a discard logger (no output).

func WithOpener

func WithOpener(o ArchiveOpener) Option

WithOpener sets the archive opener. Required.

func WithProgress

func WithProgress(f ProgressFunc) Option

WithProgress sets the progress callback, called on every counter update. Default: no-op.

func WithSink

func WithSink(s RecordSink) Option

WithSink sets the record sink. Required.

func WithSource

func WithSource(s ArchiveSource) Option

WithSource sets the archive job source. Required.

type Options

type Options struct {
	// Source produces the stream of ArchiveJob values to process.
	Source ArchiveSource
	// Opener fetches or opens each archive and emits XMLEntry values.
	Opener ArchiveOpener
	// Extractor parses each XMLEntry into PatentRecord values.
	Extractor RecordExtractor
	// Sink consumes batched PatentRecord values (serialised, one goroutine).
	Sink RecordSink
	// Janitor receives per-archive and per-entry lifecycle notifications.
	// Defaults to NoopJanitor.
	Janitor Janitor
	// Logger receives structured pipeline events. Defaults to a discard logger.
	Logger *slog.Logger
	// Progress is called on every counter update. Defaults to no-op.
	Progress ProgressFunc
	// ArchiveConcurrency is the number of archives in flight simultaneously.
	// Default: 4.
	ArchiveConcurrency int
	// ExtractorConcurrency is the number of in-flight entries decoded in
	// parallel (one sequential, CPU-bound reader each) — the knob that
	// saturates cores. Auto-sizing (NumCPU clamped by a memory budget) is a
	// caller policy: resolve it with PlanReaderConcurrency before passing it
	// here. Values <= 0 fall back to a minimal safe default. Default: 4.
	ExtractorConcurrency int
	// BatchSize is the target number of records per sink Write call.
	// Default: 1000.
	BatchSize int
	// BatchTimeout is the maximum idle period before a partial batch is
	// flushed to the sink. Default: 2s.
	BatchTimeout time.Duration
}

Options configures a Pipeline. Use WithXxx helpers to populate it. Required fields: Source, Opener, Extractor, Sink. All other fields have safe defaults (see WithXxx documentation).

type ParquetSink

type ParquetSink struct {
	// contains filtered or unexported fields
}

ParquetSink writes PatentRecord batches to a single Parquet file.

Write must be called from a single goroutine (the pipeline already guarantees this). The sink calls Flush() once the count of pending records crosses flushEvery to bound memory; without it the writer holds every record until Close.

The file footer is only written at Close — the file remains unreadable as Parquet until then. For crash-recoverable output use a sharded sink (one file per archive).

func NewParquetSink

func NewParquetSink(path string, flushEvery int) (*ParquetSink, error)

NewParquetSink creates path and returns a sink writing the PatentRecord schema. flushEvery <= 0 selects DefaultRowGroupSize.

func (*ParquetSink) Close

func (s *ParquetSink) Close() error

Close flushes the Parquet footer and closes the underlying file. Must be called or the file will be unreadable. Idempotent.

func (*ParquetSink) Write

func (s *ParquetSink) Write(_ context.Context, batch []PatentRecord) error

Write appends batch. When pending records cross flushEvery, the current row group is finalised to disk.

type PatentRecord

type PatentRecord = parse.PatentRecord

PatentRecord is re-exported from internal/parse for caller convenience.

type Pipeline

type Pipeline struct {
	// contains filtered or unexported fields
}

Pipeline runs the configured stages end-to-end.

func New

func New(opts ...Option) (*Pipeline, error)

New constructs a Pipeline from functional options.

Example
package main

import (
	"context"

	"github.com/Qubut/epo-processor/internal/pipeline"
)

func main() {
	// Build a pipeline that discards every record (NoopExtractor + NoopSink).
	// In production, replace these with XMLStreamExtractor and ParquetSink.
	p, err := pipeline.New(
		pipeline.WithSource(&pipeline.StaticListSource{}),
		pipeline.WithOpener(pipeline.NewLocalFileOpener(pipeline.WalkConfig{})),
		pipeline.WithExtractor(&pipeline.NoopExtractor{}),
		pipeline.WithSink(&pipeline.NoopSink{}),
		pipeline.WithArchiveConcurrency(2),
		pipeline.WithBatchSize(500),
	)
	if err != nil {
		panic(err)
	}
	// Run returns when the source is exhausted, a stage errors, or ctx is cancelled.
	_ = p.Run(context.Background())
}

func (*Pipeline) Run

func (p *Pipeline) Run(ctx context.Context) (runErr error)

Run executes the pipeline and blocks until the source is exhausted, the first stage error occurs, or ctx is cancelled.

type ProgressFunc

type ProgressFunc func(Stats)

ProgressFunc receives a Stats snapshot on every counter update. Implementations must be cheap and goroutine-safe.

type RecordExtractor

type RecordExtractor interface {
	Stream(ctx context.Context, entry XMLEntry) rill.Stream[PatentRecord]
}

RecordExtractor turns one XMLEntry into a stream of PatentRecord values.

type RecordSink

type RecordSink interface {
	Write(ctx context.Context, batch []PatentRecord) error
	Close() error
}

RecordSink consumes record batches. Implementations need only be safe for a single consumer goroutine.

type StaticListSource

type StaticListSource struct{ Jobs []ArchiveJob }

StaticListSource emits a fixed slice of [ArchiveJob]s. Used by tests and CLI overrides.

func (*StaticListSource) Stream

Stream emits all configured jobs on a channel and then closes it.

type Stats

type Stats struct {
	Archives int64 // total archives fully drained
	Entries  int64 // total XML entries emitted across all archives
	Records  int64 // total PatentRecord values produced by extractors
	Batches  int64 // total Write calls delivered to the sink
}

Stats is a monotonically-increasing snapshot of pipeline counters. All fields are updated atomically and safe to read from any goroutine.

type WalkConfig

type WalkConfig struct {
	// SpoolDir is where zip archives are spooled (zip needs random access).
	// Tar / tar.gz never use it. Defaults to OS temp.
	SpoolDir string

	// EntrySelector decides whether an entry is emitted to the extractor.
	// Nested archives are always recursed regardless. Defaults to IsXML.
	EntrySelector func(name string) bool

	// KeepExtracted, when true, tees each selected entry's bytes to a
	// file under ExtractedDir while the consumer reads it.
	KeepExtracted bool

	// ExtractedDir receives kept entry files. Required when KeepExtracted is true.
	ExtractedDir string
}

WalkConfig parameterises the archive walker. All fields are optional; zero values give the defaults documented per field.

type XMLEntry

type XMLEntry struct {
	ArchiveName string    // name of the enclosing archive (may be a "!" chain for nested archives)
	Name        string    // path of this entry within the archive
	Size        int64     // payload byte count
	Reader      io.Reader // in-memory entry body; independent of the container stream
	// contains filtered or unexported fields
}

XMLEntry is a single XML payload found inside an archive. The walker buffers the (small) payload in memory and emits it without holding the container stream open, so entries from one archive can be parsed concurrently. Calling XMLEntry.Close is still required (it is a no-op for buffered entries) to keep the consumer contract uniform.

func (*XMLEntry) Close

func (e *XMLEntry) Close() error

Close releases the entry. Safe to call on a nil receiver.

type XMLStreamExtractor

type XMLStreamExtractor struct {
	// ElementXPath selects the streamed element. Defaults to the EPO contract.
	ElementXPath string
	// ParseConcurrency is the number of goroutines that run the CPU-bound
	// per-document parse in parallel within a single entry. Because the
	// reader feeds nodes sequentially, a small value (just enough to overlap
	// parse with read) is sufficient; large per-entry values only
	// over-subscribe (N entries x ParseConcurrency goroutines). Values <= 0
	// default to defaultParseConcurrency.
	ParseConcurrency int
}

XMLStreamExtractor streams <exchange-document> nodes from an XMLEntry, keeping memory bounded to a single element. It owns the entry's lifetime and closes it when done.

func NewXMLStreamExtractor

func NewXMLStreamExtractor() *XMLStreamExtractor

NewXMLStreamExtractor returns an extractor pre-configured for EPO documents.

func (*XMLStreamExtractor) Stream

Stream emits one PatentRecord per matched element. Reading the entry's XML is sequential (a single decoder owns the entry and its lifetime), but the expensive per-document parse is fanned across parseConcurrency workers so a single big entry still uses every core. Per-document parse errors surface as stream errors and are downgraded/dropped by the pipeline's Catch stage, so one bad document never aborts the entry.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL