├── README.md ├── conf.scala ├── FSM.scala ├── .gitignore ├── SimpleEventProcessing.scala ├── .scalafmt.conf ├── flake.lock ├── main.scala ├── flake.nix └── ComplexEventProcessing.scala /README.md: -------------------------------------------------------------------------------- 1 | # scalar-feda 2 | 3 | Code examples presented at my Scalar 2023 talk. 4 | 5 | ### Run 6 | 7 | ```console 8 | $ nix run 9 | ``` 10 | 11 | Alternatively: 12 | 13 | ```console 14 | $ scala-cli run . 15 | ``` 16 | -------------------------------------------------------------------------------- /conf.scala: -------------------------------------------------------------------------------- 1 | //> using scala "3.3.0-RC3" 2 | //> using lib "co.fs2::fs2-core:3.6.1" 3 | //> using plugin "com.github.ghik:::zerowaste:0.2.5" 4 | //> using options "-Wunused:imports" 5 | //> using options "-Werror" 6 | //> using options "-Wvalue-discard" 7 | -------------------------------------------------------------------------------- /FSM.scala: -------------------------------------------------------------------------------- 1 | import cats.syntax.all.* 2 | import cats.{ Functor, Id } 3 | 4 | case class FSM[F[_], S, I, O](run: (S, I) => F[(S, O)]): 5 | def runS(using F: Functor[F]): (S, I) => F[S] = 6 | (s, i) => run(s, i).map(_._1) 7 | 8 | object FSM: 9 | def id[S, I, O](run: (S, I) => Id[(S, O)]) = FSM(run) 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | project/boot 2 | target 3 | .ensime 4 | .ensime_lucene 5 | .ensime_cache 6 | .ensime_snapshot 7 | ensime.sbt 8 | TAGS 9 | \#*# 10 | *~ 11 | .#* 12 | .lib 13 | .history 14 | .*.swp 15 | .idea 16 | .idea/* 17 | .idea_modules 18 | .DS_Store 19 | .sbtrc 20 | *.sublime-project 21 | *.sublime-workspace 22 | tests.iml 23 | # Auto-copied by sbt-microsites 24 | docs/src/main/tut/contributing.md 25 | .ignore 26 | result* 27 | project/metals.sbt 28 | .bloop/ 29 | .metals/ 30 | project/.bloop/ 31 | elm-stuff/ 32 | *elm.js 33 | *Main.js 34 | .scala 35 | .scala-build 36 | hello 37 | -------------------------------------------------------------------------------- /SimpleEventProcessing.scala: -------------------------------------------------------------------------------- 1 | import java.util.UUID 2 | 3 | object SimpleEventProcessing: 4 | type EventId = UUID 5 | type UserEmail = String 6 | 7 | enum UserEvent: 8 | case Registered(id: EventId, email: UserEmail) 9 | case Deleted(id: EventId, email: UserEmail) 10 | 11 | trait UserCache[F[_]]: 12 | def registerUser(email: UserEmail): F[Unit] 13 | def deleteUser(email: UserEmail): F[Unit] 14 | 15 | class UserEngine[F[_]](cache: UserCache[F]): 16 | def run: UserEvent => F[Unit] = 17 | case UserEvent.Registered(_, email) => 18 | cache.registerUser(email) 19 | case UserEvent.Deleted(_, email) => 20 | cache.deleteUser(email) 21 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | runner.dialect = scala3 2 | version = "3.6.1" 3 | maxColumn = 120 4 | 5 | newlines.source = keep 6 | 7 | rewrite { 8 | rules = [Imports] 9 | imports.sort = ascii 10 | } 11 | 12 | rewrite.scala3 { 13 | convertToNewSyntax = yes 14 | removeOptionalBraces = yes 15 | } 16 | 17 | fileOverride { 18 | "glob:**/build.sbt" { 19 | runner.dialect = scala213 20 | } 21 | "glob:**/project/**" { 22 | runner.dialect = scala213 23 | } 24 | } 25 | 26 | align { 27 | allowOverflow = true 28 | preset = more 29 | openParenCallSite = false 30 | stripMargin = true 31 | } 32 | 33 | continuationIndent { 34 | callSite = 2 35 | defnSite = 4 36 | } 37 | 38 | docstrings { 39 | style = Asterisk 40 | oneline = keep 41 | wrap = no 42 | } 43 | 44 | spaces { 45 | beforeContextBoundColon = Never 46 | inImportCurlyBraces = true 47 | } 48 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "locked": { 5 | "lastModified": 1678901627, 6 | "narHash": "sha256-U02riOqrKKzwjsxc/400XnElV+UtPUQWpANPlyazjH0=", 7 | "owner": "numtide", 8 | "repo": "flake-utils", 9 | "rev": "93a2b84fc4b70d9e089d029deacc3583435c2ed6", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "numtide", 14 | "repo": "flake-utils", 15 | "type": "github" 16 | } 17 | }, 18 | "nixpkgs": { 19 | "locked": { 20 | "lastModified": 1679410443, 21 | "narHash": "sha256-xDHO/jixWD+y5pmW5+2q4Z4O/I/nA4MAa30svnZKK+M=", 22 | "owner": "nixos", 23 | "repo": "nixpkgs", 24 | "rev": "c9ece0059f42e0ab53ac870104ca4049df41b133", 25 | "type": "github" 26 | }, 27 | "original": { 28 | "owner": "nixos", 29 | "ref": "nixpkgs-unstable", 30 | "repo": "nixpkgs", 31 | "type": "github" 32 | } 33 | }, 34 | "root": { 35 | "inputs": { 36 | "flake-utils": "flake-utils", 37 | "nixpkgs": "nixpkgs" 38 | } 39 | } 40 | }, 41 | "root": "root", 42 | "version": 7 43 | } 44 | -------------------------------------------------------------------------------- /main.scala: -------------------------------------------------------------------------------- 1 | import java.time.Instant 2 | 3 | import scala.concurrent.duration.* 4 | import scala.util.Random 5 | 6 | import cats.effect.* 7 | import fs2.Stream 8 | 9 | object ScalarTalk extends IOApp.Simple: 10 | import ComplexEventProcessing.* 11 | 12 | val events: List[PriceEvent | Tick] = List( 13 | PriceEvent.Created("EURUSD", BigDecimal(Random.nextInt(10)), Instant.now()), 14 | PriceEvent.Updated("EURUSD", BigDecimal(Random.nextInt(10)), Instant.now()), 15 | PriceEvent.Created("EURGBP", BigDecimal(Random.nextInt(10)), Instant.now()), 16 | Tick, 17 | PriceEvent.Created("EURPLN", BigDecimal(Random.nextInt(10)), Instant.now()), 18 | PriceEvent.Updated("EURGBP", BigDecimal(Random.nextInt(10)), Instant.now()), 19 | PriceEvent.Updated("EURUSD", BigDecimal(Random.nextInt(10)), Instant.now()), 20 | PriceEvent.Deleted("EURUSD", Instant.now()), 21 | Tick 22 | ) 23 | 24 | // val ticks: Stream[IO, Tick] = 25 | // Stream.fixedDelay[IO](2.seconds).as(Tick) 26 | 27 | // val events2: Stream[IO, PriceEvent] = ??? 28 | 29 | // val foo: IO[Unit] = runner(events2.merge(ticks)) 30 | 31 | def run: IO[Unit] = 32 | // assume events come from a Pulsar topic 33 | runner(Stream.emits(events).metered(300.millis)) 34 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Scalar 2023 FEDA talk examples"; 3 | 4 | inputs = { 5 | nixpkgs.url = github:nixos/nixpkgs/nixpkgs-unstable; 6 | flake-utils.url = github:numtide/flake-utils; 7 | }; 8 | 9 | outputs = { nixpkgs, flake-utils, ... }: 10 | flake-utils.lib.eachDefaultSystem (system: 11 | let 12 | jreOverlay = f: p: { 13 | jre = p.jdk19_headless; 14 | }; 15 | 16 | nativeOverlay = f: p: { 17 | scala-cli-native = p.symlinkJoin 18 | { 19 | name = "scala-cli-native"; 20 | paths = [ p.scala-cli ]; 21 | buildInputs = [ p.makeWrapper ]; 22 | postBuild = '' 23 | wrapProgram $out/bin/scala-cli \ 24 | --prefix LLVM_BIN : "${p.llvmPackages.clang}/bin" 25 | ''; 26 | }; 27 | }; 28 | pkgs = import nixpkgs { 29 | inherit system; 30 | overlays = [ jreOverlay nativeOverlay ]; 31 | }; 32 | 33 | mainApp = pkgs.writeShellScriptBin "scalar-feda-app" '' 34 | ${pkgs.scala-cli}/bin/scala-cli run . 35 | ''; 36 | in 37 | { 38 | devShells.default = pkgs.mkShell { 39 | name = "scala-dev-shell"; 40 | buildInputs = with pkgs; [ jre scala-cli ]; 41 | JAVA_HOME = "${pkgs.jre}"; 42 | }; 43 | 44 | packages.default = mainApp; 45 | } 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /ComplexEventProcessing.scala: -------------------------------------------------------------------------------- 1 | import java.time.Instant 2 | import java.util.UUID 3 | 4 | import cats.Applicative 5 | import cats.effect.* 6 | import cats.syntax.all.* 7 | import fs2.Stream 8 | 9 | object ComplexEventProcessing: 10 | type EventId = UUID 11 | type Price = BigDecimal 12 | type Symbol = String 13 | type Timestamp = Instant 14 | 15 | def runner(events: Stream[IO, PriceEvent | Tick]): IO[Unit] = 16 | val alerts = PriceAlerts.make(List("EURUSD", "EURGBP")) 17 | val engine = PriceEngine2(alerts) 18 | events.evalMapAccumulate(PriceState.empty)(engine.fsm.run).compile.drain 19 | 20 | case object Tick 21 | type Tick = Tick.type 22 | 23 | type UserEmail = String 24 | 25 | enum UserEvent: 26 | case Registered(id: EventId, email: UserEmail) 27 | case Deleted(id: EventId, email: UserEmail) 28 | 29 | case class UserState(emails: Set[UserEmail]) 30 | 31 | enum PriceEvent: 32 | case Created(symbol: Symbol, price: Price, createdAt: Timestamp) 33 | case Updated(symbol: Symbol, price: Price, createdAt: Timestamp) 34 | case Deleted(symbol: Symbol, createdAt: Timestamp) 35 | 36 | case class PriceHistory( 37 | history: List[(Price, Timestamp)] 38 | ) 39 | 40 | object PriceHistory: 41 | def empty: PriceHistory = PriceHistory(List.empty) 42 | def init(price: Price, ts: Timestamp): PriceHistory = 43 | PriceHistory(List(price -> ts)) 44 | 45 | case class PriceState(prices: Map[Symbol, PriceHistory]) 46 | 47 | object PriceState: 48 | def empty: PriceState = PriceState(Map.empty) 49 | 50 | type St[In] = In match 51 | case PriceEvent | Tick => PriceState 52 | case UserEvent => UserState 53 | 54 | type Output[In] = In match 55 | case PriceEvent | Tick => Unit 56 | case UserEvent => Int 57 | 58 | type SM[F[_], In] = FSM[F, St[In], In, Output[In]] 59 | 60 | trait PriceAlerts[F[_]]: 61 | def publish(prices: Map[Symbol, PriceHistory]): F[Unit] 62 | 63 | object PriceAlerts: 64 | def make(symbols: List[Symbol]): PriceAlerts[IO] = new: 65 | def publish(prices: Map[Symbol, PriceHistory]): IO[Unit] = 66 | prices.view.filterKeys(symbols.contains).toList.traverse_ { (symbol, history) => 67 | IO.println(s">>> Symbol: $symbol - History: $history") 68 | } 69 | 70 | object PriceEngine1: 71 | val fsm = FSM.id[PriceState, PriceEvent, Unit] { 72 | case (st, PriceEvent.Created(symbol, price, ts)) => 73 | PriceState(st.prices.updated(symbol, PriceHistory.init(price, ts))) -> () 74 | case (st, PriceEvent.Updated(symbol, price, ts)) => 75 | val current = st.prices.get(symbol).toList.flatMap(_.history) 76 | val updated = PriceHistory(current ::: List(price -> ts)) 77 | PriceState(st.prices.updated(symbol, updated)) -> () 78 | case (st, PriceEvent.Deleted(symbol, ts)) => 79 | PriceState(st.prices.removed(symbol)) -> () 80 | } 81 | 82 | class PriceEngine2[F[_]: Applicative](alerts: PriceAlerts[F]): 83 | val fsm = FSM[F, PriceState, PriceEvent | Tick, Unit] { 84 | case (st, PriceEvent.Created(symbol, price, ts)) => 85 | (PriceState(st.prices.updated(symbol, PriceHistory.init(price, ts))) -> ()).pure[F] 86 | case (st, PriceEvent.Updated(symbol, price, ts)) => 87 | val current = st.prices.get(symbol).toList.flatMap(_.history) 88 | val updated = PriceHistory(current ::: List(price -> ts)) 89 | (PriceState(st.prices.updated(symbol, updated)) -> ()).pure[F] 90 | case (st, PriceEvent.Deleted(symbol, ts)) => 91 | (PriceState(st.prices.removed(symbol)) -> ()).pure[F] 92 | case (st, Tick) => 93 | alerts.publish(st.prices).tupleLeft(PriceState.empty) 94 | } 95 | 96 | val fsm3: SM[F, PriceEvent | Tick] = fsm 97 | 98 | val fsm4: SM[F, UserEvent] = FSM { 99 | case (st, UserEvent.Registered(_, email)) => 100 | val nst = UserState(st.emails + email) 101 | (nst -> nst.emails.size).pure[F] 102 | case (st, UserEvent.Deleted(_, email)) => 103 | val nst = UserState(st.emails - email) 104 | (nst -> nst.emails.size).pure[F] 105 | } 106 | --------------------------------------------------------------------------------