|
@@ -14,6 +14,8 @@
|
|
|
#include <atomic>
|
|
#include <atomic>
|
|
|
#include <filesystem>
|
|
#include <filesystem>
|
|
|
#include <iostream>
|
|
#include <iostream>
|
|
|
|
|
+#include <cstdio>
|
|
|
|
|
+#include <set>
|
|
|
#include <string>
|
|
#include <string>
|
|
|
#include <unistd.h>
|
|
#include <unistd.h>
|
|
|
|
|
|
|
@@ -273,6 +275,84 @@ void test_scan_limit_zero_reports_total() {
|
|
|
"filtered limit=0 reports the matching total, not the collection size");
|
|
"filtered limit=0 reports the matching total, not the collection size");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// v2.7.1 — the unfiltered/unsorted fast path in scan() must agree with the
|
|
|
|
|
+// general path exactly. It exists because the general path decoded every
|
|
|
|
|
+// document in the collection to return `limit` of them, so cost tracked total
|
|
|
|
|
+// bytes rather than page size (382ms to return one 510-byte document from a
|
|
|
|
|
+// 414 MB collection on a live instance). Any divergence here is a paging bug.
|
|
|
|
|
+void test_scan_fast_path_matches_general_path() {
|
|
|
|
|
+ TmpEnv t("fastpath");
|
|
|
|
|
+ LmdbDocumentStore store(t.env);
|
|
|
|
|
+ for (int i = 0; i < 25; ++i) {
|
|
|
|
|
+ Document d;
|
|
|
|
|
+ char buf[16];
|
|
|
|
|
+ std::snprintf(buf, sizeof(buf), "id%02d", i);
|
|
|
|
|
+ d.id = buf;
|
|
|
|
|
+ d.collection = "things";
|
|
|
|
|
+ d.set_data(nlohmann::json{{"n", i}, {"kind", i % 2 ? "odd" : "even"}});
|
|
|
|
|
+ store.put("things", d.id, d);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // total_matched and has_more must match what a full count says.
|
|
|
|
|
+ smartbotic::database::Query page;
|
|
|
|
|
+ page.limit = 10;
|
|
|
|
|
+ page.offset = 0;
|
|
|
|
|
+ auto p0 = store.scan("things", page);
|
|
|
|
|
+ check(p0.documents.size() == 10, "fast path returns exactly `limit` docs");
|
|
|
|
|
+ check(p0.total_matched == 25, "fast path total_matched excludes the sentinel");
|
|
|
|
|
+ check(p0.has_more, "has_more true when more remain");
|
|
|
|
|
+
|
|
|
|
|
+ page.offset = 20;
|
|
|
|
|
+ auto p2 = store.scan("things", page);
|
|
|
|
|
+ check(p2.documents.size() == 5, "final page returns the remainder");
|
|
|
|
|
+ check(p2.total_matched == 25, "total_matched stable across pages");
|
|
|
|
|
+ check(!p2.has_more, "has_more false on the last page");
|
|
|
|
|
+
|
|
|
|
|
+ page.offset = 25;
|
|
|
|
|
+ auto p3 = store.scan("things", page);
|
|
|
|
|
+ check(p3.documents.empty(), "offset past the end returns nothing");
|
|
|
|
|
+ check(p3.total_matched == 25, "and still reports the true total");
|
|
|
|
|
+
|
|
|
|
|
+ // Paging must cover every document exactly once, in a stable order.
|
|
|
|
|
+ std::set<std::string> seen;
|
|
|
|
|
+ for (uint32_t off = 0; off < 25; off += 7) {
|
|
|
|
|
+ smartbotic::database::Query q;
|
|
|
|
|
+ q.limit = 7;
|
|
|
|
|
+ q.offset = off;
|
|
|
|
|
+ for (const auto& d : store.scan("things", q).documents) seen.insert(d.id);
|
|
|
|
|
+ }
|
|
|
|
|
+ check(seen.size() == 25, "paging the whole collection yields every document once");
|
|
|
|
|
+
|
|
|
|
|
+ // A filter forces the general path; it must still be correct.
|
|
|
|
|
+ smartbotic::database::Query fq;
|
|
|
|
|
+ fq.limit = 100;
|
|
|
|
|
+ smartbotic::database::Filter f;
|
|
|
|
|
+ f.field = "kind";
|
|
|
|
|
+ f.op = smartbotic::database::FilterOp::EQ;
|
|
|
|
|
+ f.value = "odd";
|
|
|
|
|
+ fq.filters.push_back(f);
|
|
|
|
|
+ auto filtered = store.scan("things", fq);
|
|
|
|
|
+ check(filtered.total_matched == 12, "filtered path still counts matches, not rows");
|
|
|
|
|
+
|
|
|
|
|
+ // A sort also forces the general path.
|
|
|
|
|
+ smartbotic::database::Query sq;
|
|
|
|
|
+ sq.limit = 3;
|
|
|
|
|
+ sq.sort = smartbotic::database::Sort{"n", true};
|
|
|
|
|
+ auto sorted = store.scan("things", sq);
|
|
|
|
|
+ check(sorted.documents.size() == 3, "sorted path paginates");
|
|
|
|
|
+ check(sorted.total_matched == 25, "sorted path totals all rows");
|
|
|
|
|
+ check(sorted.documents[0].data().value("n", -1) == 24,
|
|
|
|
|
+ "descending sort really sorted (fast path must not swallow sorts)");
|
|
|
|
|
+
|
|
|
|
|
+ // limit=0 keeps meaning "no documents, but a true total" - the contract
|
|
|
|
|
+ // Count depends on (see test_scan_limit_zero_reports_total).
|
|
|
|
|
+ smartbotic::database::Query zq;
|
|
|
|
|
+ zq.limit = 0;
|
|
|
|
|
+ auto z = store.scan("things", zq);
|
|
|
|
|
+ check(z.documents.empty(), "limit=0 returns no documents on the fast path");
|
|
|
|
|
+ check(z.total_matched == 25, "limit=0 still reports the true total");
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
} // namespace
|
|
} // namespace
|
|
|
|
|
|
|
|
int main() {
|
|
int main() {
|
|
@@ -284,6 +364,7 @@ int main() {
|
|
|
test_sentinel_invisible_through_store();
|
|
test_sentinel_invisible_through_store();
|
|
|
test_vector_subdb_sentinel();
|
|
test_vector_subdb_sentinel();
|
|
|
test_scan_limit_zero_reports_total();
|
|
test_scan_limit_zero_reports_total();
|
|
|
|
|
+ test_scan_fast_path_matches_general_path();
|
|
|
|
|
|
|
|
std::cout << "passed: " << g_pass << ", failed: " << g_fail << "\n";
|
|
std::cout << "passed: " << g_pass << ", failed: " << g_fail << "\n";
|
|
|
return g_fail == 0 ? 0 : 1;
|
|
return g_fail == 0 ? 0 : 1;
|