Skip to content

Commit 6b71b9d

Browse files
authored
Merge pull request #25 from breez/savage-distributed-lock
Distributed lock
2 parents db82770 + 7c037e8 commit 6b71b9d

19 files changed

Lines changed: 1421 additions & 347 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
.claude/settings.local.json
12
db

CLAUDE.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# data-sync
2+
3+
A Go data synchronization service that provides authenticated, versioned record storage with real-time change notifications over gRPC. Built by Breez.
4+
5+
## Purpose
6+
7+
Synchronizes user data across client applications. Each user's records are isolated, versioned, and protected by ECDSA signature verification. Clients can create/update records, poll for changes, or subscribe to a real-time notification stream.
8+
9+
## Tech Stack
10+
11+
- **Go 1.22+** (toolchain 1.23.1)
12+
- **gRPC** with Protocol Buffers for the API layer
13+
- **gRPC-Web proxy** on a second HTTP port for browser clients
14+
- **SQLite** or **PostgreSQL** as pluggable storage backends
15+
- **ECDSA (secp256k1)** signature-based authentication — user identity is derived from the public key
16+
17+
## API (gRPC)
18+
19+
Defined in `proto/sync.proto`:
20+
21+
| RPC | Description |
22+
|-----|-------------|
23+
| `SetRecord` | Create or update a record. Returns `CONFLICT` if the client's revision doesn't match the server's (optimistic concurrency). |
24+
| `ListChanges` | List all records changed since a given revision. |
25+
| `ListenChanges` | Server-streaming RPC that pushes real-time notifications when a user's data changes. |
26+
| `SetLock` | Acquire or release a named distributed lock. Supports per-instance identity and TTL-based expiration (default 30s). |
27+
| `GetLock` | Check if any instance currently holds an active (non-expired) lock. |
28+
29+
Every request must include a signature over the request payload. The server derives the user ID from the recovered public key (compressed, hex-encoded).
30+
31+
## Directory Structure
32+
33+
```
34+
config/ Environment-based configuration (ports, DB path, certs)
35+
middleware/ ECDSA signature verification and optional X.509 cert validation
36+
proto/ Protobuf definitions and generated Go code
37+
store/ Storage interface and implementations
38+
sqlite/ SQLite backend with golang-migrate migrations
39+
postgres/ PostgreSQL backend with golang-migrate migrations
40+
main.go Entry point — starts gRPC server and web proxy
41+
syncer_server.go Core server logic: SetRecord, ListChanges, ListenChanges, SetLock, GetLock, EventsManager
42+
```
43+
44+
## Configuration (environment variables)
45+
46+
| Variable | Default | Description |
47+
|----------|---------|-------------|
48+
| `GRPC_LISTEN_ADDRESS` | `0.0.0.0:8080` | gRPC server address |
49+
| `GRPC_WEB_LISTEN_ADDRESS` | `0.0.0.0:8081` | gRPC-Web/HTTP proxy address |
50+
| `SQLITE_DIR_PATH` | `db` | Directory for SQLite database file |
51+
| `DATABASE_URL` || PostgreSQL connection string (uses Postgres when set) |
52+
| `CA_CERT` || Base64-encoded CA certificate for client cert validation |
53+
54+
## Build and Run
55+
56+
```bash
57+
# Build
58+
go build -v -o data-sync .
59+
60+
# Run (SQLite, default)
61+
./data-sync
62+
63+
# Run (PostgreSQL)
64+
DATABASE_URL="postgres://user:pass@host:5432/dbname" ./data-sync
65+
```
66+
67+
## Docker
68+
69+
```bash
70+
docker build -t data-sync .
71+
docker run -p 8080:8080 -p 8081:8081 data-sync
72+
```
73+
74+
## Deploy
75+
76+
Deployed to **Fly.io** (primary region: `lhr`). See `fly.toml` for configuration. CI/CD via GitHub Actions (`.github/workflows/fly-deploy.yml`).
77+
78+
## Key Design Decisions
79+
80+
- **Optimistic concurrency control**: Clients must supply the current revision when updating. Mismatches return `CONFLICT` — no automatic merge.
81+
- **Signature-based identity**: No passwords or tokens. User ID is the compressed public key recovered from the ECDSA signature on each request.
82+
- **Pluggable storage**: The `store.SyncStorage` interface allows swapping between SQLite (single-instance) and PostgreSQL (multi-instance) without changing server logic.
83+
- **Serializable transactions**: Both storage backends use serializable isolation to prevent race conditions.
84+
- **Channel-based streaming**: `EventsManager` maintains per-user Go channels to fan out real-time change notifications.
85+
86+
## Testing
87+
88+
```bash
89+
go test ./...
90+
```
91+
92+
Tests cover the storage layer (SQLite and PostgreSQL) and end-to-end server behavior including conflict detection and real-time notifications.
93+
94+
## Regenerate Protobuf
95+
96+
```bash
97+
cd proto && ./genproto.sh
98+
```

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
# sdk-data-sync
22
A simple service to synchronize user data across different apps.
3-
The service has two endpoints:
3+
The service has the following endpoints:
44
1. SetRecord - create/update a specific record that supports conflict detection
55
2. ListChanges - list changes since a specific revision
66
3. TrackChanges - listen in real-time for new changes
7+
4. SetLock - acquire or release a named distributed lock (with TTL-based expiration)
8+
5. GetLock - check if a named lock is currently held by any instance
79

810
# TODOs
911
- [ ] Distribute sqlite files accross different folders.

middleware/auth.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,18 @@ func Authenticate(config *config.Config, ctx context.Context, req interface{}) (
9797
signature = listenChangesReq.Signature
9898
}
9999

100+
setLockReq, ok := req.(*proto.SetLockRequest)
101+
if ok {
102+
toVerify = fmt.Sprintf("%v-%v-%v-%v-%v", setLockReq.LockName, setLockReq.InstanceId, setLockReq.Acquire, setLockReq.Exclusive, setLockReq.RequestTime)
103+
signature = setLockReq.Signature
104+
}
105+
106+
getLockReq, ok := req.(*proto.GetLockRequest)
107+
if ok {
108+
toVerify = fmt.Sprintf("%v-%v", getLockReq.LockName, getLockReq.RequestTime)
109+
signature = getLockReq.Signature
110+
}
111+
100112
pubkey, err := VerifyMessage([]byte(toVerify), signature)
101113
if err != nil {
102114
return nil, err

0 commit comments

Comments
 (0)