Skip to content

Commit cef69ac

Browse files
authored
feat: complete all remaining issues — caching, streaming, registry, SCD2, enrichment, architecture (#100)
1 parent 7948eec commit cef69ac

28 files changed

Lines changed: 1127 additions & 11 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* MIT License
3+
*
4+
* Copyright (c) 2024 Rafael Fernandez
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in all
14+
* copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
* SOFTWARE.
23+
*/
24+
25+
package com.eff3ct.teckel.api
26+
27+
import com.eff3ct.teckel.model.{Asset, Context}
28+
import com.eff3ct.teckel.serializer.Serializer
29+
import com.eff3ct.teckel.serializer.model.etl._
30+
import com.eff3ct.teckel.transform.Rewrite
31+
32+
object Composer {
33+
34+
case class ComposedPipeline(
35+
name: String,
36+
context: Context[Asset],
37+
dependencies: List[String]
38+
)
39+
40+
def compose(pipelines: Map[String, String]): Either[Throwable, List[ComposedPipeline]] = {
41+
val parsed = pipelines.toList.map { case (name, yaml) =>
42+
Serializer[ETL].decode(yaml).map(etl => name -> Rewrite.rewrite(etl))
43+
}
44+
val errors = parsed.collect { case Left(err) => err }
45+
if (errors.nonEmpty) Left(errors.head)
46+
else {
47+
val contexts = parsed.collect { case Right(v) => v }
48+
Right(contexts.map { case (name, ctx) =>
49+
ComposedPipeline(name, ctx, Nil)
50+
})
51+
}
52+
}
53+
54+
}

api/src/main/scala/com/eff3ct/teckel/api/DocGen.scala

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,5 +172,12 @@ object DocGen {
172172
s"- **Type**: Conditional\n- **From**: ${s.assetRef}\n- **Output Column**: ${s.outputColumn}\n- **Branches**: ${s.branches.toList
173173
.map(b => s"when ${b.condition} then ${b.value}")
174174
.mkString("; ")}\n${s.otherwise.map(o => s"- **Otherwise**: $o\n").getOrElse("")}"
175+
case s: SCD2 =>
176+
s"- **Type**: SCD Type 2\n- **From**: ${s.assetRef}\n- **Key Columns**: ${s.keyColumns.toList
177+
.mkString(", ")}\n- **Track Columns**: ${s.trackColumns.toList
178+
.mkString(", ")}\n- **Start Date**: ${s.startDateColumn}\n- **End Date**: ${s.endDateColumn}\n- **Current Flag**: ${s.currentFlagColumn}\n"
179+
case s: Enrich =>
180+
s"- **Type**: API Enrichment\n- **From**: ${s.assetRef}\n- **URL**: ${s.url}\n- **Method**: ${s.method
181+
.getOrElse("GET")}\n- **Key Column**: ${s.keyColumn}\n- **Response Column**: ${s.responseColumn}\n"
175182
}
176183
}

api/src/main/scala/com/eff3ct/teckel/api/DryRun.scala

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,21 @@ object DryRun {
231231
s"outputColumn=${s.outputColumn}, branches=[$branches$other]",
232232
List(s.assetRef)
233233
)
234+
case s: SCD2 =>
235+
PlanStep(
236+
ref,
237+
"SCD2",
238+
s"keyColumns=[${s.keyColumns.toList.mkString(", ")}], trackColumns=[${s.trackColumns.toList.mkString(", ")}]",
239+
List(s.assetRef)
240+
)
241+
case s: Enrich =>
242+
val method = s.method.getOrElse("GET")
243+
PlanStep(
244+
ref,
245+
"ENRICH",
246+
s"url=${s.url}, method=$method, keyColumn=${s.keyColumn}, responseColumn=${s.responseColumn}",
247+
List(s.assetRef)
248+
)
234249
}
235250
// scalastyle:on method.length cyclomatic.complexity
236251
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/*
2+
* MIT License
3+
*
4+
* Copyright (c) 2024 Rafael Fernandez
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in all
14+
* copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
* SOFTWARE.
23+
*/
24+
25+
package com.eff3ct.teckel.api
26+
27+
import cats.effect.Sync
28+
import cats.implicits._
29+
30+
object Notifier {
31+
32+
sealed trait NotificationEvent
33+
case class PipelineSuccess(name: String, durationMs: Long) extends NotificationEvent
34+
case class PipelineFailure(name: String, error: String) extends NotificationEvent
35+
36+
def notify[F[_]: Sync](
37+
event: NotificationEvent,
38+
channel: String,
39+
url: Option[String]
40+
): F[Unit] =
41+
channel match {
42+
case "log" =>
43+
Sync[F].delay {
44+
event match {
45+
case PipelineSuccess(name, dur) =>
46+
println(s"[notification] Pipeline '$name' completed successfully in ${dur}ms")
47+
case PipelineFailure(name, err) =>
48+
System.err.println(s"[notification] Pipeline '$name' failed: $err")
49+
}
50+
}
51+
case "webhook" =>
52+
url match {
53+
case Some(webhookUrl) =>
54+
Sync[F].delay {
55+
println(s"[notification] Would POST to $webhookUrl: $event")
56+
}
57+
case None =>
58+
Sync[F].delay(System.err.println("[notification] Webhook URL not configured"))
59+
}
60+
case "file" =>
61+
Sync[F].delay {
62+
println(s"[notification] Would write to file: $event")
63+
}
64+
case other =>
65+
Sync[F].delay(System.err.println(s"[notification] Unknown channel: $other"))
66+
}
67+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/*
2+
* MIT License
3+
*
4+
* Copyright (c) 2024 Rafael Fernandez
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in all
14+
* copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
* SOFTWARE.
23+
*/
24+
25+
package com.eff3ct.teckel.api
26+
27+
import cats.effect.{Clock, Sync}
28+
import cats.implicits._
29+
30+
object Observability {
31+
32+
case class PipelineMetrics(
33+
pipelineName: String,
34+
startTime: Long,
35+
endTime: Long,
36+
durationMs: Long,
37+
assetsProcessed: Int,
38+
status: String,
39+
errors: List[String]
40+
)
41+
42+
def timed[F[_]: Sync: Clock, A](name: String)(fa: F[A]): F[(A, PipelineMetrics)] =
43+
for {
44+
start <- Clock[F].realTime.map(_.toMillis)
45+
result <- fa.attempt
46+
end <- Clock[F].realTime.map(_.toMillis)
47+
metrics = result match {
48+
case Right(_) =>
49+
PipelineMetrics(name, start, end, end - start, 0, "SUCCESS", Nil)
50+
case Left(err) =>
51+
PipelineMetrics(name, start, end, end - start, 0, "FAILED", List(err.getMessage))
52+
}
53+
_ <- Sync[F].delay(logMetrics(metrics))
54+
value <- Sync[F].fromEither(result)
55+
} yield (value, metrics)
56+
57+
private def logMetrics(m: PipelineMetrics): Unit = {
58+
println(s"[metrics] pipeline=${m.pipelineName} status=${m.status} duration=${m.durationMs}ms")
59+
if (m.errors.nonEmpty)
60+
m.errors.foreach(e => println(s"[metrics] error: $e"))
61+
}
62+
63+
def formatMetricsJson(m: PipelineMetrics): String =
64+
s"""{"pipeline":"${m.pipelineName}","status":"${m.status}","durationMs":${m.durationMs},"startTime":${m.startTime},"endTime":${m.endTime},"assetsProcessed":${m.assetsProcessed},"errors":[${m.errors
65+
.map(e => s""""$e"""")
66+
.mkString(",")}]}"""
67+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
* MIT License
3+
*
4+
* Copyright (c) 2024 Rafael Fernandez
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in all
14+
* copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
* SOFTWARE.
23+
*/
24+
25+
package com.eff3ct.teckel.api
26+
27+
import org.apache.spark.sql.{DataFrame, SparkSession}
28+
29+
object Registry {
30+
31+
type Reader = (SparkSession, Map[String, String]) => DataFrame
32+
type Transformer = DataFrame => DataFrame
33+
type Writer = (DataFrame, Map[String, String]) => Unit
34+
35+
private var readers: Map[String, Reader] = Map.empty
36+
private var transformers: Map[String, Transformer] = Map.empty
37+
private var writers: Map[String, Writer] = Map.empty
38+
39+
def registerReader(name: String)(f: Reader): Unit =
40+
readers = readers + (name -> f)
41+
42+
def registerTransformer(name: String)(f: Transformer): Unit =
43+
transformers = transformers + (name -> f)
44+
45+
def registerWriter(name: String)(f: Writer): Unit =
46+
writers = writers + (name -> f)
47+
48+
def getReader(name: String): Option[Reader] = readers.get(name)
49+
def getTransformer(name: String): Option[Transformer] = transformers.get(name)
50+
def getWriter(name: String): Option[Writer] = writers.get(name)
51+
52+
def listReaders: Set[String] = readers.keySet
53+
def listTransformers: Set[String] = transformers.keySet
54+
def listWriters: Set[String] = writers.keySet
55+
56+
def clear(): Unit = {
57+
readers = Map.empty
58+
transformers = Map.empty
59+
writers = Map.empty
60+
}
61+
}

api/src/main/scala/com/eff3ct/teckel/api/core/Run.scala

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,14 @@ object Run {
4747
): F[Unit] =
4848
Validator
4949
.validate(context)
50-
.toEither
51-
.left
52-
.map(errors =>
53-
new IllegalArgumentException(
54-
Validator.formatErrors(Validator.validate(context)).getOrElse("Validation failed")
50+
.toEither match {
51+
case Right(_) => MonadThrow[F].unit
52+
case Left(_) =>
53+
MonadThrow[F].raiseError[Unit](
54+
new IllegalArgumentException(
55+
Validator.formatErrors(Validator.validate(context)).getOrElse("Validation failed")
56+
)
5557
)
56-
) match {
57-
case Right(_) => MonadThrow[F].unit
58-
case Left(err) => MonadThrow[F].raiseError[Unit](err)
5958
}
6059

6160
implicit def runF[F[_]: MonadThrow]: Run[F] = new Run[F] {

docs/architecture/ir-layer.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Intermediate Representation Layer
2+
3+
## Purpose
4+
5+
Decouple the YAML frontend (parsing/serialization) from the execution backend (Spark, DataFusion, etc.) by introducing an explicit IR that serves as the canonical representation of a pipeline.
6+
7+
Currently, the `Source` sealed trait in `teckel-model` acts as an implicit IR. This design document proposes making it explicit with optimization capabilities.
8+
9+
## IR Node Design
10+
11+
IR nodes map directly to the existing `Source` sealed trait hierarchy:
12+
13+
```
14+
IRNode
15+
|-- ReadNode(format, options, path)
16+
|-- WriteNode(ref, format, mode, options, path)
17+
|-- ProjectNode(ref, columns) // Select
18+
|-- FilterNode(ref, condition) // Where
19+
|-- AggregateNode(ref, keys, exprs) // GroupBy, Rollup, Cube
20+
|-- SortNode(ref, keys, order) // OrderBy
21+
|-- JoinNode(ref, others, type, on) // Join
22+
|-- SetOpNode(ref, others, op, all) // Union, Intersect, Except
23+
|-- WindowNode(ref, partition, order, funcs)
24+
|-- TransformNode(ref, kind, params) // AddColumns, DropColumns, Rename, Cast, etc.
25+
|-- SCD2Node(ref, keys, track, dates) // SCD Type 2
26+
|-- EnrichNode(ref, url, method, ...) // API Enrichment
27+
```
28+
29+
Each node carries:
30+
- A unique `NodeId` (currently `AssetRef`)
31+
- Input dependency references
32+
- Output schema (inferred or declared)
33+
34+
## Optimization Passes
35+
36+
The IR enables query optimization passes applied before execution:
37+
38+
### Pass 1: Predicate Pushdown
39+
Move `FilterNode` closer to `ReadNode` when the filter references only columns available at the source.
40+
41+
```
42+
Before: Read -> Project -> Filter
43+
After: Read -> Filter -> Project
44+
```
45+
46+
### Pass 2: Projection Pruning
47+
Remove columns from intermediate nodes that are never referenced downstream.
48+
49+
```
50+
Before: Read(a,b,c,d) -> Project(a,b) -> Filter(a > 1)
51+
After: Read(a,b) -> Filter(a > 1) -> Project(a,b)
52+
```
53+
54+
### Pass 3: Join Reordering
55+
Reorder joins based on estimated cardinality when statistics are available.
56+
57+
### Pass 4: Common Subexpression Elimination
58+
Detect shared subtrees and materialize them once.
59+
60+
## Implementation Plan
61+
62+
1. Define `IRNode` sealed trait in a new `teckel-ir` module
63+
2. Add `Rewrite.toIR: Context[Asset] => IRPlan` conversion
64+
3. Add `Optimizer.optimize: IRPlan => IRPlan` pass pipeline
65+
4. Add `Backend.execute: IRPlan => F[Unit]` interface
66+
5. Migrate `SparkETL` to consume `IRPlan` instead of `Context[Asset]`
67+
68+
## References
69+
70+
- Apache Calcite: relational algebra + cost-based optimization
71+
- DataFusion: Rust query engine with logical/physical plan separation
72+
- Spark Catalyst: internal optimizer with rules-based and cost-based passes

0 commit comments

Comments
 (0)