Skip to content

Commit db238df

Browse files
committed
[CHORE] Prepare v1.0.0 release with enhanced documentation
1 parent 8f63d4b commit db238df

4 files changed

Lines changed: 303 additions & 16 deletions

File tree

README.md

Lines changed: 155 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
<p align="center">
2+
<img src="https://github.com/user-attachments/assets/fde2b47f-d853-4ee3-bdad-54a18b852730" alt="EctoLiteFS" style="max-width: 100%; height: auto;" />
3+
</p>
4+
15
# EctoLiteFS
26

37
LiteFS-aware Ecto middleware for automatic write forwarding in distributed SQLite clusters.
@@ -6,44 +10,108 @@ EctoLiteFS detects which node is the current LiteFS primary and automatically fo
610
write operations to it, allowing replicas to handle reads locally while writes are
711
transparently routed to the primary.
812

13+
> **Built on [EctoMiddleware](https://hex.pm/packages/ecto_middleware):** EctoLiteFS includes
14+
> EctoMiddleware as a dependency, so installing `ecto_litefs` gives you everything you need!
15+
16+
## Features
17+
18+
- **Automatic write forwarding** - Writes on replicas are transparently forwarded to primary
19+
- **Local reads** - Replicas handle reads locally for low latency
20+
- **Multiple detection methods** - Filesystem polling + HTTP event stream + database tracking
21+
- **Zero-config in dev/test** - Gracefully degrades when LiteFS is not present
22+
- **Minimal setup** - Just add middleware and a supervisor to your app
23+
- **Works with multiple repos** - Can track any number of Ecto repos in the same app
24+
- **Telemetry integration** - Monitor write forwarding performance and failures
25+
926
## Installation
1027

1128
Add `ecto_litefs` to your list of dependencies in `mix.exs`:
1229

1330
```elixir
1431
def deps do
1532
[
16-
{:ecto_litefs, "~> 0.1.0"}
33+
{:ecto_litefs, "~> 1.0"}
1734
]
1835
end
1936
```
2037

21-
## Usage
38+
## Quick Start
39+
40+
### 1. Add to Supervision Tree
41+
42+
Add `EctoLiteFS.Supervisor` to your application, **after** your Repo:
43+
44+
```elixir
45+
defmodule MyApp.Application do
46+
def start(_type, _args) do
47+
children = [
48+
MyApp.Repo,
49+
{EctoLiteFS.Supervisor,
50+
repo: MyApp.Repo,
51+
primary_file: "/litefs/.primary",
52+
poll_interval: 30_000,
53+
event_stream_url: "http://localhost:20202/events"
54+
}
55+
]
56+
57+
Supervisor.start_link(children, strategy: :one_for_one)
58+
end
59+
end
60+
```
61+
62+
### 2. Add Middleware to Repo
2263

23-
Add the supervisor to your application's supervision tree, after your Repo:
64+
```elixir
65+
defmodule MyApp.Repo do
66+
use Ecto.Repo, otp_app: :my_app
67+
use EctoMiddleware.Repo # Included with ecto_litefs!
68+
69+
@impl EctoMiddleware.Repo
70+
def middleware(_action, _resource) do
71+
[EctoLiteFS.Middleware] # Add anywhere in your middleware stack
72+
end
73+
end
74+
```
75+
76+
### 3. Use Your Repo Normally
2477

2578
```elixir
26-
children = [
27-
MyApp.Repo,
28-
{EctoLiteFS.Supervisor,
29-
repo: MyApp.Repo,
30-
primary_file: "/litefs/.primary",
31-
poll_interval: 30_000,
32-
event_stream_url: "http://localhost:20202/events"
33-
}
34-
]
79+
# On primary node - executes locally
80+
MyApp.Repo.insert!(%User{name: "Alice"})
81+
82+
# On replica node - automatically forwarded to primary
83+
MyApp.Repo.insert!(%User{name: "Bob"})
84+
85+
# Reads always execute locally (low latency!)
86+
MyApp.Repo.all(User)
3587
```
3688

89+
That's it! Writes are automatically forwarded when running on a replica.
90+
91+
> **Future Plans:** Support for forwarding transactions and bulk operations is planned for future releases.
92+
3793
## Configuration Options
3894

39-
* `:repo` - Required. The Ecto Repo module to track.
95+
All options are configured when starting the supervisor:
96+
97+
* `:repo` - **Required**. The Ecto Repo module to track.
4098
* `:primary_file` - Path to LiteFS `.primary` file. Default: `"/litefs/.primary"`
4199
* `:poll_interval` - Filesystem poll interval in ms. Default: `30_000`
42100
* `:event_stream_url` - LiteFS HTTP events endpoint. Default: `"http://localhost:20202/events"`
43101
* `:table_name` - Database table for primary tracking. Default: `"_ecto_litefs_primary"`
44102
* `:cache_ttl` - Cache TTL in ms. Default: `5_000`
45103
* `:erpc_timeout` - Timeout for RPC calls to primary. Default: `15_000`
46104

105+
### Minimal Configuration
106+
107+
For most use cases, you only need to specify `:repo`:
108+
109+
```elixir
110+
{EctoLiteFS.Supervisor, repo: MyApp.Repo}
111+
```
112+
113+
All other options use sensible defaults that work with standard LiteFS configurations.
114+
47115
## How It Works
48116

49117
EctoLiteFS uses multiple detection methods to determine primary status:
@@ -55,6 +123,72 @@ EctoLiteFS uses multiple detection methods to determine primary status:
55123
When a write operation is detected on a replica node, it's automatically forwarded
56124
to the primary node via `:erpc.call/4`.
57125

126+
By default, both filesystem polling and event streaming are enabled for robust detection, but either
127+
of these can be disabled if desired.
128+
129+
### Architecture
130+
131+
```
132+
┌─────────────────────┐ ┌─────────────────────┐
133+
│ Primary Node │ │ Replica Node │
134+
│ │ │ │
135+
│ ┌──────────────┐ │ │ ┌──────────────┐ │
136+
│ │ Repo.insert │ │ :erpc │ │ Repo.insert │───┼──┐
137+
│ └──────┬───────┘ │ ◄───────┼──│ (forwarded)│ │ │
138+
│ │ │ │ └──────────────┘ │ │
139+
│ ┌────▼────┐ │ │ │ │
140+
│ │ SQLite │◄─────┼─────────┼─► Reads happen │ │
141+
│ │ (write) │ │ replicate│ locally │ │
142+
│ └─────────┘ │ │ │ │
143+
└─────────────────────┘ └─────────────────────┘ │
144+
145+
Middleware detects write ────────────┘
146+
and forwards to primary
147+
```
148+
149+
## Development & Testing
150+
151+
EctoLiteFS gracefully handles environments where LiteFS is not present:
152+
153+
- **Production (with LiteFS):** Forwards writes to primary, reads from replica
154+
- **Development/Test (no LiteFS):** Executes all operations locally
155+
156+
This means you can add the middleware to your Repo without any conditional logic -
157+
it will "just work" in all environments!
158+
159+
## Monitoring with Telemetry
160+
161+
EctoLiteFS emits telemetry events for observability:
162+
163+
```elixir
164+
# Monitor slow write forwards
165+
:telemetry.attach(
166+
"log-slow-forwards",
167+
[:ecto_litefs, :forward, :stop],
168+
fn _event, %{duration: duration}, %{repo: repo, action: action}, _config ->
169+
if duration > 5_000_000 do # 5ms
170+
Logger.warning("Slow forward: #{action} took #{duration}ns")
171+
end
172+
end,
173+
nil
174+
)
175+
176+
# Track forwarding failures
177+
:telemetry.attach(
178+
"track-forward-errors",
179+
[:ecto_litefs, :forward, :exception],
180+
fn _event, _measurements, %{repo: repo, reason: reason}, _config ->
181+
Logger.error("Forward failed: #{inspect(reason)}")
182+
end,
183+
nil
184+
)
185+
```
186+
187+
Available events:
188+
- `[:ecto_litefs, :forward, :start]` - Write forwarding initiated
189+
- `[:ecto_litefs, :forward, :stop]` - Forwarding completed successfully
190+
- `[:ecto_litefs, :forward, :exception]` - Forwarding failed
191+
58192
## Testing
59193

60194
### Unit Tests
@@ -84,6 +218,14 @@ Test scenarios:
84218
3. Write forwarding from replica to primary
85219
4. Primary failover - replica promoted, data preserved
86220

221+
## Similar Projects
222+
223+
- **[litefs](https://hex.pm/packages/litefs)** - The original LiteFS library for Elixir by [@sheertj](https://git.sr.ht/~sheertj).
224+
Uses a repo wrapper approach where you rename your repo to `MyApp.Repo.Local` and create a new
225+
`MyApp.Repo` that proxies writes, and relies on filesystem polling only. EctoLiteFS differs by
226+
using middleware instead (so your existing repo stays unchanged), and adds HTTP event streaming
227+
for faster primary detection.
228+
87229
## License
88230

89231
MIT License - see [LICENSE](LICENSE) for details.

lib/ecto_litefs.ex

Lines changed: 146 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,47 @@ defmodule EctoLiteFS do
66
write operations to it, allowing replicas to handle reads locally while writes are
77
transparently routed to the primary.
88
9-
## Usage
9+
> **Built on EctoMiddleware:** EctoLiteFS is powered by [EctoMiddleware](https://hex.pm/packages/ecto_middleware),
10+
> which is included as a dependency. Installing `ecto_litefs` gives you everything you need!
1011
11-
Add the supervisor to your application's supervision tree, after your Repo:
12+
## Quick Example
13+
14+
# 1. Add EctoLiteFS supervisor to your application
15+
defmodule MyApp.Application do
16+
def start(_type, _args) do
17+
children = [
18+
MyApp.Repo,
19+
{EctoLiteFS.Supervisor, repo: MyApp.Repo}
20+
]
21+
22+
Supervisor.start_link(children, strategy: :one_for_one)
23+
end
24+
end
25+
26+
# 2. Add middleware to your Repo
27+
defmodule MyApp.Repo do
28+
use Ecto.Repo, otp_app: :my_app
29+
use EctoMiddleware.Repo
30+
31+
@impl EctoMiddleware.Repo
32+
def middleware(_action, _resource) do
33+
[EctoLiteFS.Middleware]
34+
end
35+
end
36+
37+
# 3. Use your Repo normally - writes are automatically forwarded!
38+
# On primary node:
39+
MyApp.Repo.insert!(%User{name: "Alice"}) # Executes locally
40+
41+
# On replica node:
42+
MyApp.Repo.insert!(%User{name: "Bob"}) # Forwarded to primary via :erpc
43+
MyApp.Repo.all(User) # Reads locally from replica
44+
45+
## Setup
46+
47+
### 1. Add to Supervision Tree
48+
49+
Add `EctoLiteFS.Supervisor` to your application's supervision tree, **after** your Repo:
1250
1351
children = [
1452
MyApp.Repo,
@@ -20,6 +58,22 @@ defmodule EctoLiteFS do
2058
}
2159
]
2260
61+
### 2. Add Middleware to Repo
62+
63+
EctoLiteFS uses [EctoMiddleware](https://hex.pm/packages/ecto_middleware) (included as a dependency):
64+
65+
defmodule MyApp.Repo do
66+
use Ecto.Repo, otp_app: :my_app
67+
use EctoMiddleware.Repo # Comes with ecto_litefs!
68+
69+
@impl EctoMiddleware.Repo
70+
def middleware(_action, _resource) do
71+
[EctoLiteFS.Middleware] # Add alongside other middleware if needed
72+
end
73+
end
74+
75+
That's it! Writes will automatically be forwarded to the primary node when running on a replica.
76+
2377
## Configuration Options
2478
2579
* `:repo` - Required. The Ecto Repo module to track (also serves as the unique identifier).
@@ -29,6 +83,96 @@ defmodule EctoLiteFS do
2983
* `:table_name` - Database table for primary tracking. Default: `"_ecto_litefs_primary"`
3084
* `:cache_ttl` - Cache TTL in ms. Default: `5_000`
3185
* `:erpc_timeout` - Timeout for `:erpc` calls when forwarding writes to primary. Default: `15_000`
86+
87+
## How It Works
88+
89+
EctoLiteFS uses multiple detection methods to determine primary status:
90+
91+
1. **Filesystem polling** - Checks for the presence of LiteFS's `.primary` file
92+
2. **Event streaming** - Subscribes to LiteFS's HTTP event stream for real-time updates
93+
3. **Database tracking** - Stores primary node information in a replicated table
94+
95+
When a write operation is detected on a replica node, it's automatically forwarded
96+
to the primary node via `:erpc.call/4`.
97+
98+
### Primary Detection
99+
100+
The Tracker process maintains an ETS cache of the current primary node, refreshed from
101+
the database when stale. This ensures low-latency reads while maintaining consistency.
102+
103+
### Write Forwarding
104+
105+
The middleware intercepts write operations (insert, update, delete) and checks if the
106+
current node is the primary. If not, it forwards the operation to the primary using
107+
`:erpc.call/4` with a configurable timeout.
108+
109+
## Development & Test Mode
110+
111+
When `EctoLiteFS.Supervisor` is not started (e.g., in dev/test), the middleware
112+
automatically passes through to local execution. This means you can add the
113+
middleware to your Repo and it will "just work" in all environments:
114+
115+
- **Production (with LiteFS):** Forwards writes to primary node
116+
- **Development/Test (no LiteFS):** Executes writes locally
117+
118+
## Telemetry Events
119+
120+
EctoLiteFS emits telemetry events for observability:
121+
122+
- `[:ecto_litefs, :forward, :start]` - Write forwarding initiated
123+
- `[:ecto_litefs, :forward, :stop]` - Forwarding completed successfully
124+
- `[:ecto_litefs, :forward, :exception]` - Forwarding failed
125+
126+
All events include metadata: `%{repo: repo, action: action, primary_node: node}`
127+
128+
### Example: Monitoring Write Forwarding
129+
130+
:telemetry.attach(
131+
"log-write-forwards",
132+
[:ecto_litefs, :forward, :stop],
133+
fn _event, %{duration: duration}, %{repo: repo, action: action}, _config ->
134+
Logger.info("Forwarded \#{action} to primary in \#{duration}ns")
135+
end,
136+
nil
137+
)
138+
139+
## Error Handling
140+
141+
- `{:error, :primary_unavailable}` - No primary node is known (cluster may be initializing)
142+
- `{:error, {:erpc, :timeout, node}}` - RPC call timed out
143+
- `{:error, {:erpc, :noconnection, node}}` - Primary node is unreachable
144+
145+
> #### Timeout Warning {: .warning}
146+
>
147+
> A timeout error does **not** mean the write failed. The primary node may have
148+
> completed the write before the timeout occurred. Design your application to
149+
> handle this uncertainty (e.g., idempotent writes, conflict resolution).
150+
151+
## Limitations
152+
153+
- **Transactions:** Write forwarding within `Repo.transaction/2` is not currently
154+
supported. Transactions must execute entirely on the primary node.
155+
156+
## Public API
157+
158+
While the middleware handles most operations automatically, you can also use the
159+
public API for direct control:
160+
161+
# Check if current node is primary
162+
EctoLiteFS.is_primary?(MyApp.Repo)
163+
#=> true
164+
165+
# Get current primary node
166+
EctoLiteFS.get_primary(MyApp.Repo)
167+
#=> {:ok, :node1@host}
168+
169+
# Manually set primary (usually not needed)
170+
EctoLiteFS.set_primary(MyApp.Repo, node())
171+
#=> :ok
172+
173+
# Invalidate cache (force refresh from DB)
174+
EctoLiteFS.invalidate_cache(MyApp.Repo)
175+
#=> :ok
32176
"""
33177

34178
alias EctoLiteFS.Tracker

lib/ecto_litefs/rpc.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ defmodule EctoLiteFS.RPC do
1818
- The result of the function execution
1919
- Raises :erpc exceptions on timeout, noconnection, etc.
2020
"""
21+
@spec call(node(), (-> term()), pos_integer()) :: term()
2122
def call(node, fun, timeout) do
2223
:erpc.call(node, fun, timeout)
2324
end

0 commit comments

Comments
 (0)