fate: Mesin Statechart untuk Go
Statechart Harel di Go, hierarki, region paralel, history, guard, dan transisi tertunda. Nol dependensi, deterministik, dan snapshot-nya bisa dipulihkan persis, termasuk di dalam workflow Temporal. v0.5.0 membetulkan cara region paralel keluar dan membuat event yang diabaikan bisa dilihat.
fate adalah implementasi statechart Harel di Go: state bersarang, region paralel, history dangkal dan dalam. Bukan finite automaton datar. Semantiknya mengikuti SCXML dan XState v5, ditulis dengan generics supaya konteks dan event-nya bertipe kuat.
import "github.com/arisros/fate"
Rilis terbarunya v0.5.1, diberi tag pada 2026-09-17, sehari setelah v0.5.0.
Dua aturan yang menentukan bentuknya
Pertama: nol dependensi. go.mod di modul root tidak mewajibkan apa pun, dan CI menegakkannya: job bernama “engine has zero external dependencies” menggagalkan build kalau go list -m all mengembalikan apa pun selain modul itu sendiri. Aturan itu yang membuat go get github.com/arisros/fate tetap ringan, alasan integrasi Temporal dipisah jadi modul sendiri (fate/temporal), dan alasan fate-studio tinggal di repositori terpisah sama sekali.
Aturan itu juga yang memaksa pemecahan package di v0.5.0: renderer pindah ke fate/render (render.ASCII, render.Mermaid, render.GraphJSON) dan pembanding snapshot ke fate/diff. Ini perubahan yang memutus API, ditandai begitu di changelog lengkap dengan tabel migrasi dari 0.4.0, tapi orang yang cuma butuh mesinnya tidak lagi ikut membawa kode visualisasi.
Kedua: deterministik. Machine bersifat immutable dan boleh dipakai bersama; Actor yang menyimpan state, dan state itu diserialisasi ke JSON lalu dipulihkan byte-per-byte:
blob, _ := a.Persist()
b, _ := fate.NewActorFromSnapshot[Ctx, Evt](m, blob)
Waktu tidak ditentukan oleh mesinnya
Ini bagian yang paling banyak mengubah desain. Transisi tertunda (after) tidak memakai time.After di dalam mesin. Mesin hanya melaporkan timer apa yang tertunggak, dan pihak luar yang memutuskan kapan timer itu berbunyi:
for _, pt := range a.PendingTimers() {
a.FireTimer(pt.ID)
}
Terlihat berbelit sampai kita ingat siapa penggunanya. Di dalam workflow Temporal, memanggil jam sungguhan itu terlarang: replay harus menghasilkan hasil yang sama persis. Dengan timer yang ditarik, bukan didorong, inti mesinnya buta terhadap jam dan aman dijalankan di lingkungan deterministik mana pun. Pola yang sama dipakai untuk invocation: PendingInvocations, ResolveInvocation, dan RejectInvocation.
Bug yang jadi alasan v0.5.0
Perbaikan terbesar di v0.5.0 ada di region paralel, dan jenis bug-nya tidak pernah kelihatan dari nilai state saja.
computeExitSet menelusur naik dari leaf aktif yang namanya paling awal secara alfabet, bukan dari domain transisinya. Akibatnya, transisi yang terjadi di satu region bisa menjalankan aksi Exit milik region lain dan mematikan timer serta invocation-nya, sementara snapshot masih melaporkan region itu aktif. Hasilnya state yang tampak siap dengan transisi tertunda yang tidak akan pernah berbunyi. Sisi masuknya punya masalah cerminan: masuk ke node paralel hanya memasuki node-nya, tanpa satu pun region-nya, jadi aksi Entry mereka tidak pernah berjalan.
Exit set yang lama justru menyembunyikan bug di sisi masuk, karena ia juga tidak pernah mematikan efek-efek itu saat keluar. Membetulkan yang satu membuka yang lain. Sekarang exit set berisi setiap node aktif di bawah domain transisi, entry set memasuki setiap region di jalurnya, dan tidak ada hasil yang bergantung pada nama region mana yang urut duluan. History dangkal punya asumsi leaf-pertama yang sama dan dibetulkan sekalian.
v0.5.1 adalah lanjutannya, dan isinya hanya perbaikan itu: transisi internal yang menargetkan state-nya sendiri sekarang kembali memasuki state awalnya, dan history dalam ke node paralel memulihkan state yang tersimpan, bukan state bawaan.
Changelog-nya juga menyimpan bagian “known limitations”, yang menurut saya sama pentingnya dengan perbaikannya: transisi lintas region hanya memindahkan region target, OnDone untuk node paralel belum ada, dan urutan keluar ditentukan kedalaman, bukan urutan dokumen terbalik. Semua itu lebih sempit dari SCXML, dan menyebutkannya lebih murah daripada seseorang menemukannya sendiri.
”Mesinnya mengabaikan itu” sekarang terlihat
Di statechart, event yang tidak ditangani state mana pun bukan galat, jadi Send membuangnya diam-diam. Itu benar, dan justru itu yang membuat “tidak terjadi apa-apa” susah dilacak. v0.5.0 menambahkan Can, yang mengevaluasi guard tanpa mengubah actor:
if !actor.Can(evt) {
// mesin akan mengabaikan event ini di state-nya sekarang
}
_ = actor.Send(ctx, evt)
Karena guard menurut kontraknya murni, jawabannya pasti, bukan tebakan. Dengan semangat yang sama, FireTimer, ResolveInvocation, dan RejectInvocation sekarang mengembalikan bool yang menyatakan apakah panggilannya diterima mesin. Itu memutus method value dan pemenuhan interface, karena itu ia tercatat di bagian perubahan yang memutus.
Metadata untuk alat, bukan untuk mesinnya
Separuh lain v0.5.0 untuk perkakas seperti fate-studio. Guard adalah fungsi Go, jadi diagram tidak bisa tahu apa yang diperiksanya. CondMeta membiarkan kamu menjelaskannya:
fate.TransitionConfig[Ctx, Evt]{
Guard: approved,
CondMeta: fate.Gates(
fate.Field("$.score").Gte(60),
fate.Field("$.status").Eq("approved"),
).Sample(`{"score": 65, "status": "approved"}`),
}
Ia tidak pernah memengaruhi apakah transisi terjadi; yang memutuskan tetap guard-nya. Ia hanya ikut di descriptor mesin supaya penampil bisa menampilkannya. UIStateOf melakukan hal yang sama untuk view model per state: fungsi dari konteks ke sebuah struct, yang JSON Schema-nya diturunkan lewat refleksi dan diterbitkan bersama state-nya. fate.Named dan TransitionConfig.GuardName memberi aksi dan guard nama yang terbaca. Percobaan sebelumnya menebak nama guard dari pointer fungsi dan salah memberi label, karena itu namanya sekarang ditulis eksplisit.
Yang ada di dalam
| Konsep statechart | Di fate |
|---|---|
| State atomic / compound / parallel / final | NodeAtomic / NodeCompound / NodeParallel / NodeFinal |
| History dangkal & dalam | NodeHistory dengan HistoryShallow / HistoryDeep |
| Guard | TransitionConfig.Guard plus kombinator And / Or / Not / StateIn |
| Aksi masuk, keluar, dan transisi | Assign, Raise, Log, EnqueueActions, Named |
| Transisi tertunda | StateNodeConfig.After |
| Child actor yang di-invoke atau di-spawn | StateNodeConfig.Invoke |
| Persistensi | Actor.Persist / NewActorFromSnapshot |
| Metadata untuk perkakas | TransitionConfig.CondMeta, StateNodeConfig.UIState |
| Render dan diff | fate/render, fate/diff |
Bagian tersulit ada di algorithm.go, yang mengadaptasi algoritma transisi SCXML: mencari LCCA (least common compound ancestor) dari state sumber dan target, lalu menyusun exit set dari yang terdalam dan entry set dari yang terluar. Urutan itu bukan detail kosmetik: dia yang menentukan urutan aksi exit dan entry berjalan, dan salah urut berarti aksi berjalan pada state yang sudah tidak aktif. Bug paralel di atas adalah wujud salah urut itu.
Di sekitar mesinnya
fate/httphandlermembungkus sebuah mesin menjadi simulator HTTP: aliran SSE plus endpoint untuk mengirim event, membunyikan timer, menyelesaikan invocation, undo, dan ekspor. Format kabelnya sama dengan simulator fate-studio, jadi studio bisa mem-proxy ke mesin yang berjalan di dalam layananmu sendiri. Tidak ada autentikasi dan hanya dimaksudkan untuk pengembangan;SECURITY.mdmenyatakannya.fate/snapshotmenulis descriptor mesin ke<dir>/<nama>.json. Arahkan ke.fate/, direktori yang dimuat fate-studio secara bawaan, dan studio bisa menggambar mesinnya tanpa menjalankan apa pun.cmd/fateadalah CLI kecil (go install github.com/arisros/fate/cmd/fate@latest) untuk merender descriptor ke ASCII, Mermaid, atau JSON graph, dan membandingkan dua snapshot.
Status
Masih v0.x: API bisa berubah antar rilis minor sampai v1.0.0, dan perubahan yang memutus selalu didaftar eksplisit di CHANGELOG.md.
Proses rilisnya berubah di versi ini. Rilis sekarang dibuat oleh release-please, yang menjaga satu PR rilis tetap terbuka dan, begitu PR itu digabung, memberi tag versinya lalu membiarkan GoReleaser melampirkan biner CLI-nya. Setiap GitHub Action dipaku ke SHA commit, Dependabot mengawasi action dan modul Temporal, dan rangkaian ujinya (197 uji di 33 berkas, dijalankan dengan -race di Go 1.24 dan 1.25) punya batas cakupan 85%.