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>
136 lines
3.5 KiB
Ruby
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
|