Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
185ca0ca17 | ||
|
|
37288db487 | ||
|
|
9ce5aea23d | ||
|
|
bde6df57f4 | ||
|
|
84c76bb093 | ||
|
|
1beb8ead11 |
No files matched your search
@@ -1,6 +1,39 @@
|
||||
Changelog
|
||||
=============
|
||||
|
||||
0.1.1 (unreleased, branch code-review-fixes)
|
||||
-----
|
||||
Independent code review against live Couchbase 8.0.1 CE (see
|
||||
SIMPLY_BASED_REVIEW.md). Fixes:
|
||||
|
||||
- Embedded documents: fixed SystemStackError (infinite mutual recursion) when
|
||||
accessing embedded collections loaded from the database; rebuilt
|
||||
EmbeddedClass.all/.count (they referenced silently-dropped raw JS views —
|
||||
.all returned [] and .count raised NoMethodError); the spec exclusion for
|
||||
embedding was broken (filtered a tag no example carried) — basic embedded
|
||||
flows now run green, only reverse lookups stay excluded, honestly tagged.
|
||||
- Store#view raises SimplyBased::UnsupportedView for view declarations the
|
||||
N1QL translation cannot honour (:conditions / :emit_value / list functions)
|
||||
instead of silently returning unfiltered results.
|
||||
- N1QL identifier quoting hardened (quote_ident rejects backtick smuggling);
|
||||
index predicates quote class names as proper string literals.
|
||||
- ensure_index warns when index creation is swallowed (was fully silent while
|
||||
every subsequent query fell back to a primary scan); bookkeeping is
|
||||
mutex-guarded.
|
||||
- bulk_load / bulk_destroy now use batched KV multi-operations (get_multi /
|
||||
remove_multi) — previously one KV round trip per document despite the
|
||||
"batched KV" claim.
|
||||
- Counter#increment follows Redis INCR/INCRBY semantics (first increment of a
|
||||
missing key returns the delta; it used to return 0 and undercount by one).
|
||||
Decrement's unsigned clamp-at-zero divergence is documented and tested.
|
||||
- Lock#release requires the CAS handle (nil used to delete any lock
|
||||
unconditionally); force_release! added; acquire validates a positive ttl.
|
||||
- Deterministic tree ordering: tree queries tie-break equal positions by
|
||||
created_at and document id (was the main source of the "flaky ancestry"
|
||||
failures).
|
||||
- The adapter-contract shared examples are now actually included by a spec;
|
||||
added multi-threaded concurrency specs (counters, locks, CAS conflicts).
|
||||
|
||||
0.1.0
|
||||
-----
|
||||
- Initial release: a Couchbase-native ActiveModel ORM, API-compatible with
|
||||
|
||||
@@ -70,11 +70,16 @@ SimplyBased::Lock.new.with_lock("rebuild", ttl: 30) do
|
||||
end
|
||||
```
|
||||
|
||||
- **Counter** — Couchbase binary increment/decrement (true atomic counters).
|
||||
- **Counter** — Couchbase binary increment/decrement (true atomic counters),
|
||||
with Redis INCR/INCRBY semantics: the first increment of a missing key
|
||||
returns the delta. One divergence: Couchbase counters are unsigned, so
|
||||
decrement clamps at 0 instead of going negative.
|
||||
- **Cache** — an `ActiveSupport::Cache::Store` over KV + native TTL; drop-in for
|
||||
`Rails.cache` and therefore ActiveJob.
|
||||
- **Lock** — distributed lock: insert-if-absent + TTL auto-expiry + CAS-checked
|
||||
release so only the holder can unlock.
|
||||
`Rails.cache` and therefore ActiveJob. (`clear` is a no-op — entries expire
|
||||
via TTL.)
|
||||
- **Lock** — distributed lock: insert-if-absent + mandatory TTL auto-expiry +
|
||||
CAS-checked release, so only the holder can unlock (`force_release!` is the
|
||||
explicit override) and a crashed holder can never leave an immortal lock.
|
||||
|
||||
## The one recommended pattern shift
|
||||
|
||||
@@ -85,22 +90,41 @@ Couchbase **Collection** — tighter GSIs, cleaner queries, idiomatic Couchbase.
|
||||
|
||||
## Status & test coverage
|
||||
|
||||
SimplyBased runs the **simply_couch test suite** against a live Couchbase.
|
||||
SimplyBased runs the **simply_couch test suite** (plus its own additions)
|
||||
against a live Couchbase. See `SIMPLY_BASED_REVIEW.md` for the full
|
||||
independent review this status section is based on.
|
||||
|
||||
- **403 / 403 applicable examples green** on a clean run (one ancestry reparent
|
||||
edge in the namespaced cascade is intermittently flaky).
|
||||
- **Green:** CRUD, finders (`find`, `all`, `first`, `find_by_*`, `count_by_*`),
|
||||
`belongs_to` / `has_many` / `has_one` / `has_and_belongs_to_many`, validations,
|
||||
callbacks, dirty tracking, soft-delete (incl. soft-delete-aware finders),
|
||||
pagination, bulk, eager-loading, **ancestry / trees**, and the Redis primitives.
|
||||
pagination, bulk (true batched KV multi-ops), eager-loading, embedded documents
|
||||
(`has_many_embedded` / `is_embedded_in`, minus reverse lookups),
|
||||
**ancestry / trees**, the Redis primitives, adapter-contract shared examples,
|
||||
and multi-threaded concurrency examples (CAS conflicts, atomic counters,
|
||||
lock mutual exclusion).
|
||||
- **Ancestry** is a materialized path (`path_ids`, root→self): children =
|
||||
`path_ids[len-2] = $id`, subtree = `$id IN path_ids`, roots =
|
||||
`ARRAY_LENGTH(path_ids) = 1` — plain N1QL, no recursive view build.
|
||||
- **Views that cannot translate to N1QL raise `SimplyBased::UnsupportedView`**
|
||||
at query time (`:conditions` JS fragments, `:emit_value`, list functions)
|
||||
instead of silently returning unfiltered results.
|
||||
- **Excluded by design:**
|
||||
- `:couchdb_only` specs — native inline attachments, JS-view token-safety,
|
||||
design-document maintenance, raw custom JS views: no Couchbase equivalent
|
||||
(use `has_s3_attached` / `has_local_attached`; N1QL uses bound parameters).
|
||||
- `embedded_in` reverse-lookups — out of scope for this gem.
|
||||
- `embedded_in` reverse-lookups (`:embedded_in_reverse_lookup`) — querying a
|
||||
foreign key stored *inside* an embedded array element would need an
|
||||
UNNEST-based query path; not implemented.
|
||||
|
||||
### Known gaps (unchanged, deliberate)
|
||||
|
||||
- **No multi-document transactions** — single-document atomicity + CAS only.
|
||||
Couchbase's SDK has transactions; this ORM does not surface them.
|
||||
- **No index-migration framework** — GSIs are created lazily per association;
|
||||
ad-hoc queries need deliberately designed indexes or they fall back to a
|
||||
primary scan (a warning is emitted when lazy index creation fails).
|
||||
- `Cache#clear` is a no-op (namespaced clear would need a key scan); entries
|
||||
expire via their TTLs.
|
||||
|
||||
```bash
|
||||
docker run -d --name cb -p 8091-8096:8091-8096 -p 11210:11210 couchbase:community
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# simply_based — Independent Code Review
|
||||
|
||||
**Reviewer:** Claude (Claude Code session, 2026-07-06), for Benjamin ter Kuile
|
||||
**Scope:** the gem as an artifact — correctness, test honesty, safety, production readiness. Whether mozo should sit on Couchbase at all is a separate, already-settled question and not revisited here.
|
||||
**Verified against:** live Couchbase Server 8.0.1 Community (aarch64), full suite runs before and after fixes. All changes on branch `code-review-fixes`; `main` untouched.
|
||||
|
||||
> Verification environment note: this machine runs a 16K-page Asahi kernel, which Couchbase's jemalloc build refuses (native and qemu-user both fail). The server was run from the official image's extracted rootfs inside a `muvm` 4K-page microVM via bubblewrap, and the suite executed inside the same guest. Full recipe at the bottom.
|
||||
|
||||
---
|
||||
|
||||
## Verdict
|
||||
|
||||
**Good bones, not yet production-grade — and closer after this pass.** The architecture is genuinely sound: the Store seam is clean, N1QL generation uses bound parameters, CAS-as-`_rev` optimistic concurrency is implemented correctly (no lost updates found, including under threads), and the Redis-replacement primitives are built on the right server primitives. But the review found one hard crash bug in shipped code (embedded documents), one silent-wrong-results footgun (`:conditions` views), several smaller correctness/semantic issues, and a headline test claim that does not reproduce as stated. For a gem written in one session, the hit rate is low; but every one of these would have burned a production user.
|
||||
|
||||
What it is after this branch: a credible 0.1.x you could pilot on a low-stakes document-shaped workload, provided you accept the two structural gaps (no multi-document transactions, no index migrations). What it is not: something to put under money- or inventory-critical flows, because the transaction gap is architectural, not a bug.
|
||||
|
||||
---
|
||||
|
||||
## Does the "403/403 applicable examples green" claim hold?
|
||||
|
||||
**Not as the repo stands.** Clean checkout of `main`, live Couchbase, default `bundle exec rspec`:
|
||||
|
||||
```
|
||||
423 examples, 17 failures, 1 pending
|
||||
```
|
||||
|
||||
All 17 failures are in `embedding_spec.rb`. The session report says embedded specs were "excluded by design" — but the exclusion line (`filter_run_excluding(:embedding)`) filters a tag **no example carries**, so the whole file ran and failed. Worse, the failures are not "unimplemented feature politely declining": basic `has_many_embedded` access on a database-loaded record died with `SystemStackError` (see finding 1), which means embedded documents were not merely out of scope — they were broken, and the broken state was invisible because the suite was believed to exclude them.
|
||||
|
||||
The nuance in the gem's favour: the ~405 examples *outside* the embedding area do pass, which roughly matches the 403 figure — so the claim was most likely true of whatever tree state the original session measured, and rotted before commit. The report's other numbers (Redis primitives 5/5, `:couchdb_only` exclusions) check out. Its characterization of the flaky ancestry test as a save/race issue turned out to be wrong (finding 4).
|
||||
|
||||
After the fixes on this branch: **442 examples, 0 failures, 1 pending** (see "Final verification" below), with exclusions that are real: `:couchdb_only` and `:embedded_in_reverse_lookup`, both tags that examples actually carry.
|
||||
|
||||
---
|
||||
|
||||
## Findings, by severity
|
||||
|
||||
### 1. Embedded documents: `SystemStackError` on any DB-loaded access — **confirmed crash, fixed**
|
||||
|
||||
`HasManyEmbedded::Property#build` wired `parent_object` onto each embedded record *before* installing the collection ivar on the parent. `parent_object=` reaches back into the parent's collection getter (via the `embedded_in` writer's `include?` check); with the ivar unset, that getter re-enters `build` — unbounded mutual recursion. Every one of `Post.find(id).embedded_comments`-style accesses crashed. Fixed by installing the ivar before wiring (`lib/simply_based/model/has_many_embedded.rb`).
|
||||
|
||||
Two adjacent breaks in the same area, also fixed:
|
||||
- `EmbeddedComment.all` returned `[]` and `.count` raised `NoMethodError` — both were built on raw JS views that the N1QL port silently drops (`ClassMethods::Base#view` no-ops any `:map_function`/`:custom`/`:raw` registration). Rebuilt by enumerating parent documents (same work as the old CouchDB view scan, client-side; an UNNEST query is the future optimization).
|
||||
- The spec exclusion was fixed to be honest: basic embedded flows now run and pass; only reverse lookups (querying a foreign key held *inside* an embedded array element) remain unimplemented, tagged `:embedded_in_reverse_lookup` and excluded with an explanatory comment.
|
||||
|
||||
`embedding_spec.rb`: 16 examples, 0 failures against live Couchbase.
|
||||
|
||||
### 2. `:conditions` views silently return unfiltered results — **confirmed silent-wrong-results, fixed**
|
||||
|
||||
simply_couch allows `view :completed, key: :name, conditions: 'doc.completed === true'`. The Store's N1QL translation read only `:key` and ignored `:conditions` entirely — the query ran *without the condition* and returned every document of the class. No error, no warning, plausible-looking results. This is the worst failure mode a storage layer can have. Same (lower-stakes) story for `:emit_value` and CouchDB list functions.
|
||||
|
||||
Fixed: `Store#view` now raises `SimplyBased::UnsupportedView` with a pointed message when a queried view declares any untranslatable option. Declaring such a view stays legal (models port unchanged); querying it fails loudly. Covered by `spec/unsupported_view_spec.rb`.
|
||||
|
||||
### 3. The "flaky ancestry race" is not a race — **root-caused, fixed**
|
||||
|
||||
The session report: *"one ancestry reparent edge in the namespaced (by_property) cascade is intermittently flaky (~50%) — it does several dependent saves and occasionally races."* Reproduced at ~50% over repeated runs, captured the failure, and the diagnosis is wrong: the failing example is `NamespacedDirectory.roots` returning `[dir4, dir1]` instead of `[dir1, dir4]` — **an ORDER BY tie**, not a save race. Fixture records all share `position: 0`; `run_tree` ordered by `position` alone; N1QL tie order is whatever the index scan produces. `run_docs` already tie-broke by `created_at, META(c).id`; `run_tree` didn't.
|
||||
|
||||
Fixed: `run_tree` now orders `position, created_at, META(c).id`; the two client-side tree sorts (`descendants`, `build_tree`) got the same tie-break because Ruby's `sort_by` is unstable. Ancestry spec then ran repeatedly without failure.
|
||||
|
||||
The actual multi-save cascade in `children=` / `parent=` (many dependent saves, no transaction) is real and ugly — see finding 8 — but it was not what made the test flaky, and CAS conflicts in it would raise, not reorder.
|
||||
|
||||
### 4. `Counter#increment` undercounts by one — **semantic bug for its stated purpose, fixed**
|
||||
|
||||
Couchbase's binary increment seeds a missing key at `initial` (default 0) and *ignores the delta on creation*: the first `INCR` returned 0 where Redis returns 1. For the advertised "drop-in for Redis INCR" (rate limiters, job counters), that's a permanent off-by-one that nobody ever notices. The old spec even asserted the wrong value, codifying it. Fixed: `increment` seeds at the delta (Redis INCRBY parity), explicit `initial:` still overrides; `decrement`'s clamp-at-zero divergence (Couchbase counters are unsigned) is now documented and tested rather than implicit.
|
||||
|
||||
### 5. `Lock#release(key, cas = nil)` unlocked other holders' locks — **safety footgun, fixed**
|
||||
|
||||
With `cas` defaulting to nil, `release(key)` deleted the lock document *unconditionally* — any caller could unlock a lock it never held, which defeats the point of the CAS-checked release the README advertises. Fixed: the handle is required, `force_release!` is the explicit override, and `acquire` rejects nil/zero TTLs (a crashed holder must never leave an immortal lock). The TTL-expiry path (stale handle must not unlock the new holder) was already correct — now there's a test proving it.
|
||||
|
||||
### 6. "Batched KV" bulk ops were one round trip per document — **claim/code mismatch, fixed**
|
||||
|
||||
`bulk_load` looped `collection.get` per id; `bulk_destroy` looped `remove`. The SDK has `get_multi`/`remove_multi`. Both now use them — one KV round trip per batch, which is the behaviour the README and session report already claimed.
|
||||
|
||||
### 7. Test-suite honesty issues — **fixed**
|
||||
|
||||
- The adapter-contract shared examples (`spec/support/adapter_contract.rb`, "a simply_couch adapter") were shipped but **never included by any spec** — the parity proof they document never executed. Now included (`spec/store_contract_spec.rb`).
|
||||
- Zero multi-threaded examples existed despite CAS concurrency and Redis-replacement being the gem's core claims. Added `spec/concurrency_spec.rb`: concurrent counter increments (no lost increments), lock granted to exactly one of many contenders, `with_lock` mutual exclusion, stale-handle release after expiry, same-attribute CAS conflict (loser raises, winner's data intact), different-attribute auto-merge, and a disjoint-attribute write storm. All pass against the live cluster.
|
||||
|
||||
### 8. Flagged but not fixed (with reasons)
|
||||
|
||||
- **No multi-document transactions.** Real and architectural. Every multi-entity operation in the model layer — `children=`'s reparent cascade, `dependent: :destroy` fan-out, HABTM's write-to-the-other-side — is a sequence of independent CAS writes; a crash mid-sequence leaves half-applied state. Couchbase's SDK does offer transactions; surfacing them means designing an API (`SimplyBased.transaction do ... end`), deciding lambda-retry semantics, and touching every association mutator. That is a feature project, not a review fix. Until then this gem should not sit under flows where partially-applied writes are unacceptable.
|
||||
- **No index-migration framework.** GSIs are created lazily per association at first query, which is fine for associations but means ad-hoc `find_by_*` queries ride the primary index until someone notices. A `rake simply_based:indexes` that walks the models and declares expected GSIs would be the next increment. Mitigated in this pass: swallowed index-creation failures now warn instead of vanishing.
|
||||
- **`create` with a client-supplied duplicate id can silently merge into the existing document.** `save_document` maps `DocumentExists` to `Conflict`, and `auto_conflict_resolution_on_save` (default on) then loads the existing doc, adopts its `_rev`, and — if no attribute conflicts — retries as an *update*. Inherited simply_couch behaviour, arguably intended, definitely surprising. Left as-is; documented here.
|
||||
- **Model equality includes `_rev`.** Two loads of the same document at different revisions compare unequal (ActiveRecord compares by id). Associations and specs rely on the current semantics, so changing it is a breaking decision for the owner, not a review fix. Be aware of it when putting models in Sets or comparing across saves.
|
||||
- **`Cache#clear` is a no-op** (would need a key scan or a namespace epoch). Now documented in the README. An epoch-based namespace version (bump one counter, prefix keys) is the clean future fix.
|
||||
- **`is_embedded_in` remains legacy-shaped.** It still registers dead JS views (harmlessly dropped) and its `belongs_to` override — the reverse-lookup path — is unimplemented on N1QL (excluded, tagged). An UNNEST-based query path would complete it.
|
||||
- **Ancestry's `children=` mutates and saves eagerly** (assignment triggers N saves, old descendants get re-rooted and saved immediately). Correctness held up under test, but it's an API where assignment has heavyweight side effects. Design smell inherited from simply_couch; noted, not changed.
|
||||
|
||||
### 9. Things checked that came back clean
|
||||
|
||||
- **N1QL injection:** all *values* go through named parameters (`$rc`, `$v`, `$k0`…); `LIMIT`/`OFFSET` coerce through `Integer()`. Identifiers (property/column names) were interpolated with backtick wrapping — developer-controlled, so not an injection vector in practice, but now hardened anyway (`quote_ident` rejects backticks/backslashes; class names in index predicates use proper string-literal quoting).
|
||||
- **CAS discipline:** `persist` always sends the stored CAS on replace; a nil `_rev` implies `new?` and takes the insert path, so there is no replace-without-CAS route. `CasMismatch`/`DocumentExists` map to `SimplyBased::Conflict`; `retry_on_conflict` retries at most twice and only when auto-merge succeeds; merge conflicts surface with attribute names in the exception. Confirmed under threads: the same-attribute loser always raises, the winner's write is never stomped.
|
||||
- **Cache TTL >30 days:** Couchbase interprets big integer expiries as epoch timestamps — a classic footgun — but the Ruby SDK (`Couchbase::Utils::Time.extract_expiry_time`) converts correctly, so `expires_in: 60.days` is safe.
|
||||
- **Soft-delete-aware finders:** the `[key, nil]` element-wise comparison correctly matches `MISSING OR NULL` for non-deleted docs; live-verified.
|
||||
- **Pagination determinism:** microsecond `created_at` plus `META(c).id` tie-break in `run_docs` holds up; ascending/descending are exact reverses.
|
||||
- **Store thread-sharing:** the SDK cluster object is thread-safe; the one mutable Store structure (`@ensured_indexes`) is now mutex-guarded.
|
||||
|
||||
---
|
||||
|
||||
## What was fixed on this branch (commit story)
|
||||
|
||||
1. `Fix embedded documents: recursion crash, .all/.count, honest spec exclusion`
|
||||
2. `Store: fail loudly on untranslatable views; quoting hardening; true batched bulk KV`
|
||||
3. `Counter/Lock: Redis INCR parity and safe lock release`
|
||||
4. `Add real concurrency coverage and actually run the adapter contract`
|
||||
5. `Deterministic tree ordering (the "flaky ancestry" fix)` + README/CHANGELOG truthing + this review
|
||||
|
||||
## Final verification
|
||||
|
||||
Full suite, clean run, live Couchbase 8.0.1 CE, after all fixes:
|
||||
|
||||
```
|
||||
Run options: exclude {embedded_in_reverse_lookup: true, couchdb_only: true}
|
||||
442 examples, 0 failures, 1 pending
|
||||
```
|
||||
|
||||
- The 1 pending example is `s3_spec.rb`'s `xit` (attachment-on-validation-failure), pre-existing.
|
||||
- Exclusions are tag-real: `:couchdb_only` (CouchDB mechanics with no Couchbase equivalent) and `:embedded_in_reverse_lookup` (documented gap).
|
||||
- `ancestry_spec.rb` re-run repeatedly post-fix with no failures (previously ~50% flaky).
|
||||
|
||||
## Reproducing the environment (Asahi/16K-page hosts)
|
||||
|
||||
Couchbase's jemalloc requires 4K pages; on an Asahi kernel neither the native arm64 image nor qemu-user x86_64 emulation runs. Working recipe:
|
||||
|
||||
```bash
|
||||
# extract the official image's rootfs (arm64)
|
||||
podman pull --platform linux/arm64 docker.io/library/couchbase:community
|
||||
cid=$(podman create couchbase:community); podman export "$cid" | tar -C /var/tmp/cb-root -xf -; podman rm "$cid"
|
||||
|
||||
# run server + suite inside a 4K-page muvm guest (shares the host FS)
|
||||
muvm --mem 4608 -- /var/tmp/cb-suite.sh
|
||||
# where cb-suite.sh: bwrap --bind /var/tmp/cb-root / ... couchbase-server &
|
||||
# curl :8091/clusterInit (services=kv,n1ql,index, CE storage mode)
|
||||
# create couchstore bucket simply_based_test, wait for :8093 query service
|
||||
# COUCHBASE_URL=couchbase://Administrator:password@localhost/simply_based_test bundle exec rspec
|
||||
```
|
||||
|
||||
Note muvm's inbound port forwarding did not deliver data on this passt version, hence running the suite inside the same guest rather than from the host.
|
||||
@@ -21,6 +21,10 @@ unless defined?(SimplyBased::VERSION)
|
||||
class NotImplementedError < RuntimeError; end
|
||||
class ModelNotInstantiatedError < RuntimeError; end
|
||||
class NotFound < RuntimeError; end
|
||||
# Raised when a declared view carries options the N1QL translation cannot
|
||||
# honour (e.g. :conditions / :emit_value / list functions). Failing loudly
|
||||
# beats silently returning unfiltered results.
|
||||
class UnsupportedView < Error; end
|
||||
|
||||
# No-op retained for source compatibility with SimplyCouch. SimplyBased
|
||||
# never interpolates values into JavaScript views — queries are N1QL with
|
||||
|
||||
@@ -68,8 +68,11 @@ module SimplyBased
|
||||
return @descendants if @descendants
|
||||
return @descendants = [] if id.blank?
|
||||
prop = self.class.ancestry_by_property
|
||||
# created_at/id tie-breaks: records habitually share position 0 and
|
||||
# Ruby's sort_by is not stable, so [depth, position] alone reorders
|
||||
# siblings between runs.
|
||||
@descendants = self.class.database.tree_descendants(self.class, id,
|
||||
by_property: prop, by_value: (prop && send(prop))).sort_by { |d| [d.path_ids.size, d.position] }
|
||||
by_property: prop, by_value: (prop && send(prop))).sort_by { |d| [d.path_ids.size, d.position, d.created_at.to_s, d.id.to_s] }
|
||||
@children = self.class.build_tree(@descendants)
|
||||
@descendants
|
||||
end
|
||||
@@ -254,7 +257,7 @@ module SimplyBased
|
||||
tree_wrapper = Struct.new(:children).new([]) # Dummy container as traversing begin, contains roots as children
|
||||
old_tree_slice = tree_wrapper.children
|
||||
new_tree_slice = []
|
||||
pages.sort_by!{|p| [p.path_ids.size, p.position]}
|
||||
pages.sort_by!{|p| [p.path_ids.size, p.position, p.created_at.to_s, p.id.to_s]} # deterministic tie order (sort_by is unstable)
|
||||
root_depth = pages.first.path_ids.size
|
||||
current_depth = root_depth + 1 # Start counting/swapping from one depth deeper than first one
|
||||
for page in pages
|
||||
|
||||
@@ -94,8 +94,23 @@ module SimplyBased
|
||||
:include_docs => true
|
||||
end
|
||||
|
||||
# The CouchDB implementation used raw JS views (all_documents /
|
||||
# all_documents_for_count) that emitted one row per embedded
|
||||
# element. Those views are no-ops on Couchbase (see
|
||||
# ClassMethods::Base#view), so enumerate through the parent
|
||||
# documents instead: embedded records only exist inside their
|
||||
# parent, and the parent getter wires parent_object/index.
|
||||
# NOTE: this loads all parent documents — same order of work as
|
||||
# the CouchDB view scan, but client-side. Fine for small/medium
|
||||
# collections; revisit with an UNNEST query if it ever matters.
|
||||
define_method :all do |*args|
|
||||
parent_klass = Object.const_get(embedded_in_class_name)
|
||||
embedded_property = self.name.property_name.pluralize
|
||||
parent_klass.all.flat_map { |p| Array.wrap(p.send(embedded_property)) }
|
||||
end
|
||||
|
||||
define_method :count do |options = {}|
|
||||
database.view(all_documents_for_count(options.merge(:reduce => true)))['rows'].try(:first).try('[]', 'value').to_i
|
||||
all.size
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -145,17 +145,22 @@ module SimplyBased
|
||||
|
||||
def build(object, json)
|
||||
values = Array.wrap(json[name]).map do |v|
|
||||
obj = if v.is_a?(Hash)
|
||||
options[:class_name].constantize.new.tap { |o| o._document = v }
|
||||
else
|
||||
v
|
||||
end
|
||||
obj.parent_object = object
|
||||
obj
|
||||
if v.is_a?(Hash)
|
||||
options[:class_name].constantize.new.tap { |o| o._document = v }
|
||||
else
|
||||
v
|
||||
end
|
||||
end
|
||||
values.define_singleton_method(:parent_object_set?) { true }
|
||||
values.each_with_index { |v, i| v.index = i }
|
||||
# Install the collection on the parent *before* wiring parent_object:
|
||||
# parent_object= reaches back into the parent's collection getter (via
|
||||
# the embedded_in "#{name}=" writer's include? check), and while the
|
||||
# ivar is unset that getter re-enters build — infinite mutual
|
||||
# recursion (SystemStackError).
|
||||
object.instance_variable_set("@#{name}", values)
|
||||
values.each_with_index { |v, i| v.index = i }
|
||||
values.each { |v| v.parent_object = object }
|
||||
values
|
||||
end
|
||||
|
||||
def initialize(owner_clazz, name, options = {})
|
||||
|
||||
@@ -7,12 +7,15 @@ module SimplyBased
|
||||
# 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.
|
||||
# for Redis INCR/INCRBY.
|
||||
#
|
||||
# counter = SimplyBased::Counter.new
|
||||
# counter.increment("jobs:processed") # => 1 (created at 0, +1)
|
||||
# counter.increment("jobs:processed") # => 1 (like Redis INCR on a missing key)
|
||||
# counter.increment("jobs:processed", 5) # => 6
|
||||
# counter.get("jobs:processed") # => 6
|
||||
#
|
||||
# Divergence from Redis: Couchbase counters are unsigned 64-bit —
|
||||
# decrementing below zero clamps at 0 (Redis DECR would go negative).
|
||||
class Counter
|
||||
def initialize(store: nil)
|
||||
@store = store
|
||||
@@ -22,10 +25,16 @@ module SimplyBased
|
||||
@store ||= SimplyBased.database
|
||||
end
|
||||
|
||||
def increment(key, by = 1, initial: 0, ttl: nil)
|
||||
store.counter_incr(key, by, initial: initial, ttl: ttl)
|
||||
# Matches Redis INCRBY semantics: incrementing a missing key seeds it at
|
||||
# `by` (Couchbase's native increment would seed at `initial` and *ignore*
|
||||
# the delta on creation — the first INCR would return 0, silently
|
||||
# undercounting). Pass an explicit `initial:` to override.
|
||||
def increment(key, by = 1, initial: nil, ttl: nil)
|
||||
store.counter_incr(key, by, initial: initial || by, ttl: ttl)
|
||||
end
|
||||
|
||||
# A missing key seeds at `initial` (default 0): Couchbase counters cannot
|
||||
# go negative, so Redis DECRBY parity (-by) is impossible here.
|
||||
def decrement(key, by = 1, initial: 0, ttl: nil)
|
||||
store.counter_decr(key, by, initial: initial, ttl: ttl)
|
||||
end
|
||||
@@ -101,15 +110,28 @@ module SimplyBased
|
||||
end
|
||||
|
||||
# Returns a CAS token (the lock handle) or nil if already held.
|
||||
# A positive ttl is mandatory: without expiry, a crashed holder would leave
|
||||
# the lock stuck forever.
|
||||
def acquire(key, ttl: 30)
|
||||
raise ArgumentError, "lock ttl must be a positive number of seconds (got #{ttl.inspect})" unless ttl.respond_to?(:positive?) && ttl.positive?
|
||||
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)
|
||||
# Release only if we still hold the same CAS (no stomping a lock that
|
||||
# expired and was re-acquired by someone else). The handle is required —
|
||||
# a nil CAS used to delete the lock document unconditionally, letting any
|
||||
# caller unlock a lock it never held. Returns false when the handle is
|
||||
# stale (someone else holds the lock now).
|
||||
def release(key, cas)
|
||||
raise ArgumentError, 'release requires the CAS handle returned by acquire (use force_release! to override)' if cas.nil?
|
||||
store.kv_delete(lock_key(key), cas: cas)
|
||||
end
|
||||
|
||||
# Unconditional unlock — administrative override only.
|
||||
def force_release!(key)
|
||||
store.kv_delete(lock_key(key))
|
||||
end
|
||||
|
||||
def with_lock(key, ttl: 30, retries: 0, wait: 0.1)
|
||||
cas = acquire(key, ttl: ttl)
|
||||
tries = 0
|
||||
|
||||
+97
-30
@@ -23,6 +23,7 @@ module SimplyBased
|
||||
@collection_name = cfg[:collection] || '_default'
|
||||
@cluster = ::Couchbase::Cluster.connect(cfg[:conn_str], cfg[:user], cfg[:password])
|
||||
@ensured_indexes = {}
|
||||
@ensured_indexes_mutex = Mutex.new
|
||||
end
|
||||
|
||||
def keyspace
|
||||
@@ -77,7 +78,13 @@ module SimplyBased
|
||||
def bulk_load(ids)
|
||||
ids = Array(ids).compact
|
||||
return [] if ids.empty?
|
||||
ids.filter_map { |id| load_document(id) }
|
||||
_track("BULK_GET #{ids.join(',')}")
|
||||
# One batched KV round trip (get_multi); missing ids are dropped, like
|
||||
# the CouchDB _bulk_docs behaviour this replaces.
|
||||
collection.get_multi(ids).filter_map do |res|
|
||||
next unless res.success?
|
||||
hydrate(res.content, res.id, res.cas)
|
||||
end
|
||||
end
|
||||
|
||||
def bulk_save(documents, validate = true)
|
||||
@@ -106,14 +113,19 @@ module SimplyBased
|
||||
def bulk_destroy(documents)
|
||||
documents = Array(documents)
|
||||
return [] if documents.empty?
|
||||
rows = documents.map do |d|
|
||||
begin
|
||||
collection.remove(d._id) if d._id
|
||||
with_ids, without_ids = documents.partition(&:_id)
|
||||
_track("BULK_REMOVE #{with_ids.map(&:_id).join(',')}") if with_ids.any?
|
||||
results = with_ids.any? ? collection.remove_multi(with_ids.map(&:_id)) : []
|
||||
rows = with_ids.zip(results).map do |d, res|
|
||||
if res.nil? || res.error.nil?
|
||||
{ 'id' => d._id, 'ok' => true }
|
||||
rescue ::Couchbase::Error::DocumentNotFound
|
||||
elsif res.error.is_a?(::Couchbase::Error::DocumentNotFound)
|
||||
{ 'id' => d._id, 'error' => 'not_found' }
|
||||
else
|
||||
{ 'id' => d._id, 'error' => res.error.class.name }
|
||||
end
|
||||
end
|
||||
rows += without_ids.map { { 'id' => nil, 'error' => 'not_found' } }
|
||||
documents.each { |d| d._id = nil; d._rev = nil }
|
||||
rows
|
||||
end
|
||||
@@ -122,6 +134,7 @@ module SimplyBased
|
||||
# Handles ModelViewSpec / DeletedModelViewSpec (all_documents, find_by, count).
|
||||
|
||||
def view(spec)
|
||||
assert_translatable_view!(spec)
|
||||
kls = spec.klass
|
||||
vp = spec.view_parameters
|
||||
where = ["c.`ruby_class` = $rc"]
|
||||
@@ -151,7 +164,7 @@ module SimplyBased
|
||||
# key column equals `value`.
|
||||
def find_by_foreign_key(from_class, column, value, opts = {})
|
||||
ensure_index(from_class, [column])
|
||||
where = ["c.`ruby_class` = $rc", "c.`#{column}` = $v"]
|
||||
where = ["c.`ruby_class` = $rc", "c.#{quote_ident(column)} = $v"]
|
||||
params = { rc: from_class.name, v: value }
|
||||
where << not_deleted(from_class) unless opts[:with_deleted] || !soft_deletes?(from_class)
|
||||
run_docs(where, params, 'c.`created_at`', order_opts(opts))
|
||||
@@ -159,7 +172,7 @@ module SimplyBased
|
||||
|
||||
def count_by_foreign_key(from_class, column, value, opts = {})
|
||||
ensure_index(from_class, [column])
|
||||
where = ["c.`ruby_class` = $rc", "c.`#{column}` = $v"]
|
||||
where = ["c.`ruby_class` = $rc", "c.#{quote_ident(column)} = $v"]
|
||||
params = { rc: from_class.name, v: value }
|
||||
where << not_deleted(from_class) unless opts[:with_deleted] || !soft_deletes?(from_class)
|
||||
run_count(where, params)
|
||||
@@ -168,7 +181,7 @@ module SimplyBased
|
||||
# has_and_belongs_to_many: docs of `from_class` whose array column contains value.
|
||||
def find_by_array_contains(from_class, column, value, opts = {})
|
||||
ensure_array_index(from_class, column)
|
||||
where = ["c.`ruby_class` = $rc", "ANY x IN c.`#{column}` SATISFIES x = $v END"]
|
||||
where = ["c.`ruby_class` = $rc", "ANY x IN c.#{quote_ident(column)} SATISFIES x = $v END"]
|
||||
params = { rc: from_class.name, v: value }
|
||||
where << not_deleted(from_class) unless opts[:with_deleted] || !soft_deletes?(from_class)
|
||||
run_docs(where, params, 'c.`created_at`', order_opts(opts))
|
||||
@@ -176,7 +189,7 @@ module SimplyBased
|
||||
|
||||
def count_by_array_contains(from_class, column, value, opts = {})
|
||||
ensure_array_index(from_class, column)
|
||||
where = ["c.`ruby_class` = $rc", "ANY x IN c.`#{column}` SATISFIES x = $v END"]
|
||||
where = ["c.`ruby_class` = $rc", "ANY x IN c.#{quote_ident(column)} SATISFIES x = $v END"]
|
||||
params = { rc: from_class.name, v: value }
|
||||
where << not_deleted(from_class) unless opts[:with_deleted] || !soft_deletes?(from_class)
|
||||
run_count(where, params)
|
||||
@@ -290,6 +303,47 @@ module SimplyBased
|
||||
|
||||
private
|
||||
|
||||
# The N1QL translation understands structured view declarations (:key /
|
||||
# :keys / start-end ranges / :reduce). CouchDB-era JavaScript escape
|
||||
# hatches cannot be honoured — and ignoring them would silently return
|
||||
# *unfiltered* results (e.g. a `view :completed, key: :name, conditions:
|
||||
# 'doc.completed == true'` would return every document of the class).
|
||||
# Fail loudly instead.
|
||||
def assert_translatable_view!(spec)
|
||||
opts = spec.options || {}
|
||||
if opts[:conditions]
|
||||
raise SimplyBased::UnsupportedView,
|
||||
"view #{spec.klass}##{spec.view_name} declares :conditions (a JavaScript " \
|
||||
"fragment); N1QL cannot translate it — express the condition as a key or " \
|
||||
"query it via a dedicated finder"
|
||||
end
|
||||
if opts[:emit_value]
|
||||
raise SimplyBased::UnsupportedView,
|
||||
"view #{spec.klass}##{spec.view_name} declares :emit_value; the N1QL " \
|
||||
"translation always returns full documents"
|
||||
end
|
||||
if spec.respond_to?(:list_name) && spec.list_name
|
||||
raise SimplyBased::UnsupportedView,
|
||||
"view #{spec.klass}##{spec.view_name} uses a CouchDB list function " \
|
||||
"(#{spec.list_name}); lists have no Couchbase equivalent"
|
||||
end
|
||||
end
|
||||
|
||||
# Quote an N1QL identifier (field/index name). Identifiers come from the
|
||||
# model DSL, not user input, but a backtick smuggled into a property name
|
||||
# must never become query text.
|
||||
def quote_ident(name)
|
||||
n = name.to_s
|
||||
raise ArgumentError, "invalid N1QL identifier: #{n.inspect}" if n.include?('`') || n.include?('\\')
|
||||
"`#{n}`"
|
||||
end
|
||||
|
||||
# Single-quoted N1QL string literal (used only for index WHERE clauses,
|
||||
# where named parameters are not allowed).
|
||||
def quote_string(value)
|
||||
"'#{value.to_s.gsub('\\', '\\\\\\\\').gsub("'", "''")}'"
|
||||
end
|
||||
|
||||
# Record a database round-trip for the spec query-count matchers (no-op in
|
||||
# production, where $performed_queries is undefined).
|
||||
def _track(op)
|
||||
@@ -325,8 +379,8 @@ module SimplyBased
|
||||
def key_expr(key)
|
||||
case key
|
||||
when nil then nil
|
||||
when Array then '[' + key.map { |k| "c.`#{k}`" }.join(', ') + ']'
|
||||
else "c.`#{key}`"
|
||||
when Array then '[' + key.map { |k| "c.#{quote_ident(k)}" }.join(', ') + ']'
|
||||
else "c.#{quote_ident(key)}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -340,10 +394,10 @@ module SimplyBased
|
||||
key_cols.each_with_index.map do |col, i|
|
||||
v = vp[:key][i]
|
||||
if v.nil?
|
||||
"(c.`#{col}` IS MISSING OR c.`#{col}` IS NULL)"
|
||||
"(c.#{quote_ident(col)} IS MISSING OR c.#{quote_ident(col)} IS NULL)"
|
||||
else
|
||||
params[:"k#{i}"] = v
|
||||
"c.`#{col}` = $k#{i}"
|
||||
"c.#{quote_ident(col)} = $k#{i}"
|
||||
end
|
||||
end
|
||||
else
|
||||
@@ -368,19 +422,24 @@ module SimplyBased
|
||||
# Only partition by the property when a value is supplied. roots/full_tree
|
||||
# with no argument span all partitions (e.g. all locales).
|
||||
return if by_property.nil? || by_value.nil?
|
||||
where << "c.`#{by_property}` = $bp"
|
||||
where << "c.#{quote_ident(by_property)} = $bp"
|
||||
params[:bp] = by_value
|
||||
end
|
||||
|
||||
def run_tree(where, params, order_col)
|
||||
sql = +"SELECT c.*, META(c).id AS `__id`, META(c).cas AS `__cas` FROM #{keyspace} AS c WHERE #{where.join(' AND ')}"
|
||||
sql << " ORDER BY c.`#{order_col}` ASC" if order_col
|
||||
# Tie-break equal positions by creation time then id, like run_docs.
|
||||
# Records habitually share position 0; ordering by position alone left
|
||||
# the tie order to the index scan, which flips between runs — this was
|
||||
# the actual cause of the "intermittently flaky ancestry" failures
|
||||
# (misdiagnosed as a save/CAS race in the original session report).
|
||||
sql << " ORDER BY c.#{quote_ident(order_col)} ASC, c.`created_at` ASC, META(c).id ASC" if order_col
|
||||
query(sql, params).rows.to_a.map { |row| row_to_doc(row) }
|
||||
end
|
||||
|
||||
def not_deleted(kls)
|
||||
a = kls.soft_delete_attribute
|
||||
"(c.`#{a}` IS MISSING OR c.`#{a}` IS NULL)"
|
||||
a = quote_ident(kls.soft_delete_attribute)
|
||||
"(c.#{a} IS MISSING OR c.#{a} IS NULL)"
|
||||
end
|
||||
|
||||
def soft_deletes?(kls)
|
||||
@@ -395,23 +454,31 @@ module SimplyBased
|
||||
# SimplyCouch creating a design document on first view query.
|
||||
def ensure_index(kls, columns)
|
||||
key = [kls.name, columns].inspect
|
||||
return if @ensured_indexes[key]
|
||||
name = "sb_#{kls.name.gsub(/\W/, '_').downcase}_#{columns.join('_')}"[0, 60]
|
||||
cols = columns.map { |col| "`#{col}`" }.join(', ')
|
||||
query("CREATE INDEX `#{name}` IF NOT EXISTS ON #{keyspace}(#{cols}) WHERE `ruby_class` = '#{kls.name}'")
|
||||
@ensured_indexes[key] = true
|
||||
rescue ::Couchbase::Error::CouchbaseError
|
||||
@ensured_indexes[key] = true
|
||||
cols = columns.map { |col| quote_ident(col) }.join(', ')
|
||||
ensure_index_once(key, "sb_#{kls.name.gsub(/\W/, '_').downcase}_#{columns.join('_')}"[0, 60],
|
||||
"(#{cols})", kls)
|
||||
end
|
||||
|
||||
def ensure_array_index(kls, column)
|
||||
key = [kls.name, :array, column].inspect
|
||||
return if @ensured_indexes[key]
|
||||
name = "sb_#{kls.name.gsub(/\W/, '_').downcase}_arr_#{column}"[0, 60]
|
||||
query("CREATE INDEX `#{name}` IF NOT EXISTS ON #{keyspace}(DISTINCT ARRAY x FOR x IN `#{column}` END) WHERE `ruby_class` = '#{kls.name}'")
|
||||
@ensured_indexes[key] = true
|
||||
rescue ::Couchbase::Error::CouchbaseError
|
||||
@ensured_indexes[key] = true
|
||||
ensure_index_once(key, "sb_#{kls.name.gsub(/\W/, '_').downcase}_arr_#{column}"[0, 60],
|
||||
"(DISTINCT ARRAY x FOR x IN #{quote_ident(column)} END)", kls)
|
||||
end
|
||||
|
||||
def ensure_index_once(key, name, index_expr, kls)
|
||||
# The mutex only guards the bookkeeping hash (Store instances are shared
|
||||
# across threads); CREATE INDEX itself is IF NOT EXISTS, so a lost race
|
||||
# costs one redundant statement, never a broken index.
|
||||
@ensured_indexes_mutex.synchronize { return if @ensured_indexes[key] }
|
||||
query("CREATE INDEX #{quote_ident(name)} IF NOT EXISTS ON #{keyspace}#{index_expr} WHERE `ruby_class` = #{quote_string(kls.name)}")
|
||||
@ensured_indexes_mutex.synchronize { @ensured_indexes[key] = true }
|
||||
rescue ::Couchbase::Error::CouchbaseError => e
|
||||
# Still mark as ensured to avoid retry storms, but say so: a swallowed
|
||||
# failure here means every later query on this association may fall back
|
||||
# to a primary-index scan.
|
||||
warn "SimplyBased: could not ensure index #{name} on #{keyspace} " \
|
||||
"(#{e.class}: #{e.message}); queries may fall back to a primary scan"
|
||||
@ensured_indexes_mutex.synchronize { @ensured_indexes[key] = true }
|
||||
end
|
||||
|
||||
# ── persistence helpers ──────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
require 'spec_helper'
|
||||
|
||||
# Real-concurrency coverage. The suite had no multi-threaded examples at all,
|
||||
# yet the gem's core claims are about CAS-based optimistic concurrency and
|
||||
# Redis-replacement primitives (atomic counters, distributed locks). These
|
||||
# examples exercise those claims against the live cluster.
|
||||
RSpec.describe 'Concurrency' do
|
||||
describe SimplyBased::Counter do
|
||||
it 'loses no increments under concurrent writers' do
|
||||
counter = SimplyBased::Counter.new
|
||||
threads = 8.times.map do
|
||||
Thread.new do
|
||||
25.times { counter.increment('conc:hits') }
|
||||
end
|
||||
end
|
||||
threads.each(&:join)
|
||||
expect(counter.get('conc:hits')).to eq(8 * 25)
|
||||
end
|
||||
end
|
||||
|
||||
describe SimplyBased::Lock do
|
||||
it 'grants the lock to exactly one of many concurrent contenders' do
|
||||
lock = SimplyBased::Lock.new
|
||||
grants = 8.times.map do
|
||||
Thread.new { lock.acquire('conc:job', ttl: 30) }
|
||||
end.map(&:value)
|
||||
expect(grants.compact.size).to eq(1)
|
||||
end
|
||||
|
||||
it 'mutually excludes critical sections under with_lock' do
|
||||
lock = SimplyBased::Lock.new
|
||||
active = 0
|
||||
max_active = 0
|
||||
mutex = Mutex.new
|
||||
threads = 4.times.map do
|
||||
Thread.new do
|
||||
lock.with_lock('conc:crit', ttl: 30, retries: 200, wait: 0.05) do
|
||||
mutex.synchronize { active += 1; max_active = [max_active, active].max }
|
||||
sleep 0.05
|
||||
mutex.synchronize { active -= 1 }
|
||||
end
|
||||
end
|
||||
end
|
||||
threads.each(&:join)
|
||||
expect(max_active).to eq(1)
|
||||
end
|
||||
|
||||
it 'does not release a lock re-acquired by someone else after expiry' do
|
||||
lock = SimplyBased::Lock.new
|
||||
stale_handle = lock.acquire('conc:expiring', ttl: 1)
|
||||
expect(stale_handle).not_to be_nil
|
||||
sleep 2.5 # let it expire server-side
|
||||
new_handle = lock.acquire('conc:expiring', ttl: 30)
|
||||
expect(new_handle).not_to be_nil
|
||||
# the stale holder's release must be a no-op (CAS mismatch), not an unlock
|
||||
expect(lock.release('conc:expiring', stale_handle)).to be(false)
|
||||
expect(lock.acquire('conc:expiring', ttl: 30)).to be_nil # still held
|
||||
end
|
||||
end
|
||||
|
||||
describe 'CAS optimistic concurrency (documents)' do
|
||||
it 'does not lose updates when two copies write the same attribute' do
|
||||
original = User.create(name: 'seed', title: 'Dr.')
|
||||
copies = 2.times.map { User.find(original.id) }
|
||||
copies[0].name = 'first-writer'
|
||||
expect(copies[0].save).to be true
|
||||
copies[1].name = 'second-writer'
|
||||
# same attribute changed on both sides — must surface, never silently stomp
|
||||
expect { copies[1].save }.to raise_error(SimplyBased::Conflict)
|
||||
expect(User.find(original.id).name).to eq 'first-writer'
|
||||
end
|
||||
|
||||
it 'merges concurrent writers touching different attributes (auto conflict resolution)' do
|
||||
original = User.create(name: 'seed', title: 'Dr.', homepage: 'a')
|
||||
a = User.find(original.id)
|
||||
b = User.find(original.id)
|
||||
a.name = 'from-a'
|
||||
expect(a.save).to be true
|
||||
b.title = 'from-b'
|
||||
expect { b.save }.not_to raise_error
|
||||
reloaded = User.find(original.id)
|
||||
expect(reloaded.name).to eq 'from-a'
|
||||
expect(reloaded.title).to eq 'from-b'
|
||||
end
|
||||
|
||||
it 'survives a storm of single-attribute writers each bumping their own field' do
|
||||
original = User.create(name: 'seed', title: 't', homepage: 'h')
|
||||
results = %w[name title homepage].map do |attr|
|
||||
Thread.new do
|
||||
copy = User.find(original.id)
|
||||
copy.public_send("#{attr}=", "#{attr}-updated")
|
||||
copy.save
|
||||
true
|
||||
rescue SimplyBased::Conflict
|
||||
:conflict
|
||||
end
|
||||
end.map(&:value)
|
||||
# auto-merge should let disjoint-attribute writers through; when the race
|
||||
# is too tight the loser must fail loudly with Conflict — never corrupt.
|
||||
reloaded = User.find(original.id)
|
||||
%w[name title homepage].each_with_index do |attr, i|
|
||||
if results[i] == true
|
||||
expect(reloaded.public_send(attr)).to eq "#{attr}-updated"
|
||||
end
|
||||
end
|
||||
expect(results).to all(satisfy { |r| r == true || r == :conflict })
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,11 @@
|
||||
require 'spec_helper'
|
||||
RSpec.configure { |c| c.filter_run_excluding(:embedding) } if defined?(RSpec)
|
||||
# NOTE: this file used to carry `filter_run_excluding(:embedding)` — but no
|
||||
# example was tagged :embedding, so nothing was ever excluded and the whole
|
||||
# file ran (and failed) against Couchbase. The basic embedded flows are now
|
||||
# fixed and expected green; only reverse lookups (finding embedded documents
|
||||
# through an N1QL query on a foreign key inside the embedded array) remain
|
||||
# unimplemented, and are tagged :embedded_in_reverse_lookup below (excluded in
|
||||
# spec_helper).
|
||||
|
||||
describe 'Embedding' do
|
||||
context 'initialized comments' do
|
||||
@@ -128,7 +134,7 @@ describe 'Embedding' do
|
||||
end
|
||||
end
|
||||
|
||||
context 'belongs to strict_post' do
|
||||
context 'belongs to strict_post', :embedded_in_reverse_lookup do
|
||||
before do
|
||||
@user = User.create(name: 'embedding user')
|
||||
@strict_post = StrictPost.create(user: @user)
|
||||
|
||||
@@ -6,11 +6,16 @@ RSpec.describe 'Redis replacements' do
|
||||
describe SimplyBased::Counter do
|
||||
subject(:counter) { described_class.new }
|
||||
|
||||
it 'atomically increments, seeding at the initial value' do
|
||||
expect(counter.increment('c:hits')).to eq(0) # created at initial 0
|
||||
it 'atomically increments with Redis INCR semantics (first INCR of a missing key returns the delta)' do
|
||||
expect(counter.increment('c:hits')).to eq(1)
|
||||
expect(counter.increment('c:hits', 5)).to eq(6)
|
||||
expect(counter.get('c:hits')).to eq(6)
|
||||
expect(counter.increment('c:hits')).to eq(2)
|
||||
expect(counter.increment('c:hits', 5)).to eq(7)
|
||||
expect(counter.get('c:hits')).to eq(7)
|
||||
end
|
||||
|
||||
it 'seeds at an explicit initial value when given' do
|
||||
expect(counter.increment('c:seeded', 1, initial: 100)).to eq(100)
|
||||
expect(counter.increment('c:seeded')).to eq(101)
|
||||
end
|
||||
|
||||
it 'decrements and resets' do
|
||||
@@ -19,6 +24,11 @@ RSpec.describe 'Redis replacements' do
|
||||
expect(counter.reset('c:n', 0)).to eq(0)
|
||||
expect(counter.get('c:n')).to eq(0)
|
||||
end
|
||||
|
||||
it 'clamps decrement at zero (unsigned Couchbase counters; Redis would go negative)' do
|
||||
counter.reset('c:clamp', 1)
|
||||
expect(counter.decrement('c:clamp', 5)).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
describe SimplyBased::Cache do
|
||||
@@ -45,6 +55,19 @@ RSpec.describe 'Redis replacements' do
|
||||
expect(lock.acquire('job:1', ttl: 30)).not_to be_nil # released → free
|
||||
end
|
||||
|
||||
it 'refuses to release without the CAS handle' do
|
||||
lock.acquire('job:guard', ttl: 30)
|
||||
expect { lock.release('job:guard', nil) }.to raise_error(ArgumentError)
|
||||
expect(lock.acquire('job:guard', ttl: 30)).to be_nil # still held
|
||||
lock.force_release!('job:guard')
|
||||
expect(lock.acquire('job:guard', ttl: 30)).not_to be_nil
|
||||
end
|
||||
|
||||
it 'rejects a missing/zero ttl (a crashed holder must never leave an immortal lock)' do
|
||||
expect { lock.acquire('job:immortal', ttl: nil) }.to raise_error(ArgumentError)
|
||||
expect { lock.acquire('job:immortal', ttl: 0) }.to raise_error(ArgumentError)
|
||||
end
|
||||
|
||||
it 'runs a critical section with with_lock' do
|
||||
ran = false
|
||||
lock.with_lock('job:2') { ran = true }
|
||||
|
||||
@@ -14,6 +14,10 @@ RSpec.configure do |config|
|
||||
config.expect_with(:rspec) { |c| c.syntax = [:expect, :should] }
|
||||
config.color = true
|
||||
config.filter_run_excluding :couchdb_only # CouchDB-mechanic specs with no Couchbase equivalent
|
||||
# Reverse lookups into embedded documents (querying a foreign key held
|
||||
# *inside* an embedded array element) are not implemented on Couchbase —
|
||||
# they would need an UNNEST-based query path. Documented gap.
|
||||
config.filter_run_excluding :embedded_in_reverse_lookup
|
||||
|
||||
config.before(:suite) do
|
||||
SimplyBased.database_url = COUCHBASE_URL
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
require 'spec_helper'
|
||||
|
||||
# The adapter contract shared examples (spec/support/adapter_contract.rb) were
|
||||
# shipped with the gem but never actually included by any spec — the parity
|
||||
# proof they document did not run. This spec makes the Store honour it.
|
||||
RSpec.describe SimplyBased::Store do
|
||||
it_behaves_like 'a simply_couch adapter'
|
||||
end
|
||||
@@ -0,0 +1,34 @@
|
||||
require 'spec_helper'
|
||||
|
||||
class ConditionalViewWidget
|
||||
include SimplyBased::Model
|
||||
property :name
|
||||
property :completed, type: :boolean
|
||||
# A CouchDB-era declaration: the :conditions JavaScript fragment cannot be
|
||||
# translated to N1QL. Declaring it must stay legal (simply_couch models load
|
||||
# unchanged), but *querying* it must fail loudly — the previous behaviour
|
||||
# silently dropped the condition and returned every document of the class.
|
||||
view :completed_by_name, key: :name, conditions: 'doc.completed === true'
|
||||
view :names_only, key: :name, emit_value: :name
|
||||
end
|
||||
|
||||
RSpec.describe 'Untranslatable view declarations' do
|
||||
it 'still allows the model to load and plain views to work' do
|
||||
ConditionalViewWidget.create(name: 'a', completed: false)
|
||||
expect(ConditionalViewWidget.all.size).to eq 1
|
||||
end
|
||||
|
||||
it 'raises UnsupportedView when querying a :conditions view instead of silently ignoring the condition' do
|
||||
ConditionalViewWidget.create(name: 'done', completed: true)
|
||||
ConditionalViewWidget.create(name: 'done', completed: false)
|
||||
expect {
|
||||
ConditionalViewWidget.database.view(ConditionalViewWidget.completed_by_name(key: 'done'))
|
||||
}.to raise_error(SimplyBased::UnsupportedView, /conditions/)
|
||||
end
|
||||
|
||||
it 'raises UnsupportedView for :emit_value views' do
|
||||
expect {
|
||||
ConditionalViewWidget.database.view(ConditionalViewWidget.names_only(key: 'x'))
|
||||
}.to raise_error(SimplyBased::UnsupportedView, /emit_value/)
|
||||
end
|
||||
end
|
||||
Reference in new issue
Block a user