name: lark-cache
description: Authoritative reference for the @lark.js/cache distributed in-memory cache package located at packages/lark-cache (TypeScript/Node.js, ESM, published as @lark.js/cache). Use this skill whenever the user reads, writes, debugs, reviews, or extends code that imports from @lark.js/cache, references the packages/lark-cache source tree, or invokes its CLI/demo (packages/lark-cache/src/main.ts, packages/lark-cache/bootstrap.js). Trigger eagerly on any of these symbols and concepts—Group, newGroup, getGroup, listGroups, destroyGroup, destroyAllGroups, Getter, GroupOption, withExpiration, withPeers, withCacheOptions, Cache, CacheOptions, defaultCacheOptions, Store, StoreOptions, LruStore, defaultStoreOptions, ByteView, cloneBytes, Value, SingleFlightGroup, ConHashMap, ConHashConfig, ConHashOption, withConHashConfig, defaultConHashConfig, crc32, HashFunc, hashBKRD, maskOfNextPowOf2, Peer, PeerPicker, Client, ClientPicker, PickerOption, Server, ServerOptions, register, RegisterConfig, defaultRegisterConfig, ServiceDiscovery, validPeerAddr, getLocalIP, the pb.LarkCache gRPC service (Get/Set/Delete RPCs), and the x-peer-request metadata flag. Also trigger on conceptual phrases like "groupcache for Node", "TypeScript distributed cache with consistent hashing", "single-flight cache stampede", "etcd-based peer discovery", "two-level LRU cache", "peer fan-out write propagation", and on file paths under packages/lark-cache/src/**. Do NOT use this skill for the Go sibling github.com/hangtiancheng/lark-go/lark_cache—route those tasks to the lark-cache Go skill instead. The two share architecture but diverge on naming (PascalCase Go vs. camelCase TS), concurrency primitives (context.Context/goroutine vs. AbortSignal/async), and packaging.
@lark.js/cache — Distributed Cache for Node.js
@lark.js/cache is a TypeScript port of the groupcache architecture popularized by Brad Fitzpatrick, redesigned for the Node.js runtime and shipped from packages/lark-cache as ESM ("type": "module", Node ≥ 20). It provides a coherent set of building blocks—Group, Cache, LruStore, ConHashMap, SingleFlightGroup, ClientPicker, Server, etcd-based registration—that together yield a peer-to-peer, eventually consistent, sharded read-through cache reachable over gRPC. This skill is the canonical playbook: consult it before designing topologies, wiring callers, debugging timeouts, or evolving the public surface.
The package is intentionally aligned with the Go sibling at lark-go/lark_cache. APIs, semantics, and wire format match symbol-for-symbol; only naming conventions and language-idiomatic concurrency diverge. When the user mentions concepts that exist on both sides, confirm whether they mean the TypeScript package (@lark.js/cache, this skill) or the Go module (the lark-cache Go skill).
When to consult this skill (and when to skip)
Trigger this skill whenever you encounter:
- imports from
@lark.js/cacheor relative imports insidepackages/lark-cache/src/**; - discussion of the demo entry
packages/lark-cache/src/main.tsor the integration runnerpackages/lark-cache/bootstrap.js; - any of the symbols listed in the YAML
description; - requests to add new RPC handlers, swap in a different store, change consistent-hash replicas, tune deadlines, integrate TLS, change etcd endpoints, or re-export new public types;
- bug reports about cache miss storms, cross-node propagation gaps,
DEADLINE_EXCEEDEDfrompb.LarkCache.Get/Set/Delete, port collisions, or stale peers in the consistent-hash ring.
Do not trigger for: the Go module under lark-go/lark_cache (route to its dedicated skill); generic node-cache, lru-cache, keyv, cache-manager, or Redis client questions; gRPC tutorials unrelated to this package; or unrelated browser-side caches.
Mental model
Reads are read-through: a miss at the local LRU is funnelled through SingleFlightGroup to deduplicate concurrent loads; the loader either hops to the consistent-hash–elected peer over gRPC or invokes the user-supplied Getter to consult the origin. Writes (set/delete) update the local cache and asynchronously fan out to the elected peer with the x-peer-request metadata so the receiver does not echo the propagation back. There is no quorum and no anti-entropy—convergence is best-effort eventual consistency, the same as the Go sibling.
Public API surface
All exports come through packages/lark-cache/src/index.ts. Treat that file as the contract: anything not re-exported is internal and must not be relied on by callers.
Group orchestration (group.ts)
type Getter = (ctx: AbortSignal, key: string) => Promise<Buffer>— origin loader. Receives theAbortSignalfrom the originating call (or the server lifecycle signal when invoked through gRPC).interface GroupOption { (g: Group): void }— functional-options pattern matching the Go side.withExpiration(ms: number): GroupOption— set per-entry TTL in milliseconds;0(default) means no expiration.withPeers(peers: PeerPicker): GroupOption— wire aPeerPicker(typicallyClientPicker) so the group can discover and forward to remote owners.withCacheOptions(opts: CacheOptions): GroupOption— replace the defaultCachewith a custom-sized one.class Group(registered globally by name)constructor(name, cacheBytes, getter, ...opts)— also registers in the package-levelMap<string, Group>. Re-registration logs a warning and replaces the existing entry.get(ctx: AbortSignal, key: string): Promise<ByteView>— read-through with single-flight; throwsErrGroupClosed/ErrKeyRequiredon misuse.set(ctx: AbortSignal, key: string, value: Buffer, isPeerRequest = false): Promise<void>— local write plus best-effort peer fan-out whenisPeerRequestisfalse.delete(ctx: AbortSignal, key: string, isPeerRequest = false): Promise<void>— symmetric toset.clear()— wipe local entries; does not propagate.close()— idempotently dispose; remove from registry.registerPeers(peers)— late-binding alternative towithPeers. Throws if called twice.getStats()— returns counters:loads,local_hits,local_misses,peer_hits,peer_misses,loader_hits,loader_errors, derivedhit_rate,avg_load_time_ms, plus innercache_*stats.getName().
newGroup(name, cacheBytes, getter, ...opts): Group— preferred factory; identical tonew Group(...)but logs creation and registers.getGroup(name): Group | undefined,listGroups(): string[],destroyGroup(name): boolean,destroyAllGroups(): void.
Sentinel errors thrown internally (not exported): ErrKeyRequired, ErrValueRequired, ErrGroupClosed. Users should catch Error and inspect err.message for substrings ("key is required", "value is required", "cache group is closed").
Cache and storage (cache.ts, store.ts, lru.ts)
interface Value { len(): number }— minimal sized-value contract.ByteViewis the canonical implementation.interface Store—get,set,setWithExpiration,delete,clear,len,close. Pluggable but onlyLruStoreis shipped.interface StoreOptions { maxBytes?, bucketCount?, capPerBucket?, level2Cap?, cleanupInterval?, onEvicted? }.defaultStoreOptions()returns{ maxBytes: 8192, bucketCount: 16, capPerBucket: 512, level2Cap: 256, cleanupInterval: 60_000 }.class Cache— facade owned byGroup. Lazily instantiates anLruStoreon firstadd. Trackshits/misses.addWithExpiration(key, view, expirationTime)accepts an absolute deadline (ms epoch); already-expired writes are dropped.class LruStore— sharded two-level LRU.bucketCountis rounded to the next power of two viamaskOfNextPowOf2; each shard holds twoInternalCacheinstances (capacitycapPerBucketandlevel2Cap). Promotion: a get on level 1 moves the entry into level 2, providing scan-resistance and approximating an S3-FIFO/2Q-style design. Per-entry expiry is enforced lazily ongetand proactively by an internalsetInterval(cleanupInterval). Always callclose()to clear the timer.hashBKRD(s)— fast non-cryptographic 32-bit hash used to pick the shard. Do not use it for the consistent-hash ring (that's CRC32 by default).maskOfNextPowOf2(cap)— utility for power-of-two masks.
CacheOptions mirrors StoreOptions plus an alias of cleanupTime for cleanupInterval. defaultCacheOptions() returns the production-ready defaults: maxBytes: 8 MiB, 16 buckets, 512 + 256 capacity per bucket, 60 s cleanup.
Immutable byte container (byte-view.ts)
class ByteViewwraps aBuffer.len()returns size;byteSlice()returns a defensive copy so mutations cannot leak back into the cache;toString()decodes UTF-8.cloneBytes(b)is the always-copy helper used internally before any value enters a cache or crosses the public boundary. Treat all cached values as immutable.
Coalescing concurrent loads (single-flight.ts)
class SingleFlightGroupprovidesdo<T>(key, fn): Promise<T>. Concurrent calls with the same key share a single in-flightPromise; the entry is removed from the internal map infinally, so subsequent identical keys re-execute. This is what protects the origin from a thundering-herd cache miss.
Consistent hashing (consistent-hash.ts, config.ts, crc32.ts)
class ConHashMap— sorted virtual-node ring keyed by 32-bit hashes. Supportsadd(...nodes),remove(node),get(key),getStats(),close().withConHashConfig(config): ConHashOption— override the entire configuration when constructing.interface ConHashConfig { defaultReplicas, minReplicas, maxReplicas, hashFunc, loadBalanceThreshold }.defaultConHashConfigis{ defaultReplicas: 50, minReplicas: 10, maxReplicas: 200, hashFunc: crc32, loadBalanceThreshold: 0.25 }.- Adaptive rebalancing: every second the ring inspects accumulated request counts; once
totalRequests ≥ 1000and the worst node deviates from the average by more thanloadBalanceThreshold(default 25 %), it scales each node's replica count bycurrentReplicas / loadRatio(overloaded) orcurrentReplicas * (2 - loadRatio)(underloaded), clamped to[minReplicas, maxReplicas]. Counters reset and the ring is re-sorted. Always invokeclose()to stop the interval timer when disposing. crc32(data: string | Buffer): number— IEEE polynomial CRC-32 (table-driven, lazily initialized at module load). Type aliasHashFunc = (data: string | Buffer) => number.
Peer abstractions (peers.ts, client.ts, client-picker.ts)
interface Peer—get,set,delete,close, all returningPromises; shape mirrors the gRPC service.interface PeerPicker—pickPeer(key): [Peer | null, found, isSelf],close(). Implementing your own picker (e.g. for static topologies) is supported.class Client implements Peer— gRPC stub. Hard-coded 3 000 ms deadline per RPC andwaitForReady: true. Errors surface asError("failed to {get|set|delete} value from lark_cache: ${grpcMessage}").getAddr()returns the stored address.class ClientPicker implements PeerPicker- Configured with
(addr, opts?: { serviceName?: string }); default service name is"lark_cache". start()performs an initialfetchAllfrom etcd, registers existing peers (excludingself), then subscribes to liveput/deleteevents. Self-address is filtered out so a node never forwards to itself.pickPeer(key)resolves viaConHashMapand returns[null, true, true]for self-ownership,[client, true, false]for a known peer, or[null, false, false]when the address is unknown locally.printPeers()is a debugging helper.close()releases the ring timer, everyClient, and the etcd watcher.
- Configured with
Server and registration (server.ts, register.ts)
interface ServerOptions { etcdEndpoints?, dialTimeout?, maxMsgSize?, tls?, certFile?, keyFile? }. Defaults: etcd atlocalhost:2379, 5 s dial timeout, 4 MiB max message size, TLS off.class Server- Constructor
(addr, svcName, opts?)builds the gRPC server, registerspb.LarkCache(Get/Set/Delete) and thegrpc.health.v1.HealthCheck RPC (always reportsSERVINGfor the configuredsvcName), and resolves credentials (insecure orServerCredentials.createSsl). start()callsbindAsyncthen registers in etcd viaregister(svcName, addr, signal)with a 10-second lease. If registration fails the server still serves traffic but logs the failure.stop()aborts the AbortController (which revokes the lease and deletes the etcd key) and triggers a graceful gRPC shutdown.- The handlers look up the target
Groupby name; missing groups producegrpc.status.NOT_FOUND. Get/Set/Delete inspect the inboundx-peer-requestmetadata to decide whether to suppress further peer fan-out (isPeerRequest = trueshort-circuits the propagation inGroup.set/Group.delete).
- Constructor
register(svcName, addr, stopSignal)— opens anEtcd3client againstdefaultRegisterConfig.endpoints, expands a leading:togetLocalIP():port, attaches a 10-second lease at/services/{svcName}/{addr}, and revokes/deletes onstopSignal.aborted.class ServiceDiscovery— the watcher used byClientPicker.fetchAll()snapshots/services/{svcName};watch()streamsput/deletedeltas. Alwaysclose()to release the watcher and the etcd client.interface RegisterConfiganddefaultRegisterConfigare exported, so callers can hold a single source of truth for endpoints when wiring custom registries.
Helpers (utils.ts)
validPeerAddr(addr)— acceptslocalhost:<port>or<dotted-quad>:<port>. Useful when sanitizing configuration before feeding a picker.getLocalIP()— first non-internal IPv4 address; throws if none exists.
Lifecycle and orchestration
A typical node has the following lifecycle. Honour the order—starting the picker before the server is fine, but never registerPeers before picker.start() has resolved, otherwise the ring is empty and pickPeer always returns self.
- Create the singleton
GroupwithnewGroup(name, cacheBytes, getter, ...opts). - Construct and start the
Server:await new Server(addr, svcName).start(). This binds the gRPC port and registers in etcd. - Construct and start the
ClientPicker:const picker = new ClientPicker(addr, { serviceName: svcName }); await picker.start();— populates the consistent-hash ring with existing peers and subscribes to mutations. - Wire the two:
group.registerPeers(picker)(or supplywithPeers(picker)when callingnewGroup). - Serve normal traffic via
group.get/set/delete(or via the gRPC servicepb.LarkCache). - Shutdown:
server.stop()revokes the etcd lease and stops the server;picker.close()cancels the watcher and disposes peer clients;destroyAllGroups()closes every cache and timer. Each step is independently idempotent.
The reference assembly lives in packages/lark-cache/src/main.ts; the integration runner packages/lark-cache/bootstrap.js boots three local nodes (8001/8002/8003) with a brewed etcd, performs a smoke test, and tears everything down. It also illustrates the set-then-get pattern that sidesteps the 3 s peer deadline in cold-cache scenarios: pre-populating a key on the owning node guarantees a local hit on the read.
Wire format and gRPC contract
The proto definitions are bundled and re-exported via proto/index.ts (proto, healthProto).
// packages/lark-cache/src/proto/lark.proto
syntax = "proto3";
package pb;
message Request { string group = 1; string key = 2; bytes value = 3; }
message ResponseForGet { bytes value = 1; }
message ResponseForDelete { bool value = 1; }
service LarkCache {
rpc Get (Request) returns (ResponseForGet);
rpc Set (Request) returns (ResponseForGet);
rpc Delete(Request) returns (ResponseForDelete);
}
Health checks follow the standard grpc.health.v1.Health/Check contract; the registered service name matches the constructor's svcName so a discovery layer can probe per service.
The metadata key x-peer-request: "true" is the propagation guard. When Group.set/Group.delete forwards to a peer, it sets this header (well, the receiving side reads it through isPeerRequest in server.ts); the receiver therefore writes only locally and refrains from re-forwarding. When you author additional RPCs that mutate state across peers, follow the same convention to avoid storms.
Operational guidance
Topology sizing. cacheBytes in newGroup only sets Cache.maxBytes; the actual storage uses bucketCount × (capPerBucket + level2Cap) slots. Tune via withCacheOptions(defaultCacheOptions()) overrides when entry counts—not byte budgets—are the binding constraint. Power-of-two bucket counts are enforced by maskOfNextPowOf2, so bucketCount: 24 quietly becomes 32 internally (mask 0x1f).
Hot-key fairness. The adaptive replica adjustment in ConHashMap runs every second once the ring has seen ≥ 1 000 routed keys. If you observe one node serving disproportionately, prefer raising defaultReplicas (e.g. 100) rather than lowering loadBalanceThreshold, because lower thresholds amplify oscillation. Drop maxReplicas if you observe runaway memory in keys/hashMap.
Deadlines and cold reads. Every Client RPC carries a 3-second deadline. In a cold cluster a get may traverse Group.load → ClientPicker.pickPeer → Client.get → Server.handleGet → Group.get → Getter; deep pipelines plus origin latency can exceed the budget. Mitigations: (1) pre-warm hot keys by calling set on the owner first; (2) accept the time-out and rely on SingleFlightGroup to coalesce retries; (3) fork client.ts and parameterize the deadline if a different SLA is required (currently hard-coded—fixing this is a deliberate, breaking change). The integration runner bootstrap.js opts for option (1).
Error semantics. Misses on the loader path surface as Error("failed to get data: ${cause}") from Group.loadData. Propagation failures inside syncToPeers are logged but not thrown—writes are deliberately fire-and-forget. If you require write acknowledgement, await Peer.set/Peer.delete directly via picker.pickPeer. Exposed sentinels:
key is required— empty string supplied toget/set/delete.value is required—setcalled with an emptyBuffer.cache group is closed— operations afterclose()/destroyGroup.nil Getter—newGroupinvoked without a loader.RegisterPeers called more than once— defensive double-wiring guard.
Closing resources. Memory leaks in this package almost always trace to a forgotten close. The active timers/watchers are: LruStore.cleanupTimer, ConHashMap.balancerTimer, ServiceDiscovery.watcher, the etcd lease in register, and every Client.grpcClient. Group.close → Cache.close → LruStore.close is automatic; the picker and server own the rest. In tests, prefer try/finally with picker.close() and server.stop() over relying on process exit.
Self vs. remote routing. ClientPicker.pickPeer returns the tuple [peer, found, isSelf]. The Group only forwards when found && !isSelf. If pickPeer returns [null, false, false] the cluster contains zero registered peers (or etcd is unreachable) and the loader falls straight through to Getter. This is the intended single-node fallback.
Network address shape. Bind addresses passed to new Server(addr, ...) should follow host:port ("0.0.0.0:8001", "127.0.0.1:8001", or ":8001"—the latter expands to the local IPv4 in register). Peer addresses stored in etcd are byte-for-byte the value the registering node wrote; ensure all nodes agree on the host portion (don't mix localhost and 127.0.0.1 if validPeerAddr checks are added downstream).
TLS. Setting tls: true, certFile, keyFile on ServerOptions enables mutual-TLS-capable credentials on the server side. The shipped Client uses grpc.credentials.createInsecure() only—if you need a TLS client, fork or extend client.ts. This is a deliberate omission that keeps the bundled demo zero-config.
Build, test, and release
The package is consumed as ESM (exports.import/module both point at dist/index.mjs). The Rollup config (rollup.config.mjs) bundles every src/*.ts file plus copies the .proto files into dist/proto/ so the loader at proto/index.ts (join(__dirname, "lark.proto")) keeps working post-build. Scripts:
pnpm --filter @lark.js/cache run build— Rollup bundle + dts.pnpm --filter @lark.js/cache run test— Vitest suite (coversbyte-view,cache,consistent-hash,crc32,group,lru,single-flight,utils).pnpm --filter @lark.js/cache run format— Prettier.node packages/lark-cache/bootstrap.js— end-to-end smoke (spawns three nodes and an etcd subprocess; expectsbrew etcdonPATH).
When publishing, prepublishOnly re-builds from clean. Only dist/ ships (see package.json#files).
Common pitfalls and how to handle them
- Importing internals.
packages/lark-cache/src/index.tsis the only stable surface. Pull requests that import deep paths (@lark.js/cache/dist/lru) must be redirected through new exports there. - Mutating returned
Buffers.ByteView.byteSlice()already copies;Group.getreturns theByteViewitself. If you callbyteSlice()and mutate, that's safe; mutating the cachedByteViewis not—do not reach into private fields. - Forgetting
await picker.start(). Without the initialfetchAll, the ring is empty and every read falls back to the localGetter. Symptom: 100 %loader_hits, 0 %peer_hits. - Etcd reachability.
registerandServiceDiscoveryboth default tolocalhost:2379viadefaultRegisterConfig. To override, constructEtcd3directly inside a customregisterwrapper or extendregister.ts—the public surface currently exposes the config object butregister()ignores per-call endpoints. This is a known asymmetry with the Go side; flag it before shipping a multi-cluster deployment. - Mixing self addresses. If
Serverbinds:8001(whichregisterupgrades to192.168.x.y:8001) butClientPickeris constructed with127.0.0.1:8001, the picker won't recognize self in etcd events and may forward back to itself. Always pass the exact address pair you registered with. - Test flakiness from timers.
LruStoreandConHashMapstartsetIntervals. In Vitest, ensure testsclose()instances or the worker exits hang. The existing tests demonstrate the pattern. - 3-second peer deadline. As covered above, this is currently a constant in
client.ts. Document it in any user-facing performance budget.
Quick recipes
Single-node, no peers, with TTL and stats:
import { newGroup, withExpiration } from "@lark.js/cache";
const group = newGroup(
"users",
8 * 1024 * 1024,
async (signal, key) => loadUserFromDB(signal, key),
withExpiration(30_000),
);
const view = await group.get(new AbortController().signal, "alice");
const json = view.toString();
console.log(group.getStats()); // hit rate, load times, etc.
Three-node cluster (assembled like main.ts):
import { newGroup, Server, ClientPicker } from "@lark.js/cache";
const SVC = "lark_cache";
const group = newGroup("users", 8 << 20, loader);
const server = new Server("0.0.0.0:8001", SVC);
await server.start();
const picker = new ClientPicker("127.0.0.1:8001", { serviceName: SVC });
await picker.start();
group.registerPeers(picker);
process.on("SIGINT", async () => {
server.stop();
await picker.close();
process.exit(0);
});
Direct gRPC client (no Group involvement):
import { Client } from "@lark.js/cache";
const client = new Client("127.0.0.1:8001");
await client.set("users", "alice", Buffer.from("…"));
const value = await client.get("users", "alice");
await client.close();
Custom consistent-hash configuration:
import { ConHashMap, withConHashConfig, crc32 } from "@lark.js/cache";
const ring = new ConHashMap(
withConHashConfig({
defaultReplicas: 100,
minReplicas: 50,
maxReplicas: 400,
hashFunc: crc32,
loadBalanceThreshold: 0.15,
}),
);
ring.add("127.0.0.1:8001", "127.0.0.1:8002", "127.0.0.1:8003");
console.log(ring.get("alice"));
ring.close();
Cross-reference to the Go sibling
| Concept | TypeScript (@lark.js/cache) |
Go (github.com/hangtiancheng/lark-go/lark_cache) |
|---|---|---|
| Group factory | newGroup(name, cacheBytes, getter, ...opts) |
NewGroup(name, cacheBytes, getter, opts...) |
| Loader signature | (AbortSignal, string) => Promise<Buffer> |
func(ctx context.Context, key string) ([]byte, error) |
| Cancellation | AbortSignal |
context.Context |
| Functional options | withExpiration / withPeers / withCacheOptions / withConHashConfig |
WithExpiration / WithPeers / WithCacheOptions / WithConHashConfig |
| Hash default | crc32 (IEEE) |
crc32.ChecksumIEEE |
| Bucket hash | hashBKRD |
HashBKRD |
| Sentinel errors | Error("key is required"), ... value is required, ... group is closed |
ErrKeyRequired, ErrValueRequired, ErrGroupClosed |
| Peer header | x-peer-request: "true" |
x-peer-request: "true" |
| Client deadline | 3 000 ms (hard-coded) | 3 s (hard-coded) |
| Service-discovery prefix | /services/{svcName}/{addr} |
/services/{svcName}/{addr} |
Symbol parity makes ports trivial; behavioral parity makes a polyglot deployment safe. When the user describes a behavior change, ask whether they want both packages updated in lock-step—drift between them defeats the design intent.