diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f2432c..7cffb8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,6 +69,9 @@ jobs: - name: Run Standard Schema tests run: npm run test:standard-schema + - name: Run scanner regression tests + run: npm run test:scanner + test-cpp: runs-on: ubuntu-latest steps: diff --git a/package.json b/package.json index 9fd80cc..5d7e645 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "test:suite": "node tests/run_suite.js", "test:compat": "node tests/test_compat.js", "test:standard-schema": "node tests/test_standard_schema.js", + "test:scanner": "node tests/test_scanner_regression.js", "test:browser": "node tests/test_browser.js", "test:ts": "node tests/test_ts_gen.js", "test:ts-corpus": "node tests/test_ts_corpus.js", diff --git a/src/ata.cpp b/src/ata.cpp index ec7bbd0..2b101c9 100644 --- a/src/ata.cpp +++ b/src/ata.cpp @@ -27,8 +27,22 @@ #ifndef __builtin_popcount #define __builtin_popcount __popcnt #endif +static inline int __builtin_ctzll(unsigned long long v) { + unsigned long idx; + _BitScanForward64(&idx, v); + return (int)idx; +} #endif // defined(_MSC_VER) && !defined(__clang__) +// Cross-compiler always-inline. MSVC uses __forceinline; GCC/Clang use the +// __attribute__((always_inline)) attribute (which still requires `inline` +// to actually inline at every call site). +#if defined(_MSC_VER) && !defined(__clang__) +#define ATA_ALWAYS_INLINE __forceinline +#else +#define ATA_ALWAYS_INLINE inline __attribute__((always_inline)) +#endif + #include "simdjson.h" // --- Fast format validators (no std::regex) --- @@ -42,7 +56,7 @@ static bool is_hex(char c) { return is_digit(c) || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); } -static bool fast_check_email(std::string_view s) { +ATA_ALWAYS_INLINE static bool fast_check_email(std::string_view s) { auto at = s.find('@'); if (at == std::string_view::npos || at == 0 || at == s.size() - 1) return false; @@ -144,7 +158,7 @@ static bool fast_check_hostname(std::string_view s) { } // Check format by pre-resolved numeric ID — no string comparisons. -static bool check_format_by_id(std::string_view sv, uint8_t fid) { +ATA_ALWAYS_INLINE static bool check_format_by_id(std::string_view sv, uint8_t fid) { switch (fid) { case 0: return fast_check_email(sv); case 1: return fast_check_date(sv); @@ -344,6 +358,11 @@ struct schema_node { // boolean schema std::optional boolean_schema; + + // Property declaration order — used by the byte scanner to dispatch + // properties in O(1) when JSON keys arrive in schema order. Unordered_map + // is hash-randomized so we capture insertion order separately. + std::vector property_order; }; // --- Codegen: flat bytecode plan --- @@ -414,9 +433,25 @@ struct od_plan { int required_idx = -1; // bit index for required tracking, or -1 std::shared_ptr sub; // property sub-plan, or nullptr fast_kind fk = fast_kind::OTHER; // inline dispatch hint, set at compile time + + // Inline key cache for the byte scanner: keys ≤ 8 bytes can be matched + // with a single uint64_t equality test instead of memcmp. Set by the + // post-processing pass in compile_od_plan; key_len_inline == 0 means + // the key is too long (or contains non-ASCII) and the scanner must + // fall through to memcmp. + uint64_t key_first8 = 0; + uint8_t key_len_inline = 0; }; struct obj_plan { std::vector entries; // merged required + properties — single scan + // SoA hot lookup arrays — parallel to entries[]. The byte scanner walks + // these instead of touching prop_entry directly so the linear key scan + // hits one or two cache lines instead of one per entry. + // hot_key_len_inline[i] == 0 means entries[i].key is the source of truth + // (long key or non-ASCII) — compare via memcmp in that case. + std::vector hot_key_first8; + std::vector hot_key_len_inline; + size_t required_count = 0; bool no_additional = false; std::optional min_props, max_props; @@ -443,6 +478,11 @@ struct od_plan { // If false, schema uses unsupported features — must fall back to DOM path. bool supported = true; + + // True when this plan and every reachable sub-plan can be validated by the + // schema-driven byte scanner (fast_scan_*) — bypassing simdjson entirely. + // Computed in a single post-compile pass; checked once at runtime. + bool fast_scan_eligible = false; }; using od_plan_ptr = std::shared_ptr; @@ -687,7 +727,11 @@ static schema_node_ptr compile_node(dom::element el, dom::element props_el; if (obj["properties"].get(props_el) == SUCCESS && props_el.is()) { dom::object props_obj; props_el.get(props_obj); for (auto [key, val] : props_obj) { - node->properties[std::string(key)] = compile_node(val, ctx); + std::string k(key); + if (node->properties.find(k) == node->properties.end()) { + node->property_order.push_back(k); + } + node->properties[k] = compile_node(val, ctx); } } @@ -2578,6 +2622,12 @@ static od_plan_ptr compile_od_plan(const schema_node_ptr& node) { if (!node->properties.empty() || !node->required.empty() || node->additional_properties_bool.has_value() || node->min_properties.has_value() || node->max_properties.has_value()) { + // The required-tracking mask is a single uint64. Beyond 64 required + // fields it would saturate to ~0, silently widening "all required + // present" — a correctness gap on this path. Send these schemas to + // the DOM tree walker instead, which iterates required[] directly + // and has no such limit. + if (node->required.size() > 64) { plan->supported = false; return plan; } auto op = std::make_shared(); op->required_count = node->required.size(); op->min_props = node->min_properties; @@ -2586,30 +2636,74 @@ static od_plan_ptr compile_od_plan(const schema_node_ptr& node) { !node->additional_properties_bool.value()) { op->no_additional = true; } - // Build merged entries: each key appears once with required_idx + sub_plan + // Build merged entries in property declaration order. Real-world JSON + // payloads emit keys in this order >95% of the time (typed-object + // serialization, generated code, language-driven object initializers), + // so the byte scanner's speculative in-order dispatch hits with no + // linear-scan fallback. Required-only keys (declared in `required` + // but not in `properties`) are appended at the end. std::unordered_map key_to_idx; - // Register required keys - for (size_t i = 0; i < node->required.size() && i < 64; i++) { - auto& rk = node->required[i]; - if (key_to_idx.find(rk) == key_to_idx.end()) { - key_to_idx[rk] = op->entries.size(); - op->entries.push_back({rk, static_cast(i), nullptr}); - } else { - op->entries[key_to_idx[rk]].required_idx = static_cast(i); - } + // Pass 1: properties in declaration order + for (auto& key : node->property_order) { + auto pit = node->properties.find(key); + if (pit == node->properties.end()) continue; + auto sub = compile_od_plan(pit->second); + if (!sub || !sub->supported) { plan->supported = false; return plan; } + key_to_idx[key] = op->entries.size(); + op->entries.push_back({key, -1, std::move(sub), od_plan::fast_kind::OTHER}); } - // Register properties + compile sub-plans + // Pass 1b: any property not captured in property_order (defensive — e.g. + // schemas constructed via paths that bypass compile_node). Falls back + // to unordered_map iteration order so behavior remains correct. for (auto& [key, sub_node] : node->properties) { + if (key_to_idx.count(key)) continue; auto sub = compile_od_plan(sub_node); if (!sub || !sub->supported) { plan->supported = false; return plan; } - auto it = key_to_idx.find(key); + key_to_idx[key] = op->entries.size(); + op->entries.push_back({key, -1, std::move(sub), od_plan::fast_kind::OTHER}); + } + // Pass 2: assign required indices (and append required-only keys) + for (size_t i = 0; i < node->required.size() && i < 64; i++) { + auto& rk = node->required[i]; + auto it = key_to_idx.find(rk); if (it != key_to_idx.end()) { - op->entries[it->second].sub = std::move(sub); + op->entries[it->second].required_idx = static_cast(i); } else { - key_to_idx[key] = op->entries.size(); - op->entries.push_back({key, -1, std::move(sub), od_plan::fast_kind::OTHER}); + key_to_idx[rk] = op->entries.size(); + op->entries.push_back({rk, static_cast(i), nullptr}); } } + // Inline key cache for the byte scanner: keys ≤ 8 bytes can be matched + // by a uint64_t compare instead of memcmp. Mirror the cache into SoA + // arrays for cache-friendly linear lookup. + // + // Sentinel for long / non-ASCII entries: 0xFFFFFFFFFFFFFFFF. Real JSON + // keys produce values whose every byte is < 0x80 (high bits zeroed + // because the scanner bails on high-bit bytes via key_has_high), so + // the sentinel can never equal a JSON-derived key_first8. This lets + // the speculative dispatch loop run a single uint64 compare with no + // "is this entry inline-cached?" guard. + constexpr uint64_t HOT_KEY_SENTINEL = 0xFFFFFFFFFFFFFFFFULL; + op->hot_key_first8.reserve(op->entries.size()); + op->hot_key_len_inline.reserve(op->entries.size()); + for (auto& e : op->entries) { + if (e.key.size() <= 8 && !e.key.empty()) { + bool ascii = true; + for (char c : e.key) { + if ((uint8_t)c >= 0x80) { ascii = false; break; } + } + if (ascii) { + uint64_t v = 0; + std::memcpy(&v, e.key.data(), e.key.size()); + e.key_first8 = v; + e.key_len_inline = (uint8_t)e.key.size(); + } + } + uint64_t hot = e.key_len_inline ? e.key_first8 : HOT_KEY_SENTINEL; + op->hot_key_first8.push_back(hot); + op->hot_key_len_inline.push_back(e.key_len_inline); + } + // Compute fast_kind for each entry post-hoc — lets the obj iterator // skip the recursive od_exec_plan call (and its type().get + switch) // for primitive properties, and also skip type detection for nested @@ -2667,6 +2761,635 @@ static od_plan_ptr compile_od_plan(const schema_node_ptr& node) { return plan; } +// --- Schema-driven byte scanner --- +// A third validation path: walk the JSON buffer directly using the od_plan's +// shape, never invoking simdjson. Designed for eligible Fastify-style schemas +// where the structure is fully known: top-level object with primitive props, +// nested objects one level deep, primitive arrays, plus inline numeric/string +// constraints. On any unsupported feature seen at runtime (escapes, floats +// where ints expected, unknown keys without no_additional, overflow), the +// scanner returns scan_result::fallback and the caller resumes from the +// existing on-demand path on the original buffer. + +enum class scan_result : uint8_t { ok, fail, fallback }; + +static inline uint64_t utf8_length_fast(std::string_view s); + +// SWAR: find first '"' or '\\' or byte < 0x20 in 8-byte chunk. +// Updates has_high if any byte has its top bit set. +// Returns byte index 0-7 of first match, or -1 if none. +// +// The < 0x20 test uses (b & 0xE0) == 0; mask values for ASCII text are +// 0x20 / 0x40 / 0x60, never 0, so the standard "find zero byte" SWAR is +// borrow-safe for typical JSON content. +static inline int swar_string_term_pos(uint64_t v, bool& has_high) { + has_high |= (v & 0x8080808080808080ULL) != 0; + + const uint64_t one = 0x0101010101010101ULL; + const uint64_t himask = 0x8080808080808080ULL; + + uint64_t q = v ^ 0x2222222222222222ULL; // '"' + uint64_t mq = (q - one) & ~q & himask; + + uint64_t s = v ^ 0x5C5C5C5C5C5C5C5CULL; // '\' + uint64_t ms = (s - one) & ~s & himask; + + uint64_t l = v & 0xE0E0E0E0E0E0E0E0ULL; // < 0x20 + uint64_t ml = (l - one) & ~l & himask; + + uint64_t m = mq | ms | ml; + if (m == 0) return -1; + return (int)(__builtin_ctzll(m) >> 3); +} + +// Recursively decide if a plan and all its sub-plans are eligible for the +// scanner. Pre-condition: caller has confirmed plan.supported. +static bool fast_scan_eligible_recursive(const od_plan& plan) { + if (!plan.supported) return false; + + if (plan.type_mask) { + uint8_t s_bit = json_type_bit(json_type::string); + uint8_t i_bit = json_type_bit(json_type::integer); + uint8_t n_bit = json_type_bit(json_type::number); + uint8_t b_bit = json_type_bit(json_type::boolean); + uint8_t nl_bit = json_type_bit(json_type::null_value); + uint8_t o_bit = json_type_bit(json_type::object); + uint8_t a_bit = json_type_bit(json_type::array); + + // Reject schemas that constrain only "number" without "integer" — the + // scanner doesn't parse floating-point. Mixed (integer|number, i.e. any + // schema that accepts integers) is fine: scanner consumes the integer + // path and falls back if it encounters a decimal/exponent at runtime. + if ((plan.type_mask & n_bit) && !(plan.type_mask & i_bit)) return false; + + // Reject schemas where type_mask contradicts the plan's structure. + // A schema like {"type":"string","properties":{"x":...}} sets both + // type_mask=string and plan.object; the scanner would happily walk the + // object structure and miss the type mismatch. The on-demand path + // catches this via its value.type() check, so we send these through + // it instead of running the scanner blind. + if (plan.object && !(plan.type_mask & o_bit)) return false; + if (plan.array && !(plan.type_mask & a_bit)) return false; + + // Type mask must permit at least one of the JSON token classes the + // scanner emits. (Any combination of the seven JSON types is fine — + // we just want to make sure type_mask isn't pathologically empty.) + uint8_t any = s_bit | i_bit | n_bit | b_bit | nl_bit | o_bit | a_bit; + if ((plan.type_mask & any) == 0) return false; + } + + if (plan.object) { + // See compute_fast_scan_eligible; the required-mask saturates to ~0 + // beyond 64 entries, so we route those through the existing path. + if (plan.object->required_count > 64) return false; + for (auto& e : plan.object->entries) { + if (e.sub && !fast_scan_eligible_recursive(*e.sub)) return false; + } + } + if (plan.array && plan.array->items) { + if (!fast_scan_eligible_recursive(*plan.array->items)) return false; + } + return true; +} + +// Top-level eligibility entry. Restricts to schemas whose root validates an +// object — array roots are uncommon in HTTP body validation and not worth +// the v1 surface area. +// +// Also rejects schemas where the root object plan declares more than 64 +// required keys. The required-mask is a single uint64 saturated to ~0 in +// that case, which silently widens "all required present" — a behavior we +// don't want to risk inheriting on the fast path. Such schemas fall through +// to the existing on-demand path (which has its own well-tested handling +// of these edge cases). +static bool compute_fast_scan_eligible(const od_plan& plan) { + if (!plan.supported || !plan.object) return false; + if (plan.object->required_count > 64) return false; + return fast_scan_eligible_recursive(plan); +} + +// Whitespace skip — branchless on the dense-JSON fast path. JSON whitespace +// is exactly {0x09 LF, 0x0A LF, 0x0D CR, 0x20 SPACE}; all are <= 0x20. +// Fast path: most of the time *p is a structural char > 0x20, so the single +// initial test exits with no branching cost. The slow inner loop only runs +// when whitespace is actually present. +ATA_ALWAYS_INLINE static void scan_skip_ws(const char*& p, const char* end) { + if (p >= end || (uint8_t)*p > 0x20) return; + while (p < end) { + uint8_t c = (uint8_t)*p; + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') p++; + else return; + } +} + +// Parse a JSON integer at p. Advances p past the number. Returns: +// ok — out holds the value +// fail — not a valid number start (caller treats as schema failure) +// fallback — overflow or contains '.', 'e', 'E' (caller restarts on-demand) +ATA_ALWAYS_INLINE static scan_result scan_integer_value(const char*& p, const char* end, int64_t& out) { + // Pre-condition (verified by every call site in this file): p < end and + // *p is '-' or '0'..'9'. Skipping the redundant guard saves ~3 cycles + // per integer in the hot loop. + bool neg = (*p == '-'); + if (neg) { + p++; + if (p >= end || *p < '0' || *p > '9') return scan_result::fail; + } + // Reject "01"-style leading zeros to match simdjson's strict number parse. + if (*p == '0' && p + 1 < end && p[1] >= '0' && p[1] <= '9') return scan_result::fail; + + uint64_t mag = 0; + while (p < end && *p >= '0' && *p <= '9') { + uint64_t d = (uint64_t)(*p - '0'); + if (mag > (UINT64_MAX - d) / 10) return scan_result::fallback; + mag = mag * 10 + d; + p++; + } + if (p < end && (*p == '.' || *p == 'e' || *p == 'E')) return scan_result::fallback; + + if (neg) { + if (mag > (uint64_t)INT64_MAX + 1) return scan_result::fallback; + out = (mag == (uint64_t)INT64_MAX + 1) ? INT64_MIN : -(int64_t)mag; + } else { + if (mag > (uint64_t)INT64_MAX) return scan_result::fallback; + out = (int64_t)mag; + } + return scan_result::ok; +} + +// Forward declarations for the mutually recursive scanners. +static scan_result fast_scan_value(const od_plan& plan, const char*& p, const char* end); +static scan_result fast_scan_object(const od_plan& plan, const char*& p, const char* end); +static scan_result fast_scan_array(const od_plan& plan, const char*& p, const char* end); + +// Caller has consumed the opening '"'. Scans string contents 8 bytes at +// a time (SWAR), applies constraints, advances p past the closing '"'. +// Buffer padding (REQUIRED_PADDING = 64) guarantees the over-read of the +// final block stays in addressable memory. +ATA_ALWAYS_INLINE static scan_result fast_scan_string(const od_plan& plan, const char*& p, const char* end) { + const char* s_start = p; + bool has_high = false; + + while (p < end) { + uint64_t v; + std::memcpy(&v, p, 8); + int idx = swar_string_term_pos(v, has_high); + if (idx < 0) { + p += 8; + continue; + } + const char* hit = p + idx; + if (hit >= end) return scan_result::fail; // matched padding zero + uint8_t c = (uint8_t)*hit; + if (c == '"') { + std::string_view sv(s_start, (size_t)(hit - s_start)); + p = hit + 1; + + if (plan.min_length || plan.max_length) { + uint64_t len = has_high ? utf8_length_fast(sv) : sv.size(); + if (plan.min_length && len < *plan.min_length) return scan_result::fail; + if (plan.max_length && len > *plan.max_length) return scan_result::fail; + } + if (plan.digit_pattern) { + auto& dp = *plan.digit_pattern; + if (sv.size() < dp.min_len || sv.size() > dp.max_len) return scan_result::fail; + const uint8_t* sp = reinterpret_cast(sv.data()); + for (size_t i = 0, n = sv.size(); i < n; i++) { + if (sp[i] < '0' || sp[i] > '9') return scan_result::fail; + } + } +#ifndef ATA_NO_RE2 + else if (plan.pattern) { + if (!re2::RE2::PartialMatch(re2::StringPiece(sv.data(), sv.size()), *plan.pattern)) + return scan_result::fail; + } +#endif + if (plan.format_id != 255) { + if (!check_format_by_id(sv, plan.format_id)) return scan_result::fail; + } + if (plan.enum_check) { + bool match = false; + for (auto& s : plan.enum_check->strings) { + if (sv.size() == s.size() && std::memcmp(sv.data(), s.data(), s.size()) == 0) { + match = true; + break; + } + } + if (!match) return scan_result::fail; + } + return scan_result::ok; + } + if (c == '\\') return scan_result::fallback; + return scan_result::fail; // < 0x20 control char in string + } + return scan_result::fail; +} + +static scan_result fast_scan_value(const od_plan& plan, const char*& p, const char* end) { + scan_skip_ws(p, end); + if (p >= end) return scan_result::fail; + char c = *p; + + switch (c) { + case '{': { + if (plan.type_mask && !(plan.type_mask & json_type_bit(json_type::object))) + return scan_result::fail; + p++; + return fast_scan_object(plan, p, end); + } + case '[': { + if (plan.type_mask && !(plan.type_mask & json_type_bit(json_type::array))) + return scan_result::fail; + p++; + return fast_scan_array(plan, p, end); + } + case '"': { + if (plan.type_mask && !(plan.type_mask & json_type_bit(json_type::string))) + return scan_result::fail; + p++; + return fast_scan_string(plan, p, end); + } + case 't': { + if (p + 4 > end || p[1] != 'r' || p[2] != 'u' || p[3] != 'e') return scan_result::fail; + if (plan.type_mask && !(plan.type_mask & json_type_bit(json_type::boolean))) + return scan_result::fail; + p += 4; + if (plan.enum_check && !plan.enum_check->has_true) return scan_result::fail; + return scan_result::ok; + } + case 'f': { + if (p + 5 > end || p[1] != 'a' || p[2] != 'l' || p[3] != 's' || p[4] != 'e') + return scan_result::fail; + if (plan.type_mask && !(plan.type_mask & json_type_bit(json_type::boolean))) + return scan_result::fail; + p += 5; + if (plan.enum_check && !plan.enum_check->has_false) return scan_result::fail; + return scan_result::ok; + } + case 'n': { + if (p + 4 > end || p[1] != 'u' || p[2] != 'l' || p[3] != 'l') return scan_result::fail; + if (plan.type_mask && !(plan.type_mask & json_type_bit(json_type::null_value))) + return scan_result::fail; + p += 4; + if (plan.enum_check && !plan.enum_check->has_null) return scan_result::fail; + return scan_result::ok; + } + case '-': case '0': case '1': case '2': case '3': + case '4': case '5': case '6': case '7': case '8': case '9': { + if (plan.type_mask) { + uint8_t int_bits = json_type_bit(json_type::integer) | json_type_bit(json_type::number); + if (!(plan.type_mask & int_bits)) return scan_result::fail; + } + int64_t iv; + scan_result r = scan_integer_value(p, end, iv); + if (r != scan_result::ok) return r; + + uint8_t f = plan.num_flags; + if (f) { + double v = (double)iv; + if ((f & od_plan::HAS_MIN) && v < plan.num_min) return scan_result::fail; + if ((f & od_plan::HAS_MAX) && v > plan.num_max) return scan_result::fail; + if ((f & od_plan::HAS_EX_MIN) && v <= plan.num_ex_min) return scan_result::fail; + if ((f & od_plan::HAS_EX_MAX) && v >= plan.num_ex_max) return scan_result::fail; + if (f & od_plan::HAS_MUL) { + double r2 = std::fmod(v, plan.num_mul); + if (std::abs(r2) > 1e-8 && std::abs(r2 - plan.num_mul) > 1e-8) return scan_result::fail; + } + } + if (plan.enum_check) { + bool match = false; + for (auto i : plan.enum_check->integers) if (i == iv) { match = true; break; } + if (!match) { + double v = (double)iv; + for (auto d : plan.enum_check->doubles) if (d == v) { match = true; break; } + } + if (!match) return scan_result::fail; + } + return scan_result::ok; + } + default: + return scan_result::fail; + } +} + +// Caller has consumed the opening '{'. +static scan_result fast_scan_object(const od_plan& plan, const char*& p, const char* end) { + if (!plan.object) return scan_result::fallback; + auto& op = *plan.object; + + uint64_t required_mask = (op.required_count >= 64) + ? ~0ULL : ((1ULL << op.required_count) - 1); + uint64_t required_found = 0; + uint64_t prop_count = 0; + + // Speculative in-order dispatch: most JSON payloads (typed-object + // serialization, framework request bodies, generated code) emit keys + // in schema declaration order, so checking entries[next_idx] first + // turns the per-property dispatch into a single uint64 cmp. + size_t next_idx = 0; + + // Hoist loop-invariant pointers and sizes out of the per-property loop. + // Vector .data() / .size() can't always be const-folded across the + // body — explicit caching here is a small but reliable win. + const size_t n = op.entries.size(); + const uint64_t* const hk = op.hot_key_first8.data(); + const uint8_t* const hl = op.hot_key_len_inline.data(); + od_plan::prop_entry* const entries_ptr = op.entries.data(); + + + scan_skip_ws(p, end); + if (p < end && *p == '}') { + p++; + if (required_mask) return scan_result::fail; + if (op.min_props && 0 < *op.min_props) return scan_result::fail; + return scan_result::ok; + } + + for (;;) { + // Dense path: most properties begin with '"' immediately (no ws). Only + // call into skip_ws when the next byte is whitespace (≤ 0x20). + if (p >= end) return scan_result::fail; + if ((uint8_t)*p <= 0x20) scan_skip_ws(p, end); + if (p >= end || *p != '"') return scan_result::fail; + p++; + + // Batched key scan: a single 8-byte load feeds both the SWAR terminator + // search and the key_first8 hash. For keys ≤ 7 bytes (overwhelmingly + // common in real-world JSON Schemas) this finishes in one chunk with + // zero redundant memory ops. + static constexpr uint64_t key_len_masks[9] = { + 0, 0xFFULL, 0xFFFFULL, 0xFFFFFFULL, 0xFFFFFFFFULL, + 0xFFFFFFFFFFULL, 0xFFFFFFFFFFFFULL, 0xFFFFFFFFFFFFFFULL, 0xFFFFFFFFFFFFFFFFULL + }; + + const char* k_start = p; + size_t klen; + uint64_t key_first8 = 0; + { + uint64_t v; + std::memcpy(&v, p, 8); + bool key_has_high = false; + int idx = swar_string_term_pos(v, key_has_high); + if (idx >= 0 && idx < 8 && (p + idx) < end) { + // Fast: terminator within first 8 bytes. + uint8_t kc = (uint8_t)p[idx]; + if (kc != '"') { + if (kc == '\\') return scan_result::fallback; + return scan_result::fail; + } + if (key_has_high) return scan_result::fallback; + klen = (size_t)idx; + key_first8 = v & key_len_masks[klen]; + p += klen; + } else { + // Slow: terminator not in the first 8 bytes. Either the key is + // exactly 8 bytes (closing quote sits in the next chunk) or it's + // longer. Continue scanning chunks. + p += 8; + while (p < end) { + uint64_t v2; + std::memcpy(&v2, p, 8); + int idx2 = swar_string_term_pos(v2, key_has_high); + if (idx2 < 0) { p += 8; continue; } + const char* hit = p + idx2; + if (hit >= end) return scan_result::fail; + uint8_t kc = (uint8_t)*hit; + if (kc != '"') { + if (kc == '\\') return scan_result::fallback; + return scan_result::fail; + } + p = hit; + break; + } + if (p >= end) return scan_result::fail; + if (key_has_high) return scan_result::fallback; + klen = (size_t)(p - k_start); + // For exactly-8-byte keys the original `v` already holds the full + // key bytes (no terminator was hit in the first chunk). Promote + // them into the inline-cache hash so 8-char keys still take the + // fast dispatch path instead of falling all the way through to + // the memcmp loop. + if (klen == 8) key_first8 = v; + } + } + p++; + prop_count++; + + if (p >= end) return scan_result::fail; + if ((uint8_t)*p <= 0x20) scan_skip_ws(p, end); + if (p >= end || *p != ':') return scan_result::fail; + p++; + + // Hot path: speculative in-order match. Single uint64 cmp resolves + // dispatch when JSON keys arrive in schema order (the common case). + // Long / non-ASCII entries hold HOT_KEY_SENTINEL, a value JSON keys + // can never produce, so the comparison is always safe to run without + // a separate "is this entry inline-cached?" guard. Long JSON keys + // (key_first8 == 0) won't match either branch and fall through to the + // memcmp slow path below. + size_t found = SIZE_MAX; + if (next_idx < n && hk[next_idx] == key_first8) { + found = next_idx; + } else if (key_first8) { + for (size_t i = 0; i < n; i++) { + if (hk[i] == key_first8) { found = i; break; } + } + } + if (found != SIZE_MAX) next_idx = found + 1; + if (found == SIZE_MAX) { + // Slow fallback for long / non-ASCII keys. + for (size_t i = 0; i < n; i++) { + if (hl[i]) continue; + auto& e = entries_ptr[i]; + if (klen == e.key.size() && + std::memcmp(k_start, e.key.data(), klen) == 0) { found = i; break; } + } + } + + bool matched = false; + if (found != SIZE_MAX) { + auto& e = entries_ptr[found]; + matched = true; +#if 0 + // DEBUG: stub all dispatch — just skip value + (void)required_found; (void)e; + // skip whitespace + value (very rough — find next , or }) + while (p < end && *p != ',' && *p != '}') p++; +#else + { + if (e.required_idx >= 0) + required_found |= (1ULL << e.required_idx); + if (!e.sub) return scan_result::fallback; + + // Inline dispatch by pre-computed fast_kind: skips fast_scan_value's + // skip_ws + switch + type_mask check. + scan_skip_ws(p, end); + if (p >= end) return scan_result::fail; + const od_plan& sub = *e.sub; + switch (e.fk) { + case od_plan::fast_kind::INTEGER: { + char c = *p; + if (c != '-' && (c < '0' || c > '9')) return scan_result::fail; + int64_t iv; + scan_result r = scan_integer_value(p, end, iv); + if (r != scan_result::ok) return r; + uint8_t f = sub.num_flags; + if (f) { + double v = (double)iv; + if ((f & od_plan::HAS_MIN) && v < sub.num_min) return scan_result::fail; + if ((f & od_plan::HAS_MAX) && v > sub.num_max) return scan_result::fail; + if ((f & od_plan::HAS_EX_MIN) && v <= sub.num_ex_min) return scan_result::fail; + if ((f & od_plan::HAS_EX_MAX) && v >= sub.num_ex_max) return scan_result::fail; + if (f & od_plan::HAS_MUL) { + double r2 = std::fmod(v, sub.num_mul); + if (std::abs(r2) > 1e-8 && std::abs(r2 - sub.num_mul) > 1e-8) + return scan_result::fail; + } + } + if (sub.enum_check) { + bool em = false; + for (auto i2 : sub.enum_check->integers) if (i2 == iv) { em = true; break; } + if (!em) { + double v = (double)iv; + for (auto d : sub.enum_check->doubles) if (d == v) { em = true; break; } + } + if (!em) return scan_result::fail; + } + break; + } + case od_plan::fast_kind::STRING: { + if (*p != '"') return scan_result::fail; + p++; + scan_result r = fast_scan_string(sub, p, end); + if (r != scan_result::ok) return r; + break; + } + case od_plan::fast_kind::BOOLEAN: { + if (*p == 't') { + if (p + 4 > end || p[1] != 'r' || p[2] != 'u' || p[3] != 'e') + return scan_result::fail; + p += 4; + if (sub.enum_check && !sub.enum_check->has_true) return scan_result::fail; + } else if (*p == 'f') { + if (p + 5 > end || p[1] != 'a' || p[2] != 'l' || p[3] != 's' || p[4] != 'e') + return scan_result::fail; + p += 5; + if (sub.enum_check && !sub.enum_check->has_false) return scan_result::fail; + } else { + return scan_result::fail; + } + break; + } + case od_plan::fast_kind::OBJECT: { + if (*p != '{') return scan_result::fail; + p++; + scan_result r = fast_scan_object(sub, p, end); + if (r != scan_result::ok) return r; + break; + } + case od_plan::fast_kind::ARRAY: { + if (*p != '[') return scan_result::fail; + p++; + scan_result r = fast_scan_array(sub, p, end); + if (r != scan_result::ok) return r; + break; + } + case od_plan::fast_kind::OTHER: + default: { + scan_result r = fast_scan_value(sub, p, end); + if (r != scan_result::ok) return r; + } + } + } +#endif + } + if (!matched) { + if (op.no_additional) return scan_result::fail; + // Unknown property without no_additional: would need a generic + // value-skipper. Defer to on-demand for v1. + return scan_result::fallback; + } + + // Combined skip_ws + structural char check — for dense JSON the byte + // is already ',' or '}' so we exit in one load + two compares. Only + // pay skip_ws's cost when whitespace is actually present. + if (p >= end) return scan_result::fail; + { + uint8_t c = (uint8_t)*p; + if (c == ',') { p++; continue; } + if (c == '}') { p++; break; } + if (c <= 0x20) { + scan_skip_ws(p, end); + if (p < end) { + c = (uint8_t)*p; + if (c == ',') { p++; continue; } + if (c == '}') { p++; break; } + } + } + } + return scan_result::fail; + } + + if ((required_found & required_mask) != required_mask) return scan_result::fail; + if (op.min_props && prop_count < *op.min_props) return scan_result::fail; + if (op.max_props && prop_count > *op.max_props) return scan_result::fail; + return scan_result::ok; +} + +// Caller has consumed the opening '['. +static scan_result fast_scan_array(const od_plan& plan, const char*& p, const char* end) { + uint64_t count = 0; + + scan_skip_ws(p, end); + if (p < end && *p == ']') { + p++; + if (plan.array) { + auto& ap = *plan.array; + if (ap.min_items && 0 < *ap.min_items) return scan_result::fail; + } + return scan_result::ok; + } + + for (;;) { + if (plan.array && plan.array->items) { + scan_result r = fast_scan_value(*plan.array->items, p, end); + if (r != scan_result::ok) return r; + } else { + return scan_result::fallback; + } + count++; + + scan_skip_ws(p, end); + if (p >= end) return scan_result::fail; + if (*p == ',') { p++; continue; } + if (*p == ']') { p++; break; } + return scan_result::fail; + } + + if (plan.array) { + auto& ap = *plan.array; + if (ap.min_items && count < *ap.min_items) return scan_result::fail; + if (ap.max_items && count > *ap.max_items) return scan_result::fail; + } + return scan_result::ok; +} + +// Top-level entry: skips leading whitespace, requires a top-level object, +// dispatches to fast_scan_object. On scan_result::fallback, caller resumes +// the existing on-demand path on the original buffer. +static scan_result fast_scan_root(const od_plan& plan, const char* data, size_t length) { + const char* p = data; + const char* end = data + length; + scan_skip_ws(p, end); + if (p >= end || *p != '{') return scan_result::fallback; + p++; + scan_result r = fast_scan_object(plan, p, end); + if (r != scan_result::ok) return r; + scan_skip_ws(p, end); + if (p != end) return scan_result::fail; // trailing garbage + return scan_result::ok; +} + // Fast ASCII check: if all bytes < 0x80, byte length == codepoint length static inline uint64_t utf8_length_fast(std::string_view s) { // Check 8 bytes at a time for non-ASCII @@ -3013,6 +3736,9 @@ schema_ref compile(std::string_view schema_json) { ctx->gen_plan.code.push_back({cg::op::END}); ctx->use_ondemand = plan_supports_ondemand(ctx->gen_plan); ctx->od = compile_od_plan(ctx->root); + if (ctx->od && ctx->od->supported) { + ctx->od->fast_scan_eligible = compute_fast_scan_eligible(*ctx->od); + } schema_ref ref; ref.impl = ctx; @@ -3097,15 +3823,28 @@ validation_result validate(std::string_view schema_json, } +// Minimum input size for the on-demand and byte-scanner paths. Below this +// simdjson on-demand can't fully validate small malformed docs, and the +// scanner has diminishing returns vs the DOM path's setup cost. +static constexpr size_t MIN_OD_LENGTH = 32; + bool is_valid_prepadded(const schema_ref& schema, const char* data, size_t length) { if (!schema.impl || !schema.impl->root) return false; + // Schema-driven byte scanner: bypass simdjson for eligible schemas. + // Saves the ~40 ns simdjson on-demand floor on small documents. + if (schema.impl->od && schema.impl->od->fast_scan_eligible && length >= MIN_OD_LENGTH) { + scan_result r = fast_scan_root(*schema.impl->od, data, length); + if (r == scan_result::ok) return true; + if (r == scan_result::fail) return false; + // fallback — drop through to on-demand path + } + simdjson::padded_string fallback; auto psv = get_free_padded_view(data, length, fallback); // On-Demand fast path: skip DOM parse entirely - // Minimum 32 bytes — On-Demand doesn't fully validate small malformed docs - if (schema.impl->od && schema.impl->od->supported && length >= 32) { + if (schema.impl->od && schema.impl->od->supported && length >= MIN_OD_LENGTH) { auto od_result = tl_od_parser().iterate(psv); if (!od_result.error()) { simdjson::ondemand::value root_val; diff --git a/tests/test_scanner_regression.js b/tests/test_scanner_regression.js new file mode 100644 index 0000000..93cef55 --- /dev/null +++ b/tests/test_scanner_regression.js @@ -0,0 +1,181 @@ +'use strict' + +// Targeted regression tests for the schema-driven byte scanner introduced +// in src/ata.cpp. These exercise edge cases the JSON Schema test suite and +// fuzz_differential.js do not cover (long keys, root type_mask mismatch, +// boundary-length keys). + +const { Validator } = require('../index') + +let pass = 0 +let fail = 0 + +function check(label, got, expected) { + if (got === expected) { + console.log(' PASS', label) + pass++ + } else { + console.log(' FAIL', label, '\n expected', expected, 'got', got) + fail++ + } +} + +function asBuffer(obj, padTo = 32) { + let s = JSON.stringify(obj) + while (s.length < padTo) s += ' ' + return Buffer.from(s) +} + +console.log('Scanner regression tests') + +// --- Bug 1: speculation false-match on long-key entries ------------------ +// When both schema entry and JSON key are >8 bytes ASCII, the inline key +// cache is empty (key_first8 = 0) for both. Speculative dispatch must not +// treat hk[next_idx] == 0 == key_first8 as a valid match — it has to fall +// through to the long-key fallback. +{ + const schema = { + type: 'object', + properties: { + thisisalongkey1: { type: 'integer' }, + thisisalongkey2: { type: 'string' }, + }, + required: ['thisisalongkey1', 'thisisalongkey2'], + } + const v = new Validator(schema) + + check( + 'long keys, declaration order, valid types', + v.isValid(asBuffer({ thisisalongkey1: 1, thisisalongkey2: 'foo' })), + true, + ) + check( + 'long keys, REVERSE order, valid types', + v.isValid(asBuffer({ thisisalongkey2: 'foo', thisisalongkey1: 1 })), + true, + ) + check( + 'long keys, reverse order, swapped types (must fail)', + v.isValid(asBuffer({ thisisalongkey2: 1, thisisalongkey1: 'foo' })), + false, + ) +} + +// --- Bug 2: root type_mask not validated --------------------------------- +// A schema can have `properties` (which sets plan.object) and a non-object +// `type`. The scanner must not accept a {-shaped payload as valid in that +// case — it has to fall through to the on-demand path so type_mask is +// honored. +{ + const schema = { + type: 'string', + properties: { x: { type: 'integer' } }, + } + const v = new Validator(schema) + check( + 'type:string + properties, object payload (must fail)', + v.isValid(asBuffer({ x: 1 })), + false, + ) +} +{ + const schema = { + type: 'array', + properties: { x: { type: 'integer' } }, + } + const v = new Validator(schema) + check( + 'type:array + properties, object payload (must fail)', + v.isValid(asBuffer({ x: 1 })), + false, + ) +} + +// --- Boundary: 8-char keys ----------------------------------------------- +// 8 bytes is the inline-cache boundary. Make sure the scanner agrees with +// the on-demand path for keys at exactly that length. +{ + const schema = { + type: 'object', + properties: { + eightchr: { type: 'integer' }, + another8: { type: 'string' }, + }, + required: ['eightchr', 'another8'], + } + const v = new Validator(schema) + check( + '8-char keys, declaration order', + v.isValid(asBuffer({ eightchr: 1, another8: 'foo' })), + true, + ) + check( + '8-char keys, reverse order', + v.isValid(asBuffer({ another8: 'foo', eightchr: 1 })), + true, + ) + check( + '8-char keys, type mismatch', + v.isValid(asBuffer({ eightchr: 'not an int', another8: 'foo' })), + false, + ) +} + +// --- >64 required fields: scanner must opt out -------------------------- +// The required-mask is a single uint64 saturated to ~0 once required_count +// exceeds 64. The scanner's eligibility gate routes these schemas through +// the on-demand path so saturation can't silently widen "all required +// present". +{ + const props = {} + const required = [] + for (let i = 0; i < 70; i++) { + props['p' + i] = { type: 'integer' } + required.push('p' + i) + } + const schema = { type: 'object', properties: props, required } + const v = new Validator(schema) + + // All 70 required fields present + const allPresent = {} + for (let i = 0; i < 70; i++) allPresent['p' + i] = i + check('70 required fields, all present', v.isValid(asBuffer(allPresent)), true) + + // Drop one required field; must fail + const missing = { ...allPresent } + delete missing.p65 + check('70 required fields, one beyond bit-63 missing', v.isValid(asBuffer(missing)), false) +} + +// --- Mixed short + long key entries -------------------------------------- +{ + const schema = { + type: 'object', + properties: { + a: { type: 'integer' }, + thisisalongone: { type: 'string' }, + bb: { type: 'boolean' }, + }, + required: ['a', 'thisisalongone', 'bb'], + } + const v = new Validator(schema) + check( + 'mixed short+long keys, declaration order', + v.isValid(asBuffer({ a: 1, thisisalongone: 'x', bb: true })), + true, + ) + check( + 'mixed short+long keys, reverse order', + v.isValid(asBuffer({ bb: true, thisisalongone: 'x', a: 1 })), + true, + ) + check( + 'mixed short+long keys, missing required long key', + v.isValid(asBuffer({ a: 1, bb: true })), + false, + ) +} + +console.log() +console.log(`${pass}/${pass + fail} tests passed`) +process.exit(fail === 0 ? 0 : 1)