Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,35 @@ jobs:

- name: Run tests
run: cargo test --workspace --exclude wasm-edge

typecheck-react:
name: Typecheck @recached/react
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"

- name: Generate SDK type declarations
run: |
cd wasm-edge && npm install
# wasm-pack isn't available in this job, so create a minimal stub that
# satisfies the TypeScript import so tsc can emit sdk.d.ts for the react step.
mkdir -p pkg
node -e "
require('fs').writeFileSync('pkg/recached_edge.d.ts',
'export class RecachedCache {}\nexport default function init(): Promise<void>;\n'
);
"
npx tsc

- name: Install dependencies
run: cd recached-react && npm install --legacy-peer-deps

- name: Typecheck
run: cd recached-react && npm run typecheck
36 changes: 34 additions & 2 deletions .github/workflows/npm.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Publish Wasm Package to NPM
name: Publish Packages to NPM

on:
push:
Expand All @@ -9,7 +9,7 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

jobs:
build-and-publish:
publish-wasm:
name: Build and publish recached-edge to NPM
runs-on: ubuntu-latest

Expand Down Expand Up @@ -59,3 +59,35 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: cd wasm-edge/pkg && npm publish --access public

publish-react:
name: Build and publish @recached/react to NPM
runs-on: ubuntu-latest
needs: publish-wasm

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20.x"
registry-url: "https://registry.npmjs.org"

- name: Install dependencies
run: cd recached-react && npm install --legacy-peer-deps

- name: Typecheck
run: cd recached-react && npm run typecheck

- name: Build
run: cd recached-react && npm run build

- name: Copy LICENSE into package
run: cp LICENSE.md recached-react/LICENSE.md

- name: Publish to NPM
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: cd recached-react && npm publish --access public
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ Thumbs.db
# committing Cargo.lock is best practice to ensure reproducible builds!

PLAN.md
NOTES.md
41 changes: 40 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,45 @@ All notable changes to Recached are documented here.

---

## [0.1.4] — 2026-05-10

### Added

**Snapshot persistence** (`server-native`)
- New `SAVE` command: blocks until the snapshot is written to disk and returns `+OK`.
- New `BGSAVE` command: spawns a background Tokio task to write the snapshot and immediately returns `+Background saving started`. The server continues accepting connections during the save.
- New `LASTSAVE` command: returns the Unix timestamp (seconds) of the most recent successful save as an integer.
- On startup, the server loads the snapshot from disk before accepting connections. Expired keys are silently skipped during restore.
- On clean shutdown (SIGTERM or Ctrl-C), a final snapshot is saved before the process exits.
- Periodic autosave runs every `RECACHED_SAVE_INTERVAL` seconds (default: 900 = 15 min). Set to `0` to disable autosave while keeping `SAVE`/`BGSAVE`/`LASTSAVE` available.
- Snapshot path is controlled by `RECACHED_SAVE_PATH` (default: `recached.rdb` in the working directory).
- Snapshot format: [MessagePack](https://msgpack.org/) via `rmp-serde`. Atomic write: data is written to a `.tmp` file then renamed, so a crash mid-save cannot corrupt the previous snapshot.
- All data types are preserved: strings, hashes, lists, sets, sorted sets, and TTLs.
- `Command::Save`, `Command::BgSave`, `Command::LastSave` added to `core-engine`; handled by the server before reaching `execute_and_record` since they require async filesystem I/O.
- `SnapshotEntry` and `SnapshotValue` public types added to `core-engine::store`.
- `KeyValueStore::snapshot()` and `KeyValueStore::restore()` methods added.

**AOF persistence** (`server-native`)
- New `RECACHED_AOF_PATH` env var. When set, every successful write is appended to the file as a normalized RESP command immediately after execution, in addition to periodic snapshot saves.
- New `RECACHED_AOF_SYNC` env var controlling fsync policy: `always` (after every write), `everysec` (background flush once per second, default), `no` (OS-managed).
- On startup: snapshot is loaded first, then AOF commands are replayed for the delta — recovering writes made after the last snapshot.
- After each successful snapshot save, the AOF is automatically truncated. The snapshot subsumes the log, so on the next startup only the post-snapshot delta is replayed.
- Combined with snapshots, the maximum data loss window is bounded by the AOF sync interval (≤1 second with `everysec`) rather than the snapshot interval.
- `APPEND` command added to the write broadcast path so it is captured by AOF and replication.

**Leader-follower replication** (`server-native`)
- New `RECACHED_REPLICAOF=host:port` env var. When set, the server runs as a read-only replica: it connects to the primary, loads a full snapshot, then streams all subsequent write commands in real time.
- New `RECACHED_REPL_PORT` env var (default: `6381`). The primary listens on this port for incoming replica connections.
- Initial sync protocol: the primary registers the replica's write channel first (so writes during snapshot serialization are buffered), serializes the full store to MessagePack, sends it length-prefixed over TCP, then streams subsequent writes as length-prefixed RESP strings.
- Replicas reject all write commands with `-READONLY You can't write against a read only replica.`
- Replicas reconnect automatically with exponential backoff (2 s → 4 s → … → 30 s cap) if the primary is temporarily unavailable.
- All writes on the primary flow through the unified `ServerState::on_write()` path, which handles AOF append and replica fan-out in a single call.

**Configurable connection limit** (`server-native`)
- New `RECACHED_MAX_CONNECTIONS` env var (default: `1024`). Raising this allows high-traffic deployments to accept more concurrent clients without rebuilding from source.

---

## [0.1.3] — 2026-05-09

### Added
Expand All @@ -30,7 +69,7 @@ All notable changes to Recached are documented here.

---

## [0.1.2] — 2026-05-08
## [0.1.2] — 2026-05-02

### Added

Expand Down
73 changes: 70 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ resolver = "2"
# ── Single source of truth for all crate versions ────────────────────────────
# Members inherit with: version.workspace = true / edition.workspace = true
[workspace.package]
version = "0.1.3"
version = "0.1.4"
edition = "2024"
license = "MIT"
authors = ["ThinkGrid Labs"]
Expand All @@ -36,6 +36,10 @@ metrics-exporter-prometheus = { version = "0.16", features = ["http-listener"]
tokio-rustls = "0.26"
rustls-pemfile = "2"

# serialization
serde = { version = "1", features = ["derive"] }
rmp-serde = "1"

# wasm
wasm-bindgen = "0.2.92"
js-sys = "0.3.69"
Expand Down
1 change: 1 addition & 0 deletions core-engine/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ edition.workspace = true
[dependencies]
dashmap.workspace = true
rand.workspace = true
serde.workspace = true
42 changes: 42 additions & 0 deletions core-engine/src/cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,12 @@ pub enum Command {
// ── Observable keys ───────────────────────────────────────────────────────
Watch(Vec<String>),
Unwatch(Vec<String>),
// ── Persistence ───────────────────────────────────────────────────────────
Save,
BgSave,
LastSave,
// ── Replication ───────────────────────────────────────────────────────────
ReplicaOfNoOne,
Unknown(String),
}

Expand Down Expand Up @@ -966,6 +972,24 @@ impl Command {
arr[1..].iter().filter_map(extract_string).collect(),
)),

// ── Persistence ───────────────────────────────────────────
"SAVE" => Ok(Command::Save),
"BGSAVE" => Ok(Command::BgSave),
"LASTSAVE" => Ok(Command::LastSave),

// ── Replication ───────────────────────────────────────────
"REPLICAOF" => {
need!(3);
let arg1 = extract_string(&arr[1]).unwrap_or_default().to_uppercase();
let arg2 = extract_string(&arr[2]).unwrap_or_default().to_uppercase();
if arg1 == "NO" && arg2 == "ONE" {
Ok(Command::ReplicaOfNoOne)
} else {
Err("ERR REPLICAOF supports only 'REPLICAOF NO ONE' at runtime"
.to_string())
}
}

_ => Ok(Command::Unknown(cmd_name.to_owned())),
}
}
Expand Down Expand Up @@ -1703,4 +1727,22 @@ mod tests {
Command::ZCount("z".into(), "-inf".into(), "+inf".into())
);
}

// ── Persistence ───────────────────────────────────────────────────────────

#[test]
fn save_bgsave_lastsave_parse() {
assert_eq!(
Command::from_value(array(&["SAVE"])).unwrap(),
Command::Save
);
assert_eq!(
Command::from_value(array(&["BGSAVE"])).unwrap(),
Command::BgSave
);
assert_eq!(
Command::from_value(array(&["LASTSAVE"])).unwrap(),
Command::LastSave
);
}
}
Loading
Loading