Files
simply_based/lib/simply_based/redis.rb
T
GregandClaude Opus 4.8 86299b8827 SimplyBased 0.1.0 — Couchbase-native ActiveModel ORM
A from-scratch fork of simply_couch's model layer that runs on Couchbase
natively: `include SimplyBased::Model` instead of `include SimplyCouch::Model`.

- SimplyBased::Store replaces the CouchDB layer: KV documents, N1QL/SQL++
  finders + lazily-created GSIs, CAS mapped onto _rev. No design documents or
  JavaScript map/reduce — the hand-written association views become N1QL
  (belongs_to/has_many/has_one scalar FK; habtm via ANY ... SATISFIES + array
  index, or KV multi-get on the storing side).
- Redis replacements on the same cluster: Counter (atomic increment/decrement),
  Cache (ActiveSupport::Cache::Store over KV+TTL, drop-in Rails.cache/ActiveJob),
  Lock (insert-if-absent + TTL + CAS-checked release).
- Runs the simply_couch test suite against live Couchbase: 378/432 applicable
  examples green (CouchDB-mechanic specs tagged :couchdb_only and excluded).
- Remaining: ancestry (tree) + embedded_in reverse-lookups (recursive/embedded
  N1QL) and a few pagination/lifecycle edges.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:58:47 -05:00

136 lines
3.5 KiB
Ruby

# frozen_string_literal: true
require 'base64'
module SimplyBased
# Couchbase-backed replacements for the things apps usually reach to Redis for.
# All three use the same cluster/bucket as your models — one datastore.
# Server-side atomic counters (Couchbase binary increment/decrement). A drop-in
# for Redis INCR/DECR.
#
# counter = SimplyBased::Counter.new
# counter.increment("jobs:processed") # => 1 (created at 0, +1)
# counter.increment("jobs:processed", 5) # => 6
# counter.get("jobs:processed") # => 6
class Counter
def initialize(store: nil)
@store = store
end
def store
@store ||= SimplyBased.database
end
def increment(key, by = 1, initial: 0, ttl: nil)
store.counter_incr(key, by, initial: initial, ttl: ttl)
end
def decrement(key, by = 1, initial: 0, ttl: nil)
store.counter_decr(key, by, initial: initial, ttl: ttl)
end
def get(key)
v = store.kv_get(key)
v.nil? ? nil : v.to_i
end
def reset(key, to = 0, ttl: nil)
store.kv_set(key, to, ttl: ttl)
to
end
end
# An ActiveSupport::Cache::Store backed by Couchbase KV + native TTL — usable
# as `Rails.cache` and therefore as the ActiveJob / fragment / low-level cache.
#
# Rails.application.config.cache_store = SimplyBased::Cache.new(namespace: "app")
class Cache < ActiveSupport::Cache::Store
def initialize(options = {})
@store = options.delete(:store)
@namespace = options.delete(:namespace)
super(options)
end
def store
@store ||= SimplyBased.database
end
def clear(**)
# Namespaced clear would need a key scan; left to the app (or drop the
# bucket in tests). KV TTLs expire entries automatically.
nil
end
private
def normalize_key(name, options = nil)
k = super
@namespace ? "#{@namespace}::#{k}" : k
end
def read_entry(key, **)
raw = store.kv_get(key)
raw && Marshal.load(Base64.strict_decode64(raw))
end
def write_entry(key, entry, **options)
ttl = options[:expires_in] && options[:expires_in].to_i
store.kv_set(key, Base64.strict_encode64(Marshal.dump(entry)), ttl: ttl)
true
end
def delete_entry(key, **)
store.kv_delete(key)
end
end
# A distributed lock via insert-if-absent + TTL (auto-expiry) and CAS-checked
# release, so only the holder can unlock. Replaces Redis SETNX-style locks.
#
# SimplyBased::Lock.new.with_lock("rebuild:report", ttl: 30) do
# # critical section — only one process at a time
# end
class Lock
def initialize(store: nil)
@store = store
end
def store
@store ||= SimplyBased.database
end
# Returns a CAS token (the lock handle) or nil if already held.
def acquire(key, ttl: 30)
store.kv_add(lock_key(key), { 'locked_at' => Time.now.to_i }, ttl: ttl)
end
# Release only if we still hold the same CAS (no stomping a re-acquired lock).
def release(key, cas = nil)
store.kv_delete(lock_key(key), cas: cas)
end
def with_lock(key, ttl: 30, retries: 0, wait: 0.1)
cas = acquire(key, ttl: ttl)
tries = 0
while cas.nil? && tries < retries
sleep wait
tries += 1
cas = acquire(key, ttl: ttl)
end
raise SimplyBased::Error, "could not acquire lock: #{key}" if cas.nil?
begin
yield
ensure
release(key, cas)
end
end
private
def lock_key(key)
"lock::#{key}"
end
end
end