Compare commits

..
Author SHA1 Message Date
bterkuile 185ca0ca17 Benjamin commit on credits ran out 2026-07-06 19:06:59 -05:00
bterkuileandClaude Sonnet 5 37288db487 Deterministic tree ordering — the actual fix for the 'flaky ancestry' failures
Reproduced the reported ~50% intermittent ancestry failure (3-4 of 8 runs) and
captured it: NamespacedDirectory.roots returned [dir4, dir1] vs [dir1, dir4].
The session report blamed 'several dependent saves that occasionally race' —
wrong. Both records share position 0 and run_tree ordered by position alone,
so the tie order was whatever the index scan produced that run. No race, no
CAS involvement: an ORDER BY tie.

- run_tree now tie-breaks by created_at then META(c).id, exactly like
  run_docs already did for finder queries.
- The two client-side tree sorts (descendants, build_tree) get the same
  tie-break: Ruby's sort_by is not stable, so [depth, position] alone could
  reorder same-position siblings between runs.

ancestry_spec re-run repeatedly post-fix: no failures.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 18:51:47 -05:00
bterkuileandClaude Sonnet 5 9ce5aea23d Add real concurrency coverage and actually run the adapter contract
- spec/support/adapter_contract.rb shipped shared examples ('a simply_couch
  adapter') that no spec ever included — the parity proof they document never
  ran. store_contract_spec.rb now includes them against the live Store.
- concurrency_spec.rb: the suite had zero multi-threaded examples despite the
  gem's core claims being CAS optimistic concurrency and Redis-replacement
  primitives. Adds: counter increments under 8 concurrent writers, lock
  granted to exactly one contender, with_lock mutual exclusion, stale-handle
  release after TTL expiry (must not unlock the new holder), same-attribute
  CAS conflict surfacing (no lost update), different-attribute auto-merge, and
  a 3-thread disjoint-attribute write storm.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 18:42:17 -05:00
bterkuileandClaude Sonnet 5 bde6df57f4 Counter/Lock: Redis INCR parity and safe lock release
- Counter#increment now seeds a missing key at the delta (Redis INCR/INCRBY
  semantics). Couchbase's native increment seeds at 'initial' and ignores the
  delta on creation, so the first INCR returned 0 — a drop-in replacement that
  silently undercounts by one is exactly the kind of bug nobody ever finds.
  Explicit initial: still overrides. Decrement keeps initial: 0 and its
  unsigned clamp-at-zero divergence from Redis is now documented and tested.
- Lock#release requires the CAS handle: passing nil used to delete the lock
  document unconditionally, so any caller could unlock a lock it never held.
  force_release! is the explicit administrative override.
- Lock#acquire validates a positive ttl — a nil/zero ttl would create a lock
  that survives its holder's crash forever.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 18:42:17 -05:00
bterkuileandClaude Sonnet 5 84c76bb093 Store: fail loudly on untranslatable views; quoting hardening; true batched bulk KV
- database.view now raises SimplyBased::UnsupportedView when a declared view
  carries CouchDB-era options the N1QL translation silently ignored before:
  :conditions (a JS fragment) and :emit_value, plus CouchDB list functions.
  Silently dropping :conditions was the worst of these — a
  'view :completed, key: :name, conditions: ...' query returned *unfiltered*
  results with no hint anything was wrong. Covered by unsupported_view_spec.
- All column/property names interpolated into N1QL are now routed through
  quote_ident, which rejects backticks/backslashes. These names come from the
  model DSL rather than user input, but a smuggled backtick must never become
  query text. Class names in index predicates go through a proper string
  literal quoting helper for the same reason.
- ensure_index: failures are still swallowed (an index build in progress must
  not take down queries) but now emit a warning naming the index, because the
  silent version left every subsequent query on a primary-index scan with no
  trace. The ensured-indexes bookkeeping is mutex-guarded for multithreaded
  servers (Puma).
- bulk_load / bulk_destroy now actually batch (collection.get_multi /
  remove_multi — one KV round trip). The README already advertised
  'bulk (batched KV)' but the code looped one get/remove per id.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 18:42:17 -05:00
bterkuileandClaude Sonnet 5 1beb8ead11 Fix embedded documents: recursion crash, .all/.count, honest spec exclusion
Live-run finding: the full suite against Couchbase was 423 examples with 17
failures — all in embedding_spec. The session report claimed embedded specs
were 'excluded by design', but the exclusion filter targeted a :embedding tag
no example carries, so the file ran (and failed) all along.

Three fixes:
- HasManyEmbedded::Property#build wired parent_object before installing the
  collection ivar on the parent; parent_object= reaches back into the parent's
  collection getter, which re-entered build -> SystemStackError on any access
  to embedded documents loaded from the database. Install the ivar first.
- EmbeddedClass.all / .count referenced raw JS views (all_documents /
  all_documents_for_count) that the N1QL port silently drops, so .all returned
  [] and .count raised NoMethodError. Rebuilt them by enumerating the parent
  documents (same work as the old view scan, client-side; an UNNEST query can
  replace it later).
- Only reverse lookups (N1QL on a foreign key inside an embedded array) remain
  unsupported: tagged :embedded_in_reverse_lookup and excluded in spec_helper
  with an explanatory comment, replacing the broken :embedding filter line.

embedding_spec.rb: 16 examples, 0 failures against live Couchbase 8.0.1 CE.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 18:35:51 -05:00
15 changed files with 556 additions and 62 deletions

No files matched your search

+33
View File
@@ -1,6 +1,39 @@
Changelog 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 0.1.0
----- -----
- Initial release: a Couchbase-native ActiveModel ORM, API-compatible with - Initial release: a Couchbase-native ActiveModel ORM, API-compatible with
+33 -9
View File
@@ -70,11 +70,16 @@ SimplyBased::Lock.new.with_lock("rebuild", ttl: 30) do
end 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 - **Cache** — an `ActiveSupport::Cache::Store` over KV + native TTL; drop-in for
`Rails.cache` and therefore ActiveJob. `Rails.cache` and therefore ActiveJob. (`clear` is a no-op — entries expire
- **Lock** — distributed lock: insert-if-absent + TTL auto-expiry + CAS-checked via TTL.)
release so only the holder can unlock. - **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 ## The one recommended pattern shift
@@ -85,22 +90,41 @@ Couchbase **Collection** — tighter GSIs, cleaner queries, idiomatic Couchbase.
## Status & test coverage ## 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_*`), - **Green:** CRUD, finders (`find`, `all`, `first`, `find_by_*`, `count_by_*`),
`belongs_to` / `has_many` / `has_one` / `has_and_belongs_to_many`, validations, `belongs_to` / `has_many` / `has_one` / `has_and_belongs_to_many`, validations,
callbacks, dirty tracking, soft-delete (incl. soft-delete-aware finders), 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 = - **Ancestry** is a materialized path (`path_ids`, root→self): children =
`path_ids[len-2] = $id`, subtree = `$id IN path_ids`, roots = `path_ids[len-2] = $id`, subtree = `$id IN path_ids`, roots =
`ARRAY_LENGTH(path_ids) = 1` — plain N1QL, no recursive view build. `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:** - **Excluded by design:**
- `:couchdb_only` specs — native inline attachments, JS-view token-safety, - `:couchdb_only` specs — native inline attachments, JS-view token-safety,
design-document maintenance, raw custom JS views: no Couchbase equivalent design-document maintenance, raw custom JS views: no Couchbase equivalent
(use `has_s3_attached` / `has_local_attached`; N1QL uses bound parameters). (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 ```bash
docker run -d --name cb -p 8091-8096:8091-8096 -p 11210:11210 couchbase:community docker run -d --name cb -p 8091-8096:8091-8096 -p 11210:11210 couchbase:community
+137
View File
@@ -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.
+4
View File
@@ -21,6 +21,10 @@ unless defined?(SimplyBased::VERSION)
class NotImplementedError < RuntimeError; end class NotImplementedError < RuntimeError; end
class ModelNotInstantiatedError < RuntimeError; end class ModelNotInstantiatedError < RuntimeError; end
class NotFound < 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 # No-op retained for source compatibility with SimplyCouch. SimplyBased
# never interpolates values into JavaScript views — queries are N1QL with # never interpolates values into JavaScript views — queries are N1QL with
+5 -2
View File
@@ -68,8 +68,11 @@ module SimplyBased
return @descendants if @descendants return @descendants if @descendants
return @descendants = [] if id.blank? return @descendants = [] if id.blank?
prop = self.class.ancestry_by_property 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, @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) @children = self.class.build_tree(@descendants)
@descendants @descendants
end end
@@ -254,7 +257,7 @@ module SimplyBased
tree_wrapper = Struct.new(:children).new([]) # Dummy container as traversing begin, contains roots as children tree_wrapper = Struct.new(:children).new([]) # Dummy container as traversing begin, contains roots as children
old_tree_slice = tree_wrapper.children old_tree_slice = tree_wrapper.children
new_tree_slice = [] 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 root_depth = pages.first.path_ids.size
current_depth = root_depth + 1 # Start counting/swapping from one depth deeper than first one current_depth = root_depth + 1 # Start counting/swapping from one depth deeper than first one
for page in pages for page in pages
+16 -1
View File
@@ -94,8 +94,23 @@ module SimplyBased
:include_docs => true :include_docs => true
end 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 = {}| 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
end end
+13 -8
View File
@@ -145,17 +145,22 @@ module SimplyBased
def build(object, json) def build(object, json)
values = Array.wrap(json[name]).map do |v| values = Array.wrap(json[name]).map do |v|
obj = if v.is_a?(Hash) if v.is_a?(Hash)
options[:class_name].constantize.new.tap { |o| o._document = v } options[:class_name].constantize.new.tap { |o| o._document = v }
else else
v v
end end
obj.parent_object = object
obj
end end
values.define_singleton_method(:parent_object_set?) { true } 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) object.instance_variable_set("@#{name}", values)
values.each_with_index { |v, i| v.index = i }
values.each { |v| v.parent_object = object }
values
end end
def initialize(owner_clazz, name, options = {}) def initialize(owner_clazz, name, options = {})
+28 -6
View File
@@ -7,12 +7,15 @@ module SimplyBased
# All three use the same cluster/bucket as your models — one datastore. # All three use the same cluster/bucket as your models — one datastore.
# Server-side atomic counters (Couchbase binary increment/decrement). A drop-in # Server-side atomic counters (Couchbase binary increment/decrement). A drop-in
# for Redis INCR/DECR. # for Redis INCR/INCRBY.
# #
# counter = SimplyBased::Counter.new # 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.increment("jobs:processed", 5) # => 6
# counter.get("jobs:processed") # => 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 class Counter
def initialize(store: nil) def initialize(store: nil)
@store = store @store = store
@@ -22,10 +25,16 @@ module SimplyBased
@store ||= SimplyBased.database @store ||= SimplyBased.database
end end
def increment(key, by = 1, initial: 0, ttl: nil) # Matches Redis INCRBY semantics: incrementing a missing key seeds it at
store.counter_incr(key, by, initial: initial, ttl: ttl) # `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 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) def decrement(key, by = 1, initial: 0, ttl: nil)
store.counter_decr(key, by, initial: initial, ttl: ttl) store.counter_decr(key, by, initial: initial, ttl: ttl)
end end
@@ -101,15 +110,28 @@ module SimplyBased
end end
# Returns a CAS token (the lock handle) or nil if already held. # 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) 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) store.kv_add(lock_key(key), { 'locked_at' => Time.now.to_i }, ttl: ttl)
end end
# Release only if we still hold the same CAS (no stomping a re-acquired lock). # Release only if we still hold the same CAS (no stomping a lock that
def release(key, cas = nil) # 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) store.kv_delete(lock_key(key), cas: cas)
end 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) def with_lock(key, ttl: 30, retries: 0, wait: 0.1)
cas = acquire(key, ttl: ttl) cas = acquire(key, ttl: ttl)
tries = 0 tries = 0
+97 -30
View File
@@ -23,6 +23,7 @@ module SimplyBased
@collection_name = cfg[:collection] || '_default' @collection_name = cfg[:collection] || '_default'
@cluster = ::Couchbase::Cluster.connect(cfg[:conn_str], cfg[:user], cfg[:password]) @cluster = ::Couchbase::Cluster.connect(cfg[:conn_str], cfg[:user], cfg[:password])
@ensured_indexes = {} @ensured_indexes = {}
@ensured_indexes_mutex = Mutex.new
end end
def keyspace def keyspace
@@ -77,7 +78,13 @@ module SimplyBased
def bulk_load(ids) def bulk_load(ids)
ids = Array(ids).compact ids = Array(ids).compact
return [] if ids.empty? 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 end
def bulk_save(documents, validate = true) def bulk_save(documents, validate = true)
@@ -106,14 +113,19 @@ module SimplyBased
def bulk_destroy(documents) def bulk_destroy(documents)
documents = Array(documents) documents = Array(documents)
return [] if documents.empty? return [] if documents.empty?
rows = documents.map do |d| with_ids, without_ids = documents.partition(&:_id)
begin _track("BULK_REMOVE #{with_ids.map(&:_id).join(',')}") if with_ids.any?
collection.remove(d._id) if d._id 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 } { 'id' => d._id, 'ok' => true }
rescue ::Couchbase::Error::DocumentNotFound elsif res.error.is_a?(::Couchbase::Error::DocumentNotFound)
{ 'id' => d._id, 'error' => 'not_found' } { 'id' => d._id, 'error' => 'not_found' }
else
{ 'id' => d._id, 'error' => res.error.class.name }
end end
end end
rows += without_ids.map { { 'id' => nil, 'error' => 'not_found' } }
documents.each { |d| d._id = nil; d._rev = nil } documents.each { |d| d._id = nil; d._rev = nil }
rows rows
end end
@@ -122,6 +134,7 @@ module SimplyBased
# Handles ModelViewSpec / DeletedModelViewSpec (all_documents, find_by, count). # Handles ModelViewSpec / DeletedModelViewSpec (all_documents, find_by, count).
def view(spec) def view(spec)
assert_translatable_view!(spec)
kls = spec.klass kls = spec.klass
vp = spec.view_parameters vp = spec.view_parameters
where = ["c.`ruby_class` = $rc"] where = ["c.`ruby_class` = $rc"]
@@ -151,7 +164,7 @@ module SimplyBased
# key column equals `value`. # key column equals `value`.
def find_by_foreign_key(from_class, column, value, opts = {}) def find_by_foreign_key(from_class, column, value, opts = {})
ensure_index(from_class, [column]) 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 } params = { rc: from_class.name, v: value }
where << not_deleted(from_class) unless opts[:with_deleted] || !soft_deletes?(from_class) where << not_deleted(from_class) unless opts[:with_deleted] || !soft_deletes?(from_class)
run_docs(where, params, 'c.`created_at`', order_opts(opts)) 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 = {}) def count_by_foreign_key(from_class, column, value, opts = {})
ensure_index(from_class, [column]) 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 } params = { rc: from_class.name, v: value }
where << not_deleted(from_class) unless opts[:with_deleted] || !soft_deletes?(from_class) where << not_deleted(from_class) unless opts[:with_deleted] || !soft_deletes?(from_class)
run_count(where, params) run_count(where, params)
@@ -168,7 +181,7 @@ module SimplyBased
# has_and_belongs_to_many: docs of `from_class` whose array column contains value. # has_and_belongs_to_many: docs of `from_class` whose array column contains value.
def find_by_array_contains(from_class, column, value, opts = {}) def find_by_array_contains(from_class, column, value, opts = {})
ensure_array_index(from_class, column) 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 } params = { rc: from_class.name, v: value }
where << not_deleted(from_class) unless opts[:with_deleted] || !soft_deletes?(from_class) where << not_deleted(from_class) unless opts[:with_deleted] || !soft_deletes?(from_class)
run_docs(where, params, 'c.`created_at`', order_opts(opts)) 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 = {}) def count_by_array_contains(from_class, column, value, opts = {})
ensure_array_index(from_class, column) 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 } params = { rc: from_class.name, v: value }
where << not_deleted(from_class) unless opts[:with_deleted] || !soft_deletes?(from_class) where << not_deleted(from_class) unless opts[:with_deleted] || !soft_deletes?(from_class)
run_count(where, params) run_count(where, params)
@@ -290,6 +303,47 @@ module SimplyBased
private 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 # Record a database round-trip for the spec query-count matchers (no-op in
# production, where $performed_queries is undefined). # production, where $performed_queries is undefined).
def _track(op) def _track(op)
@@ -325,8 +379,8 @@ module SimplyBased
def key_expr(key) def key_expr(key)
case key case key
when nil then nil when nil then nil
when Array then '[' + key.map { |k| "c.`#{k}`" }.join(', ') + ']' when Array then '[' + key.map { |k| "c.#{quote_ident(k)}" }.join(', ') + ']'
else "c.`#{key}`" else "c.#{quote_ident(key)}"
end end
end end
@@ -340,10 +394,10 @@ module SimplyBased
key_cols.each_with_index.map do |col, i| key_cols.each_with_index.map do |col, i|
v = vp[:key][i] v = vp[:key][i]
if v.nil? 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 else
params[:"k#{i}"] = v params[:"k#{i}"] = v
"c.`#{col}` = $k#{i}" "c.#{quote_ident(col)} = $k#{i}"
end end
end end
else else
@@ -368,19 +422,24 @@ module SimplyBased
# Only partition by the property when a value is supplied. roots/full_tree # Only partition by the property when a value is supplied. roots/full_tree
# with no argument span all partitions (e.g. all locales). # with no argument span all partitions (e.g. all locales).
return if by_property.nil? || by_value.nil? return if by_property.nil? || by_value.nil?
where << "c.`#{by_property}` = $bp" where << "c.#{quote_ident(by_property)} = $bp"
params[:bp] = by_value params[:bp] = by_value
end end
def run_tree(where, params, order_col) 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 = +"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) } query(sql, params).rows.to_a.map { |row| row_to_doc(row) }
end end
def not_deleted(kls) def not_deleted(kls)
a = kls.soft_delete_attribute a = quote_ident(kls.soft_delete_attribute)
"(c.`#{a}` IS MISSING OR c.`#{a}` IS NULL)" "(c.#{a} IS MISSING OR c.#{a} IS NULL)"
end end
def soft_deletes?(kls) def soft_deletes?(kls)
@@ -395,23 +454,31 @@ module SimplyBased
# SimplyCouch creating a design document on first view query. # SimplyCouch creating a design document on first view query.
def ensure_index(kls, columns) def ensure_index(kls, columns)
key = [kls.name, columns].inspect key = [kls.name, columns].inspect
return if @ensured_indexes[key] cols = columns.map { |col| quote_ident(col) }.join(', ')
name = "sb_#{kls.name.gsub(/\W/, '_').downcase}_#{columns.join('_')}"[0, 60] ensure_index_once(key, "sb_#{kls.name.gsub(/\W/, '_').downcase}_#{columns.join('_')}"[0, 60],
cols = columns.map { |col| "`#{col}`" }.join(', ') "(#{cols})", kls)
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
end end
def ensure_array_index(kls, column) def ensure_array_index(kls, column)
key = [kls.name, :array, column].inspect key = [kls.name, :array, column].inspect
return if @ensured_indexes[key] ensure_index_once(key, "sb_#{kls.name.gsub(/\W/, '_').downcase}_arr_#{column}"[0, 60],
name = "sb_#{kls.name.gsub(/\W/, '_').downcase}_arr_#{column}"[0, 60] "(DISTINCT ARRAY x FOR x IN #{quote_ident(column)} END)", kls)
query("CREATE INDEX `#{name}` IF NOT EXISTS ON #{keyspace}(DISTINCT ARRAY x FOR x IN `#{column}` END) WHERE `ruby_class` = '#{kls.name}'") end
@ensured_indexes[key] = true
rescue ::Couchbase::Error::CouchbaseError def ensure_index_once(key, name, index_expr, kls)
@ensured_indexes[key] = true # 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 end
# ── persistence helpers ────────────────────────────────────────────────── # ── persistence helpers ──────────────────────────────────────────────────
+109
View File
@@ -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
+8 -2
View File
@@ -1,5 +1,11 @@
require 'spec_helper' 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 describe 'Embedding' do
context 'initialized comments' do context 'initialized comments' do
@@ -128,7 +134,7 @@ describe 'Embedding' do
end end
end end
context 'belongs to strict_post' do context 'belongs to strict_post', :embedded_in_reverse_lookup do
before do before do
@user = User.create(name: 'embedding user') @user = User.create(name: 'embedding user')
@strict_post = StrictPost.create(user: @user) @strict_post = StrictPost.create(user: @user)
+27 -4
View File
@@ -6,11 +6,16 @@ RSpec.describe 'Redis replacements' do
describe SimplyBased::Counter do describe SimplyBased::Counter do
subject(:counter) { described_class.new } subject(:counter) { described_class.new }
it 'atomically increments, seeding at the initial value' do it 'atomically increments with Redis INCR semantics (first INCR of a missing key returns the delta)' do
expect(counter.increment('c:hits')).to eq(0) # created at initial 0
expect(counter.increment('c:hits')).to eq(1) expect(counter.increment('c:hits')).to eq(1)
expect(counter.increment('c:hits', 5)).to eq(6) expect(counter.increment('c:hits')).to eq(2)
expect(counter.get('c:hits')).to eq(6) 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 end
it 'decrements and resets' do 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.reset('c:n', 0)).to eq(0)
expect(counter.get('c:n')).to eq(0) expect(counter.get('c:n')).to eq(0)
end 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 end
describe SimplyBased::Cache do 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 expect(lock.acquire('job:1', ttl: 30)).not_to be_nil # released → free
end 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 it 'runs a critical section with with_lock' do
ran = false ran = false
lock.with_lock('job:2') { ran = true } lock.with_lock('job:2') { ran = true }
+4
View File
@@ -14,6 +14,10 @@ RSpec.configure do |config|
config.expect_with(:rspec) { |c| c.syntax = [:expect, :should] } config.expect_with(:rspec) { |c| c.syntax = [:expect, :should] }
config.color = true config.color = true
config.filter_run_excluding :couchdb_only # CouchDB-mechanic specs with no Couchbase equivalent 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 config.before(:suite) do
SimplyBased.database_url = COUCHBASE_URL SimplyBased.database_url = COUCHBASE_URL
+8
View File
@@ -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
+34
View File
@@ -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