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 ¶
- Constants
- func IsXML(name string) bool
- func PlanReaderConcurrency(requested, numCPU int, memBudgetBytes, perEntryBytes int64) int
- type ArchiveJob
- type ArchiveKind
- type ArchiveOpener
- type ArchiveSource
- type BoltCheckpointer
- type CheckpointJanitor
- type CheckpointSource
- type Checkpointer
- type EPOProductSource
- type HTTPOpener
- type Janitor
- type LocalFileOpener
- type MemorySink
- type NoopCheckpointer
- type NoopExtractor
- type NoopJanitor
- type NoopSink
- type Option
- func WithArchiveConcurrency(n int) Option
- func WithBatchSize(n int) Option
- func WithBatchTimeout(d time.Duration) Option
- func WithExtractor(e RecordExtractor) Option
- func WithExtractorConcurrency(n int) Option
- func WithJanitor(j Janitor) Option
- func WithLogger(l *slog.Logger) Option
- func WithOpener(o ArchiveOpener) Option
- func WithProgress(f ProgressFunc) Option
- func WithSink(s RecordSink) Option
- func WithSource(s ArchiveSource) Option
- type Options
- type ParquetSink
- type PatentRecord
- type Pipeline
- type ProgressFunc
- type RecordExtractor
- type RecordSink
- type StaticListSource
- type Stats
- type WalkConfig
- type XMLEntry
- type XMLStreamExtractor
Examples ¶
Constants ¶
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 ¶
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 ¶
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 ¶
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 ¶
func (CheckpointJanitor) OnEntryDone(context.Context, XMLEntry, string)
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 ¶
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 ¶
func (s *EPOProductSource) Stream(ctx context.Context) rill.Stream[ArchiveJob]
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 ¶
func (l *LocalFileOpener) Stream(ctx context.Context, job ArchiveJob) rill.Stream[XMLEntry]
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) 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) 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 ¶
func (NoopExtractor) Stream(_ context.Context, e XMLEntry) rill.Stream[PatentRecord]
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.
type Option ¶
type Option func(*Options)
Option mutates Options.
func WithArchiveConcurrency ¶
WithArchiveConcurrency sets the number of archives processed in parallel. Values ≤ 0 are treated as 4.
func WithBatchSize ¶
WithBatchSize sets the target number of records per sink Write call. Values ≤ 0 are treated as 1000.
func WithBatchTimeout ¶
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 ¶
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 ¶
WithJanitor sets the post-archive janitor. Default: NoopJanitor.
func WithLogger ¶
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 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 ¶
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())
}
Output:
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 ¶
func (s *StaticListSource) Stream(_ context.Context) rill.Stream[ArchiveJob]
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.
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 ¶
func (x *XMLStreamExtractor) Stream(ctx context.Context, e XMLEntry) rill.Stream[PatentRecord]
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.