Typed pipeline lib (Java 21, Gradle, Simplified Annotations). Java-8-Streams shape.
Stage<I,O> sealed (stage/Stage.java), 4 non-sealed permits:
SourceStage<O>-()->O(input =DataTypes.NONE)FilterStage<T>-List<T>->List<T>(subset)TransformStage<I,O>- 1:1 (alsoT->BOOLEANpredicates,I->JSON_OBJECTviaObjectBuildTransform)CollectStage<I,O>- terminal reduction (incl.MapCollect: named fan-outI->Map<String,Object>)
@StageSpec(id, displayName, description, category)on the class.idis the wire"kind".- Exactly one canonical
public static @NotNull XStage of(...)whose parameters all carry@Configurable(or a zero-argof()). Schema,config()and the load factory are derived from it byStageReflection- nofromConfig, no hand-writtenFieldSpec. Convenience overloads must have an unannotated parameter, or the lookup is ambiguous. - Implements
inputType/outputType/summary/execute @Getter(style = NamingStyle.FLUENT) @RequiredArgsConstructor(access=PRIVATE)(or@NoArgsConstructor(PRIVATE)stateless)if (input == null) return null;(rejection semantics)- List outputs:
Concurrent.newUnmodifiableList(...). Maps whose order is declared (named outputs, tables, results):Concurrent.newUnmodifiableLinkedMap(...), neverMap.copyOf. - Regex stages:
Pattern.compile(...)cached inof(...) - Validate everything in
ofand throwIllegalArgumentException: bodies, operands, enum-like strings, ranges.ofruns on load, so a refusal failsPipelineGson.fromJsonandStageMetadata.fromConfigas the exceptionofthrew -fromConfigunwraps the reflection library's. - JSON text is written with
PipelineGson.gson()(no HTML escaping).
The factory parameter's type picks the FieldSpec.Type:
| Parameter | Type | Wire |
|---|---|---|
String / int / long / double / boolean (boxed too) |
STRING / INT / LONG / DOUBLE / BOOLEAN |
JSON primitive; INT / LONG read exactly (a fraction or out-of-range value fails the load); a number slot takes a number or numeric text (a DOUBLE slot a finite one), BOOLEAN a JSON boolean or the text true / false - anything else fails the load |
DataType<?> |
DATA_TYPE |
label |
List<? extends Stage<?, ?>> / Chain |
SUB_PIPELINE |
stage array, no source |
Map<String, List<...>> / NamedChains |
SUB_PIPELINES_MAP |
object of stage arrays |
Map<String, TypedChain<?>> |
TYPED_SUB_PIPELINES_MAP |
object of {outputType, chain} |
DataPipeline<?> |
PIPELINE |
stage array whose stage 0 is a source (a pipeline file) |
Map<String, String> |
STRING_MAP |
object of strings, document order |
- The field named after a parameter holds that slot's config value -
config()reads it back. A stage that parses a string into another type stores the parsed value under a different name, or renames the parameter and keeps the wire key with@Configurable(name = "...").StageFieldConventionTestenforces this for every registered stage. - Optional slot:
@Configurable(optional = true)on a@Nullableparameter; absent or JSONnullon the wire meansnull. placeholdermust be a valueofaccepts:PipelineSerdeTest.everyNonChainKindFactoryRoundTripsbuilds each stage's default config from it (aSTRING_MAPplaceholder is a JSON object literal). It skips every stage with aSUB_PIPELINE,SUB_PIPELINES_MAP,TYPED_SUB_PIPELINES_MAPorPIPELINEslot, since a body or operand has no default, so no test hands the scalar placeholders of those stages toof- check them by hand.- Adding a
FieldSpec.Typebreaks every exhaustiveswitchover it (FieldSpec's own). The Discord UI'sStageFieldsgives native inputs to the types it lists and a JSON text input, read throughFieldSpec.readJson, to every other type, so a new type is collectable there without a case.
A PIPELINE slot carries a whole sourced pipeline that reads a second document (JoinByKeyTransform.right, KeyLookupTransform.table, ConcatTransform.other).
- In
of:DataPipeline.validate(expectedOutputType); on failure throwIllegalArgumentException("Invalid <Class> operand: " + report.issues()). Store it under the parameter's name (a narrowed generic is fine). - In
execute:ctx.evaluateOperand(operand)- runs it against the same context at most once per context, keyed by instance identity,nullheld too, reentrant for nested operands, a self-reaching operand throwsPipeline operand cycle detected, a throwing evaluation holds nothing, a read from a second thread waits for the evaluation under way, and every newPipelineContext(mutate().build()included) starts with an empty memo - so a host builds one context per run. - The value is shared by every read in the run: never mutate it - copy rows before changing them.
- A value derived from an operand once per run (an index, say) is built from
ctx.evaluateOperand(operand)and held by the context throughctx.derive(key, derivation), keyed by an object the stage owns (RowKeys.Indexkeys its index by itself). The derivation memo follows the operand memo's rules under the same lock - identity keys,nullheld, a throw holds nothing, a self-reaching derivation throwsDerived value cycle detected, a second thread waits - starts empty in every context,mutate().build()included, and reaches no tracer, so the value lives exactly as long as the context. Never hold it on the stage, which outlives its runs, and never wrap it as a stage of its own: every stage a pipeline runs reaches the tracer, which takes it for a registered@StageSpecstage.
XxxSource | XxxFilter | XxxTransform | XxxPredicate | XxxCollect. Filter/Predicate paired by name (ContainsFilter <-> ContainsPredicate).
Packages under stage/: source, filter/{string,list,numeric,dom,json}, transform/{string,primitive,list,dom,json,encoding}, predicate/{string,numeric,dom,json,common}, terminal/{collect,sum,average,minmax,match}.
StageRegistry scans dev.simplified.dataflow.stage (test classes included) for Stage subtypes carrying @StageSpec and indexes them by id; a duplicate id fails the static initialiser. Renaming an id breaks stored JSON. Adding a stage: the class with @StageSpec in the package its StageSpec.Category names - nothing else registers it.
StageSpec.Category declaration order: SOURCE first, TERMINAL_* last, rest alphabetical. UI relies on ordinal().
A sourceless body chain is a chain.Chain. Variants:
- Single body (
Map,FlatMap,SortBy,Min/MaxBy,DistinctBy,*Match,FindFirst,Take/DropWhile,Where,Expect,Compare,ReplaceMatch,Coalesce, binary arithmetic,Zip,Rotate,Broadcast):FieldSpec.Type.SUB_PIPELINE+StageConfig.subPipeline/getSubPipelinereturningChain. - Named bodies (
MapCollect,And/OrPredicate):FieldSpec.Type.SUB_PIPELINES_MAP+subPipelines/getSubPipelinesreturningchain.NamedChains. - Typed named bodies (
ObjectBuildTransform):FieldSpec.Type.TYPED_SUB_PIPELINES_MAP+typedSubPipelines/getTypedSubPipelinesreturningMap<String, chain.TypedChain>.
Chain owns: validate(seed, body, expected), execute(ctx, input), of(stages), builder(). Per-stage walks call T result = this.body.execute(ctx, element) directly. Validate every body in of; on failure throw IllegalArgumentException("Invalid <Class> body: " + report.issues()). An empty body fails Chain.validate. A named branch that yields null omits its name (MapCollect, ObjectBuildTransform).
Builder.build()validates eagerly, throwsIllegalStateExceptionon bad chainBuilder.validate()returnsValidationReport, no throwDataPipeline.validate(DataType)also reports a last stage whose output is not assignable to the given type (operand check)ValidationReportis(issues, expectations); only issues decideisValid().DataPipeline.validate()lists everyExpectTransformas anExpectationwith a wire path (#1.body[0],#1.outputs.id.chain[1],#1.right[0]), found by reading each@StageSpecstage's slots off the field named after the parameter - aChain,NamedChains,DataPipelineorTYPED_SUB_PIPELINES_MAPvalue is walked, anything else nests nothing. A stage that stores a body under another name hides the expectations inside it; a new slot type that holds stages needs a case inDataPipeline.collectExpectations. AnEmbedSourceholds only its saved pipeline's id, resolved at execute time, so validation lists none of that pipeline's expectations.Chain.validatereports issues alone.execute(ctx)does NOT re-validate (build-time guarantee)DataPipeline.empty().execute(ctx)returnsnull
Sealed: Basic<T>, ListType<E>, SetType<E>. Identity by label() - RAW_HTML ≠ STRING despite both being String-backed. Parameterised via DataTypes.byLabel("List<INT>").
Every "does this output satisfy that input / expected type" check is produced.isAssignableTo(expected), never equals - DataPipeline.validate, DataPipeline.validate(DataType), DataPipeline.expectOutput, Chain.validate (seed, each link, expected output). It widens JSON_OBJECT / JSON_ARRAY to JSON_ELEMENT and a List<X> / Set<X> to a List<Y> / Set<Y> when X is assignable to Y (read-only collections, so covariance is safe); nothing is converted at run time. Nothing else widens - no numeric widening, no RAW_* to STRING. A factory comparing a flowing type against an expected one uses it too; a factory checking its own declared type against a supported set (COMPARABLE_KEYS, SUPPORTED_*) does not.
Sort/Min/Max key types restricted to INT, LONG, FLOAT, DOUBLE, STRING; others rejected at build time.
Stages fetch through ctx.fetcher() (client UrlFetcher), never a client of their own. An optional maxBodyBytes (@Nullable Long) calls the capped overload only when set, so the fetcher's configured cap still applies otherwise. Every status outside the 2xx class raises: UrlFetchException.Redirection for every 3xx the fetcher does not follow (a known or unknown code; a redirect the transport follows reads through, and a 304 answering the fetcher's own revalidation replays the cached body), UrlFetchException.ClientError for 400-451 except Nginx's 444, plus any 4xx HttpStatus has no constant for outside Nginx's 494-499, and the base UrlFetchException for 444, 494-499, every 5xx and any other code. Catch ClientError by type to tell a 4xx apart, and read its code with getStatusCode() - RateLimited carries a synthetic 429 and is not one. A 2xx code HttpStatus has no constant for is a success, read as 200 and never cached. A status outside 2xx raises its own type whatever the size of its body; only a success body past the cap raises BodyCapExceeded.
TRANSFORM_FETCHrejects on aClientErrorexcept408and429, which rethrow: a timeout or throttling must not shorten a collection. ARedirectionis noClientError, so it fails the run.SOURCE_URLfails on every status outside 2xx.TRANSFORM_FETCHsendsURI.create(url).toASCIIString()of the substituted URL, so non-ASCII goes out as percent-encoded UTF-8. An unknown 2xx, and a5xxto a refresh of a stale cached copy inside itsstale-if-errorwindow as the fetch begins, which the client answers with the cached body, both reach the guard and are emitted; the guard sees no status.- Every successful body goes through
ctx.fetchGuard().check(uri, body)before the stage returns it, outside anyClientErrorcatch, so a guard throw fails the run and is never a dropped element. A new fetching stage does the same.
- Wire format:
{"kind":"X", ...config}viaserde/PipelineGson. Round-tripped byPipelineSerdeTest. - The loader is strict at every depth (
LoaderStrictnessTest), each refusal anIllegalArgumentException: a key besideskindthat the stage does not declare (Stage 'X' does not declare key 'k' (declared keys: [...])), so a misspelt optional key never reads as absent; a required slot absent or JSONnull(Stage 'X' is missing required key 'k'); a slot value of the wrong JSON shape (Field 'k' holds a JSON object but its type STRING takes a JSON primitive) or a scalar that does not read as its slot's type; aDATA_TYPElabel this build does not know (Field 'k' holds unknown DataType label 'x', orTyped sub-pipeline 'n' holds unknown DataType label 'x' under 'outputType'); a typed output entry with a key besidesoutputType/chain; an object naming a key twice (Pipeline JSON repeats key 'k' at '$[1].k'). JSONnullon an optional slot reads as absent.StageMetadata.fromConfigapplies the same declared and required checks to aStageConfig. - Tests: JUnit 5 + Hamcrest, one assertion per behavior, in the test package matching the stage's. Every stage gets a wire round trip (build ->
toJson->fromJson-> equal config and output).PipelineContext.defaults()for default fetcher / NOOP resolver. StageCatalogTestpins id, class and category; add a row for a new stage.- Fixture stages for framework tests live in
src/test/.../stage/fixtureand register on the test classpath. gradle testruns;gradle compileJava compileTestJavabuilds.
Imperative title <70 chars, per-feature where possible.