I have spent years in Scala and Spark codebases, and I know where they hurt. The code is hard to read and seems impossible to improve or move to new versions. The ecosystem is split between Spark, ZIO, Cats and the rest. Monitoring, control and audit are hard, and hardest for Spark jobs. This page names those problems and shows how I solve them: by making what the code does explicit, one part at a time, until systems on different platforms, and even in different languages, become one system that can be watched and controlled.
None of these is anyone's fault. They are what years of growth do to a codebase and to an ecosystem.
Implicits several layers deep, stacks of monad transformers, business logic mixed with I/O, and Spark jobs thousands of lines long. It works, but nobody can say what it does without running it. Moving it from Scala 2.12 to 2.13 or 3, or from Spark 3 to 4, looks like a rewrite, so it does not happen.
ZIO, cats-effect, Future, kyo and monix each bring their own effect type, their own way of handling errors and resources, and their own community. A company ends up with several of them in one system, glue code between them, and teams that cannot read each other's code.
What a service calls, what a job reads and writes, which external systems it reaches: all of this is reconstructed from logs, metrics and listeners added afterwards. Changing a policy, such as a limit, a retry or a masking rule, means changing the code everywhere.
The event log says that a job ran. It does not prove what the job produced. Tests need a Spark session, so the logic is tested late or not at all. Scala 3 types do not fit Spark's encoders, which pushes teams to stay on old versions.
JVM services, Python data science, Go and Rust services, a TypeScript front end. Each has its own way of calling the world, so there is no single place to see, limit or audit what the whole system does.
One idea runs through all of it. What code does to the world (a call, a read, a write, a payment, a model query) becomes an explicit operation, and a handler carries it out. That gives each action a place where it can be seen, tested, controlled and recorded, and the logic around it stays as it is.
Wrap the code's dependencies as effects at its existing seams. The logic keeps its shape and becomes testable with handlers instead of mocks and containers. Then improve it piece by piece, and split it into services along the same boundaries when that pays. okay-simple is one interface for Scala 2.13 and Scala 3, from 3.3 LTS on. One application can mix code compiled by both, so an upgrade happens module by module, not in one big step.
Teams keep their libraries, and their code meets in one program. Below, a value comes from a ZIO service, goes to a cats-effect service, and its answer goes back to ZIO. ZIO's tasks and Cats' IO are two effects of one row.
One handler runs each IO as a ZIO task, so the whole program becomes one ZIO, run by ZIO's runtime. The same program could become an IO instead: only the handler would change.
import zio.{Runtime, Task, Unsafe, ZIO}
import cats.effect.IO
import cats.effect.unsafe.implicits.global
import okay.effects.*
import okay.effects.std.*
import okay.effects.ziointerop.*
// ZIO's tasks and cats-effect's IO, in one row
type IOs[X] = Lift[IO, X]
type Both[X] = (Tasks + IOs)[X]
def loadUser(id: Int): Task[String] = ZIO.succeed(s"user-$id") // a ZIO service
def riskScore(user: String, amount: Int): IO[Int] = IO(amount / 100) // a cats-effect service
def decide(user: String, score: Int): Task[String] = // a ZIO service again
ZIO.succeed(if score > 50 then s"$user: review" else s"$user: approve")
// one program: from ZIO, into Cats, its answer back into ZIO
def check(id: Int, amount: Int): String ! Both =
for
user <- fromZIOTask[String, Both](loadUser(id))
score <- lift[IO, Int, Both](riskScore(user, amount))
decision <- fromZIOTask[String, Both](decide(user, score))
yield decision
// one handler: each IO run as a ZIO task, so the whole program is one ZIO
def ioAsZio[A](p: A ! Both): A ! Tasks =
handleLast[IOs, Tasks, A, A](p, new Handler[IOs, Tasks, A, A]:
def ret(a: A) = pure(a)
def op[X](op: IOs[X], k: X => A ! Tasks) = op match
case Lift.Of(io) => fromZIOTask[X, Tasks](ZIO.fromFuture(_ => io.unsafeToFuture())).flatMap(k))
// the result: a ZIO, run by ZIO's runtime
val zio: Task[List[String]] = toZIOTask(ioAsZio(for a <- check(7, 2500); b <- check(8, 9000) yield List(a, b)))
Unsafe.unsafe { implicit u => Runtime.default.unsafe.run(zio).getOrThrow() }
// List(user-7: approve, user-8: review)
Compiled and run against the released 0.1.0 (okay-effects-zio, with cats-effect) on Scala 3.3 and 3.9.
More is available: a ZIO's environment, typed error and run as standard effects, with its layers and cancellation in both directions; ZIO's own types as effects; cats' Monad for any program; kyo, and fs2, ZIO and monix streams both ways. See interop.
Spark jobs in Scala 3, today: Scala 3 types, enums and sums included, become DataFrames, where Spark's own encoders do not compile in Scala 3 (how). Business rules written with effects run inside Spark's tasks (how). A Spark job written against Bulk runs on a cluster or in one JVM with the same answer, so its logic is tested without Spark. Scala 3 types become DataFrames through SparkSchema. Every output can come with evidence: per partition, a count and a SHA-256 digest, committed only after the sink acknowledges it. The details are below.
An interceptor sees each operation of an effect and can record, change or refuse it. An observer sees every operation of every effect. A sensitive effect, such as payments, cannot run outside its audit, and the compiler checks that. okay-audit checks in the bytecode, at every build, that business code reaches the outside world only through declared effects.
A policy is changed in one handler, not across the codebase. See what this means for compliance.
The same design exists for Java, Go, Rust, Python and TypeScript. okay-kernel is a protocol between them: a program written in one language runs under handlers written in another, in one process or over a wire. In the demo, a shop written in Go has its inventory, payments and mail answered by Scala handlers. Every operation that crosses has a name and a type, so the controls above apply to it wherever it came from. The whole company's software can be watched, limited and audited at one point.
Spark is published for Scala 2.13 only. A Scala 3 build can use it ("org.apache.spark" %% "spark-sql" % "4.2.0" cross CrossVersion.for3Use2_13), and its untyped API, RDDs and DataFrames, works.
Its typed side does not. import spark.implicits._, toDF, Dataset[T] and as[T] derive their encoders by Scala 2 TypeTag reflection, which Scala 3 does not have. In a Scala 3 build, this line does not compile:
import spark.implicits.*
Seq(("acct-1", 100L), ("acct-2", 250L)).toDF("account", "amount")
// Scala 3:
// [error] value toDF is not a member of Seq[(String, Long)]
That is a large part of why Spark codebases stay on Scala 2.12 and 2.13. Even in Scala 2, Spark's encoders do not take enums or sealed sums, so domain types are flattened into strings and nullable columns by hand.
SparkSchema derives the StructType and the rows from an okay Schema[A], which a Scala 3 type gets with derives Schema. It handles the types Spark's encoders cannot:
Option, List and BigInt.import okay.codec.Schema
import okay.spark.SparkSchema
enum Credential derives Schema:
case KeyHash(hash: Array[Byte])
case ScriptHash(hash: Array[Byte])
final case class Output(id: Int, owner: Credential, lovelace: BigInt) derives Schema
val df = SparkSchema.dataFrame(spark, Seq(
Output(1, Credential.KeyHash(Array[Byte](1)), BigInt(2_000_000)),
Output(2, Credential.ScriptHash(Array[Byte](2)), BigInt(5_000_000))))
// owner: struct<kind: string, KeyHash: struct<hash: binary>, ScriptHash: struct<hash: binary>>
df.createOrReplaceTempView("outputs")
spark.sql("SELECT id, lovelace FROM outputs WHERE owner.kind = 'ScriptHash'").collect()
// Array([2,5000000])This is the example from okay-spark's documentation, and okay's test TestDocExamplesSparkSchema runs it. With it, SparkFrames.load[A](df) and frame[A](table) move typed tables in and out of a program, and a Bulk program runs on a cluster or in one JVM.
The jobs move to Scala 3 module by module. Spark stays as it is, with no wait for a Scala 3 build of Spark.
Enums, sealed sums and the rest of Scala 3 can be used in jobs. The schema follows the type, and it evolves safely when a case is added.
The result is an ordinary DataFrame, so spark.sql, Parquet, Delta and every Spark tool see normal columns and structs.
Effects work on Spark at two levels.
The job's reads and writes are operations of an effect, its I/O is one handler and its audit is another, as shown in adding audit to an existing job. That is where datasets are recorded, policies refuse what a job may not do, and tests replace the lake.
Business rules can be written with effects too, and run per record inside Spark's tasks. A rule that may refuse a record (Error) and add a note (Writer) is a small program. Its handlers turn each record's outcome into a value:
final case class Trade(account: String, amount: Long)
// a business rule, written once with effects: a raise refuses, a tell notes
type Check[X] = (Error % String + Writer % String)[X]
object rules extends std.On[Check]:
def check(t: Trade): Trade ! Check =
for
_ <- if t.amount <= 0 then raise(s"${t.account}: amount ${t.amount} refused") else pure(())
_ <- if t.amount > 10000 then tell(s"${t.account}: large amount ${t.amount}") else pure(())
yield t
/** the rule handled: each record's outcome a value — refused, or passed with its notes */
def outcome(t: Trade): Either[String, (List[String], Trade)] = run(attempt[String](writer[String](rules.check(t))))
// the same rule in a plain unit test, no Spark
outcome(Trade("acct-1", -5)) // Left(acct-1: amount -5 refused)
outcome(Trade("acct-2", 20000)) // Right((List(acct-2: large amount 20000),Trade(acct-2,20000)))
// on Spark: outcome is shipped to the executors; each task builds, handles and runs one program per record,
// so the RDD holds plain values, Either[String, (List[String], Trade)], and Spark sees an ordinary function
val checked = trades.rdd.map(r => Trade(r.getString(0), r.getLong(1))).map(outcome)
// on the driver, in this demo: the outcomes collected and printed
val (refused, passed) = checked.collect().partition(_.isLeft)
println("refused: " + refused.map(_.left.toOption.get).mkString("; ")) // refused: acct-3: amount -7 refused
println("notes: " + passed.flatMap(_.toOption.get._1).mkString("; ")) // notes: acct-2: large amount 25000
println("passed: " + passed.map(_.toOption.get._2.account).mkString(", ")) // passed: acct-1, acct-2, acct-4
Where the effects are handled: in outcome, inside each task. rules.check(t) only describes a program. writer collects its notes into a list, attempt turns a refusal into a Left, and run runs what is left, which has no effects. So each record's result is a plain value by the time it leaves the task. In a real job the three outcomes go to their own tables straight from the RDD, with no collect: shown below.
The rule is one piece of code. It is unit-tested on its own, it runs on the executors unchanged, and it can be audited or have its policy changed by another handler, without touching the job. This was run on Spark 4.2.0 (local[2]) with okay-effects 0.1.0. The program is in examples/spark-audit (Rules.scala).
The same rule, now in a job that handles its outcomes itself, with no collect. Each record's Either is routed into a dataset: refusals, notes and passed trades. The job then decides at its own level: with too many refusals it stops with raise, and the passed trades are not published.
Effects work at both levels at once. The rule runs on the executors, per record. The job's reads, writes and refusal are effects on the driver, under the same audit handler, so every dataset written is in the trail with its row count and digest.
object daily:
// the job's own effects: the lake, and a refusal of the whole run
type Job[X] = (Lake + Error % String)[X]
def read(path: String): DataFrame ! Job = perform[Lake](Lake.Read(path))
def write(df: DataFrame, path: String): Unit ! Job = perform[Lake](Lake.Write(df, path))
def stop(why: String): Unit ! Job = raise[String, Job, Unit](why)
private val reasons = StructType(Seq(StructField("reason", StringType)))
private val tradesSchema = StructType(Seq(StructField("account", StringType), StructField("amount", LongType)))
/** the rule per record on the executors; its outcomes split, decided on and written, all in the job */
def checkTrades(lake: String, day: String, maxRefused: Long): Unit ! Job =
for
trades <- read(s"$lake/trades/day=$day")
spark = trades.sparkSession
checked = trades.rdd.map(r => Trade(r.getString(0), r.getLong(1))).map(outcome).cache()
refused = spark.createDataFrame(checked.flatMap(_.left.toOption).map(Row(_)), reasons)
notes = spark.createDataFrame(checked.flatMap(_.toOption.toList.flatMap(_._1)).map(Row(_)), reasons)
passed = spark.createDataFrame(checked.flatMap(_.toOption.map(_._2)).map(t => Row(t.account, t.amount)), tradesSchema)
_ <- write(refused, s"$lake/refused/day=$day")
_ <- write(notes, s"$lake/notes/day=$day")
n = refused.count()
_ <- if n > maxRefused then stop(s"$n refused, more than $maxRefused: passed trades not published")
else write(passed, s"$lake/passed/day=$day")
yield ()
// the handlers: the audit, Spark's I/O, and the run's refusal as an Either run(attempt[String](onSpark(spark)(audited(log, s"check-$day")(daily.checkTrades(lake, day, maxRefused = 5))))) // Right(()) // read …/trades/day=2026-10-18: 4 rows, struct<account:string,amount:bigint> // write …/refused/day=2026-10-18: 1 rows, digest 889b1b6062f0c029… // write …/notes/day=2026-10-18: 1 rows, digest 1481fde4ce6ce393… // write …/passed/day=2026-10-18: 3 rows, digest e98689ed05d1bba8… run(attempt[String](onSpark(spark)(audited(log, s"check-$day")(daily.checkTrades(lake, day, maxRefused = 0))))) // Left(1 refused, more than 0: passed trades not published) // read …/trades/day=2026-10-18: 4 rows, struct<account:string,amount:bigint> // write …/refused/day=2026-10-18: 1 rows, digest 889b1b6062f0c029… // write …/notes/day=2026-10-18: 1 rows, digest 1481fde4ce6ce393…
Run on Spark 4.2.0 with okay-effects 0.1.0: examples/spark-audit (RulesJob.scala). With maxRefused = 5 the passed table holds [acct-1,100] [acct-2,25000] [acct-4,40]. With maxRefused = 0 it is not written, and the trail shows exactly what was.
An okay Aggregator (init, add, merge, present) maps onto Spark's zero, seqOp and combOp. The same value runs over a local collection, over an RDD, per key, or as a Dataset column. It is written and tested once.
import okay.spark.SparkInterop.* val stats = Aggregator.mean[Double].zip(Aggregator.variance[Double]) val (m1, v1) = stats.run(data) // locally val (m2, v2) = aggregate(sc.parallelize(data, 8))(stats) // the SAME value, distributed val byKey = aggregateByKey(pairs)(Aggregator.variance[Double]) ds.select(toSpark(stats).toColumn) // as a typed Dataset column
A job written against Bulk[D] does not name its platform. On SparkBulk it is a cluster job. On localBulk it runs in one JVM and gives the same answer, which is how it is tested. okay's own tests check the two against each other.
def revenue[D[_]](using B: Bulk[D]) =
B.csv("sales.csv").map(r => r("shop") -> r("amount").toLong)
.join(B.csv("shops.csv").map(r => r("id") -> r("city")))
.aggregate(Aggregator.groupBy((kv: (String, (Long, String))) => kv._2._2)(
Aggregator.sum[Long].contramap(_._2._1)))
revenue(using SparkBulk(spark)) // a cluster
revenue(using okay.localBulk) // one JVM, the same answer
SparkSchema turns an okay Schema[A] into a StructType and rows. That covers Scala 3 enums, which Spark's ExpressionEncoder does not see:
val df = SparkSchema.dataFrame(spark, Seq(
Output(1, Credential.KeyHash(Array[Byte](1)), BigInt(2_000_000)),
Output(2, Credential.ScriptHash(Array[Byte](2)), BigInt(5_000_000))))
// owner: struct<kind: string, KeyHash: struct<hash: binary>, ScriptHash: struct<hash: binary>>
df.createOrReplaceTempView("outputs")
spark.sql("SELECT id, lovelace FROM outputs WHERE owner.kind = 'ScriptHash'").collect()
// Array([2,5000000])With SparkFrames, a DataFrame-born table stays in Catalyst while it meets only structural operators: a predicate becomes a Column (a pushed filter on Parquet), and a join becomes a DataFrame join.
HyperLogLog, count-min and quantiles (okay-data) are aggregators, so approximate distinct counts and percentiles run distributed with no extra code.
okay-delta creates tables and appends to them through delta-kernel. Its loadId uses Delta's transaction identifier, so a load lands once even when it is retried. A small producer needs no cluster.
Spark's event log and listeners record what ran: jobs, stages, task times. They do not prove what came out. SparkEvidence does.
For each partition it records the partition's id, its successful task attempt, its row count and a length-framed SHA-256 digest of its records. It folds these into a run digest and hands the manifest to your sink.
The result is Committed only after the sink has durably committed and acknowledged.
object SparkEvidence:
trait Canonical[-A] extends Serializable:
def bytes(value: A): Array[Byte] // a record's deterministic bytes: yours
final case class Partition(id: Int, attempt: Long, count: Long, digest: String)
final case class Manifest(runId: String, partitions: Vector[Partition], digest: String)
final case class Receipt(output: String, acknowledgement: String)
final case class Committed(manifest: Manifest, receipt: Receipt)
def run[A](rdd: RDD[A], runId: String)(canonical: Canonical[A])(sink: Manifest => Receipt): Committed
How to add it to a job, with what it prints: step by step, below.
The same input, partition layout and record order give the same digest, so rerunning the job proves that the output is the same. Attempt ids are kept for diagnosis and never choose a winner.
A failed sink produces no Committed value, so evidence never claims an output that was not stored. Empty partitions are counted too.
Only counts and digests leave the tasks. Payloads and credentials do not enter task logs.
With SparkFrames, loading, reading and handing back a DataFrame are effects of the program. The audit-by-construction pattern then applies to a Spark job as it does to a bank.
A handler around them records each dataset read and written, intent first and result after, and the job's code does not change. Step by step, below. Together with the evidence manifest this answers the questions an auditor asks: what went in, what came out, and whether it can be shown to be the same again.
| Asks for | Where | What answers it |
|---|---|---|
| Accurate, complete risk data, reconcilable to its sources | BCBS 239, principles 3 and 4 | Per-partition counts and digests, and a run digest to reconcile against |
| Records of transactions sufficient for the supervisor | MiFID II Art. 16(6); MiFIR Art. 25 | Evidence of each end-of-day output, and the job's reads and writes recorded as effects |
| Known data flows and ICT dependencies | DORA Art. 8 | Each dataset read and written, as a declared operation |
Durable storage, identity and access, signatures and regulatory policy stay with the application. The adapter commits summaries and does not claim atomicity with a separate Spark write. An application that needs both provides a sink that commits output and manifest in one transaction.
Take a job as it is today: read trades, total them per account, write the totals. Audit can be added in two steps, and the job's logic stays as it is in both.
The code below was compiled and run on Spark 4.2.0 (local[2], JDK 25) and Scala 3.3, with okay-effects 0.1.0 from its release and SparkEvidence from okay-spark. The outputs shown are what it printed.
def dailyTotalsToday(spark: SparkSession, lake: String, day: String): Unit =
val trades = spark.read.parquet(s"$lake/trades/day=$day")
val totals = trades.groupBy("account").agg(sum("amount").as("total"))
totals.write.mode("overwrite").parquet(s"$lake/totals/day=$day")
Give the output a deterministic layout: hash-partitioned by its key and sorted within each partition. The same data then always gives the same digest. Write it as before, and hand it to SparkEvidence, which records each partition's count and SHA-256 digest and a digest for the whole run.
The manifest goes to your sink: here a file beside the data, in production a ledger, a bucket with object lock, or a signed record. Later, anyone can read the stored output back, recompute the digest and compare.
val rowBytes: Canonical[Row] = row => row.mkString("\u001f").getBytes(UTF_8)
def stable(df: DataFrame): DataFrame = df.repartition(8, col("account")).sortWithinPartitions("account")
def dailyTotalsWithEvidence(spark: SparkSession, lake: String, day: String): Committed =
val trades = spark.read.parquet(s"$lake/trades/day=$day")
val totals = stable(trades.groupBy("account").agg(sum("amount").as("total")))
val out = s"$lake/totals/day=$day"
totals.write.mode("overwrite").parquet(out)
SparkEvidence.run(totals.rdd, runId = s"totals-$day")(rowBytes) { m =>
// the manifest beside the data; in production a ledger, a bucket with object lock, a signed record
java.nio.file.Files.writeString(java.nio.file.Path.of(s"$lake/totals-$day.evidence"), s"${m.digest} ${m.partitions.map(_.count).sum}")
Receipt(output = out, acknowledgement = m.digest)
}
/** later, by anyone: the stored output read back, its digest recomputed and compared */
def verify(spark: SparkSession, out: String, runId: String, expected: String): Boolean =
SparkEvidence.run(stable(spark.read.parquet(out)).rdd, runId)(rowBytes)(m => Receipt(out, m.digest)).manifest.digest == expected
Run as it ran:
dailyTotalsWithEvidence(spark, lake, day) // evidence: 3 rows, 8 partitions, digest 6616d58871415ec1… verify(spark, out, s"totals-$day", digest) // true // a row appended to the stored output by hand, then: verify(spark, out, s"totals-$day", digest) // false
Name what the job does to the outside world: it reads a dataset and writes one. These become two operations of an effect, Lake. The logic is the same lines, with read and write in place of Spark's calls:
enum Lake[X]:
case Read(path: String) extends Lake[DataFrame]
case Write(df: DataFrame, path: String) extends Lake[Unit]
def read(path: String): DataFrame ! Lake = perform[Lake](Lake.Read(path))
def write(df: DataFrame, path: String): Unit ! Lake = perform[Lake](Lake.Write(df, path))
/** the same job: its logic as it was, its reads and writes now operations */
def dailyTotals(lake: String, day: String): Unit ! Lake =
for
trades <- read(s"$lake/trades/day=$day")
totals = trades.groupBy("account").agg(sum("amount").as("total"))
_ <- write(stable(totals), s"$lake/totals/day=$day")
yield ()
One handler does the I/O, exactly as the job did before:
/** the handler doing the I/O: Spark, as before */
def onSpark[R[_], Rest[_], A](spark: SparkSession)(p: A ! R)(using Split[Lake, R, Rest]): A ! Rest =
handle(p, new TailHandler[Lake, Rest, A, A]:
def ret(a: A) = pure(a)
def answer[X](op: Lake[X]): X = op match
case Lake.Read(path) => spark.read.parquet(path)
case Lake.Write(df, path) => df.write.mode("overwrite").parquet(path))
The audit is another handler, put around the job. It records each read and write before it happens, then its result: row count, schema, and for each output its evidence digest. The job does not know it is audited, and Spark's handler does not know either.
trait AuditLog:
def append(line: String): Unit
/** the audit: every read and write recorded, intent first; each output with its evidence */
def audited[R[_], A](log: AuditLog, runId: String)(p: A ! R)(using In[Lake, R]): A ! R =
intercept(p, new Handler[Lake, R, A, A]:
def ret(a: A) = pure(a)
def op[X](op: Lake[X], k: X => A ! R) = op match
case Lake.Read(path) =>
log.append(s"read $path")
perform[Lake].apply[R, X](op).flatMap { df =>
log.append(s"read $path: ${df.count()} rows, ${df.schema.simpleString}"); k(df) }
case Lake.Write(df, path) =>
log.append(s"write $path")
perform[Lake].apply[R, X](op).flatMap { done =>
val e = SparkEvidence.run(df.rdd, runId)(rowBytes)(m => Receipt(path, m.digest)).manifest
log.append(s"write $path: ${e.partitions.map(_.count).sum} rows, digest ${e.digest.take(16)}…"); k(done) })
val log: AuditLog = line => auditTopic.send(line) // Kafka, a table, a ledger: the handler's choice run(onSpark(spark)(audited(log, s"totals-$day")(dailyTotals(lake, day))))
The audit trail of that run. Its digest is step 1's: the same data, proven the same.
read /tmp/spark-audit/lake/trades/day=2026-10-18 read /tmp/spark-audit/lake/trades/day=2026-10-18: 4 rows, struct<account:string,amount:bigint> write /tmp/spark-audit/lake/totals/day=2026-10-18 write /tmp/spark-audit/lake/totals/day=2026-10-18: 3 rows, digest 6616d58871415ec1…
Every dataset read and written, with row counts and schemas, in the order they happened, and the output's digest. The stored output can be checked against the digest at any time later.
The audit handler can also refuse an operation, for example a write outside the paths a job is allowed, or a read of a table a job may not see, before Spark runs it.
With its reads and writes as effects, the job's logic runs under a test handler that serves small DataFrames and records the writes, with no data lake behind it.
The full program, data set-up included, is about a hundred lines: examples/spark-audit (sbt run). Two things are left to the application: the canonical bytes of its records (here the row's fields joined) and where the manifest and the trail are kept.
Aggregator on RDDs, per key and as Dataset columnsSparkBulk: a Bulk program on a cluster, checked against one JVMSparkFrames and SparkSchemaSparkEvidenceokay-deltaSpark 4.2 on JDK 17, 21 and 25.
okay-spark is part of okay. It builds from source today; its release is planned with okay 0.2.0.
I do this work as a team member or as a contract consultant on Scala and JVM projects: contact.