diff --git a/.rubocop.yml b/.rubocop.yml index 46eeb0a..1cce16b 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -19,6 +19,7 @@ require: - ./.rubocop/cops/dexpace/no_thread_interrupt.rb - ./.rubocop/cops/dexpace/no_keyword_splat.rb - ./.rubocop/cops/dexpace/qualified_core_constant.rb + - ./.rubocop/cops/dexpace/no_weak_references.rb AllCops: NewCops: enable @@ -181,3 +182,11 @@ Dexpace/QualifiedCoreConstant: Enabled: true Include: - "gems/*/lib/**/*.rb" +# CTX-19: "reimplementations MUST NOT hold contexts by weak/soft references" (phase 4a's P4-10, +# the eighth custom cop). Scoped to every gem's lib/, not core alone: gates:require_allowlist +# covers core's lib/ only, and an adapter gem can hold a context too. test/** is deliberately +# absent, as for the two cops above. +Dexpace/NoWeakReferences: + Enabled: true + Include: + - "gems/*/lib/**/*.rb" diff --git a/.rubocop/cops/dexpace/no_weak_references.rb b/.rubocop/cops/dexpace/no_weak_references.rb new file mode 100644 index 0000000..fe6974e --- /dev/null +++ b/.rubocop/cops/dexpace/no_weak_references.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +module RuboCop + module Cop + module Dexpace + # CTX-19: "reimplementations MUST NOT hold contexts by weak/soft references ... and MUST + # treat the bounded cap (CTX-11), not garbage collection, as the leak backstop." Design + # §5.4 makes that a lint rule; this is the eighth custom cop (phase 4a, P4-10), a ban list + # with a stated hazard in the shape of Dexpace/NoThreadInterrupt and named after its own + # rule, because every cop here mechanises exactly one. + # + # A source-text cop rather than a runtime check, because RuboCop parses and never + # evaluates: the suite's rejected rows include ObjectSpace::WeakKeyMap, which is UNDEFINED + # on the 3.2.11 floor, and RuboCop::ProcessedSource at TargetRubyVersion 3.2 reports + # valid_syntax? and yields the const nodes this cop matches on for every row regardless. + # + # Three spellings: a constant reference to ObjectSpace::WeakMap or ::WeakKeyMap (qualified, + # cbase-qualified, or bare and lexically inside `module ObjectSpace`); a reference to + # WeakRef, bare or cbase-qualified; and `require "weakref"` -- bare, parenthesised, or + # through `Kernel.require` / `::Kernel.require`, and nothing else. An `autoload :WeakRef, + # "weakref"` is NOT matched here: it names the constant as a Symbol, and the reference + # that later triggers it is one the second spelling flags, so the autoload form needs no + # row of its own. The last spelling is redundant inside dexpace-core, where + # gates:require_allowlist already rejects it, and is not redundant anywhere else -- the + # allowlist covers core's lib/ alone, which is why .rubocop.yml scopes this cop to every + # gem's lib/. Neither this cop nor the CTX-19 reachability test can see a third-party + # store: a reimplementation is by definition outside these gates. + class NoWeakReferences < Base + MSG = "`%s` is forbidden: a context must not be held by a weak reference " \ + "(CTX-19); the bounded cap (CTX-11) is the leak backstop." + + RESTRICT_ON_SEND = %i[require].freeze + WEAK_MAP_NAMES = %i[WeakMap WeakKeyMap].freeze + + # @!method object_space_weak_map?(node) + def_node_matcher :object_space_weak_map?, <<~PATTERN + (const (const {nil? cbase} :ObjectSpace) {:WeakMap :WeakKeyMap}) + PATTERN + + # @!method weak_ref_const?(node) + def_node_matcher :weak_ref_const?, "(const {nil? cbase} :WeakRef)" + + # @!method weakref_require?(node) + def_node_matcher :weakref_require?, <<~PATTERN + (send {nil? (const {nil? cbase} :Kernel)} :require (str "weakref")) + PATTERN + + def on_const(node) + return unless object_space_weak_map?(node) || weak_ref_const?(node) || + bare_weak_map_inside_object_space?(node) + + add_offense(node, message: format(MSG, offender: node.source)) + end + + def on_send(node) + return unless weakref_require?(node) + + add_offense(node, message: format(MSG, offender: node.source)) + end + + private + + # `WeakMap`/`WeakKeyMap` written bare, lexically inside `module ObjectSpace`. RuboCop + # parses and never evaluates, so this fires on the source text regardless of whether the + # constant would resolve at runtime. + def bare_weak_map_inside_object_space?(node) + return false unless node.namespace.nil? && WEAK_MAP_NAMES.include?(node.short_name) + + node.each_ancestor(:module).any? do |mod| + mod.identifier.const_type? && mod.identifier.short_name == :ObjectSpace + end + end + end + end + end +end diff --git a/.rubocop/test/cops_test.rb b/.rubocop/test/cops_test.rb index b26b2c4..4e7351b 100644 --- a/.rubocop/test/cops_test.rb +++ b/.rubocop/test/cops_test.rb @@ -242,4 +242,74 @@ class QualifiedCoreConstantIOTest < CopCase end end end + + # Phase 4a (P4-10): the eighth cop, Dexpace/NoWeakReferences. CTX-19 says "reimplementations + # MUST NOT hold contexts by weak/soft references"; design §5.4 makes that a lint rule. RuboCop + # parses and never evaluates, so ObjectSpace::WeakKeyMap is a valid row even though the + # constant is undefined on the 3.2.11 floor -- CopCase pins the parse target at TARGET_RUBY, + # independent of the interpreter running this suite. Its own nested class, for the 100-line cap. + class NoWeakReferencesTest < CopCase + IN_STORE = [ + "module Dexpace", " class ContextStore", " def initialize", " %s", " end", + " end", "end", "", + ].join("\n") + IN_OBJECT_SPACE = [ + "module ObjectSpace", " class Foo", " def initialize", " %s", " end", " end", + "end", "", + ].join("\n") + + # [source, the fragment the message must carry] + REJECTED = [ + ["ObjectSpace::WeakMap.new\n", "CTX-19"], + ["ObjectSpace::WeakKeyMap.new\n", "CTX-19"], + ["::ObjectSpace::WeakKeyMap.new\n", "CTX-19"], + ["::ObjectSpace::WeakMap.new\n", "CTX-19"], + # A bare WeakMap/WeakKeyMap lexically inside `module ObjectSpace` names the same thing. + [format(IN_OBJECT_SPACE, "@m = WeakMap.new"), "CTX-19"], + ["module ObjectSpace\n X = WeakKeyMap\nend\n", "CTX-19"], + ["WeakRef.new(ctx)\n", "CTX-19"], + ["::WeakRef.new(ctx)\n", "CTX-19"], + ["require \"weakref\"\n", "CTX-19"], + ["require(\"weakref\")\n", "CTX-19"], + ["Kernel.require \"weakref\"\n", "CTX-19"], + # The line the rule exists to stop. + [format(IN_STORE, "@map = ObjectSpace::WeakKeyMap.new"), "CTX-19"], + [format(IN_STORE, "@map = ObjectSpace::WeakMap.new"), "CTX-19"], + # A constant reference with no call at all is still a reference. + ["MAP_CLASS = ObjectSpace::WeakMap\n", "CTX-19"], + ].freeze + + # The accepted half is what proves the cop does not reject every occurrence of the substring. + ACCEPTED = [ + "ObjectSpace.count_objects\n", + "ObjectSpace.each_object { |o| o }\n", + "ObjectSpace.garbage_collect\n", + "# ObjectSpace::WeakMap is banned here\n", + "weak_map = {}\n", + "def weak_ref\nend\n", + "@weak_ref = nil\n", + format(IN_STORE, "@h = Hash.new"), + format(IN_STORE, "@h = {}"), + "require \"set\"\n", + "require \"weakref_something_else\"\n", + "require_relative \"weakref\"\n", + # The scoping the bare-name matcher depends on: a bare WeakMap/WeakKeyMap outside + # `module ObjectSpace` names something else and is not this cop's business. + "module Dexpace\n def self.build\n WeakMap.new\n end\nend\n", + "module Dexpace\n module Store\n def self.of\n WeakKeyMap.of\n end\n end\nend\n", + "Other::WeakMap.new\n", + ].freeze + + REJECTED.each_with_index do |(source, fragment), index| + test "rejects case #{index}: #{source.lines.first.strip}" do + assert_offense(D::NoWeakReferences, source, fragment) + end + end + + ACCEPTED.each_with_index do |source, index| + test "accepts case #{index}: #{source.lines.first.strip}" do + assert_no_offense(D::NoWeakReferences, source) + end + end + end end diff --git a/gems/dexpace-core/lib/dexpace.rb b/gems/dexpace-core/lib/dexpace.rb index d37e06e..ee0acdb 100644 --- a/gems/dexpace-core/lib/dexpace.rb +++ b/gems/dexpace-core/lib/dexpace.rb @@ -79,6 +79,24 @@ require_relative "dexpace/http/body/response_logging_body" require_relative "dexpace/http/typed_response" +# Phase 4a: the execution context, in dependency order -- the conflict error, the module the +# three flavours share, the private bounded map and the store over it, the instrumentation +# subsystem (the flavour table, the two no-op singletons, the bundle that carries them), then the +# private key generator and the three flavours innermost first, since each promotion names its +# successor. +require_relative "dexpace/error/context_conflict_error" +require_relative "dexpace/context" +require_relative "dexpace/bounded_map" +require_relative "dexpace/context_store" +require_relative "dexpace/instrumentation/trace_id_flavour" +require_relative "dexpace/instrumentation/no_span" +require_relative "dexpace/instrumentation/no_tracer" +require_relative "dexpace/instrumentation/bundle" +require_relative "dexpace/context/call_key" +require_relative "dexpace/context/exchange_context" +require_relative "dexpace/context/request_context" +require_relative "dexpace/context/dispatch_context" + # Phase 4b: the recovery layer, in dependency order -- the cause walk, the two flat errors, the # closed outcome and its two variants, the Recovery namespace with its buffering function, the # transform contract, the three transforms, the request chain, the private ownership helper, the diff --git a/gems/dexpace-core/lib/dexpace/bounded_map.rb b/gems/dexpace-core/lib/dexpace/bounded_map.rb new file mode 100644 index 0000000..8a79c6e --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/bounded_map.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "model" + +module Dexpace + # The one bounded-keyed-map implementation XCUT-14's general rule, CTX-11's context store and + # (phase 6) AUTH-19's per-nonce counter store all share -- design §5.4's "one implementation", + # as a class rather than a module function because XCUT-14's auditable unit is the map, and a + # `drain!(hash, cap:)` would leave each consumer to own the hash, the mutex and the decision to + # call it, which is precisely the drift the shared implementation exists to prevent. + # + # Not public API (P4-3): nothing outside this repository's own namespace needs the map, and + # NFR-4 locks every public name at the first release tag. A private_constant on Dexpace is + # reachable by a BARE name from any `module Dexpace; module X` body at any nesting depth, in + # any gem -- the reachability is lexical and per file, and a gem boundary is not a lexical one + # -- and from nowhere else: the compact `module Dexpace::X` form raises NameError, and so does a + # qualified `Dexpace::BoundedMap` even from inside Dexpace. Verified on 3.2.11, 3.4.10 and + # 4.0.6; docs/knowledge/notes/execution-context.md records the condition so phase 6 and phase 9 + # meet it as a stated rule rather than as a NameError. + # + # Insert and drain sit in ONE Thread::Mutex#synchronize, so the drain body runs AT MOST ONCE per + # insert: exactly one key is added per critical section and the loop's invariant on entry is + # size <= cap, so size <= cap + 1 at the top. It is written as a loop because XCUT-14 makes that + # a MUST ("using a loop (not a single pre-insert check-then-evict)"), not because a second + # iteration is reachable under this lock discipline -- and the inverse inference is the trap: + # the loop does not make the mutex removable. See the corpus note above. The mutex is held + # across the hash write and the drain of this map's own hash and nothing else; the map never + # yields to caller code while holding it (concurrency-and-async/f414b864, /c0fab747). Phase 6's + # #update(key) { |old| new } for AUTH-19's counter increment runs its block under the mutex and + # must therefore touch only in-memory state. + # + # Hash#shift removes the oldest FIRST REGISTRATION, and Hash#[]= on an existing key does not + # move it (verified fact 8), so an overwrite does not refresh a key's position. Oldest-first is + # what CTX-13 explicitly permits; what it forbids is relying on any entry surviving, and no + # caller of this map does. + class BoundedMap + # @param cap [Integer] the hard bound, a positive Integer + def initialize(cap:) + Model.required!("cap", cap) + unless cap.is_a?(::Integer) && cap.positive? + raise InvalidArgumentError, "cap must be a positive Integer" + end + + @cap = cap + @h = {} #: Hash[untyped, untyped] + @mutex = ::Thread::Mutex.new + end + + # Unconditional overwrite: install-or-replace. Never raises. + # + # @param key [Object] the slot + # @param value [Object] its new occupant + # @return [Object] `value` + def set(key, value) + @mutex.synchronize do + @h[key] = value + drain + end + value + end + + # Reject-on-duplicate insert: install only if absent. Returns whether THIS call installed the + # value; the caller decides what a false return means. Never raises -- the conflict is a + # return value, not an exception, so ContextStore can raise its own error naming the key + # AFTER this mutex is released (concurrency-and-async/f261a143). + # + # @param key [Object] the slot + # @param value [Object] the occupant to install if the slot is empty + # @return [Boolean] whether the value was installed + def put(key, value) + @mutex.synchronize do + next false if @h.key?(key) + + @h[key] = value + drain + true + end + end + + # @param key [Object] the slot + # @return [Object, nil] the occupant, or nil for an empty or evicted slot + def [](key) + @mutex.synchronize { @h[key] } + end + + # Identity-conditional delete: removes the slot only when its current occupant is the object + # handed in (`equal?`), never by value equality -- a structurally identical live sibling must + # survive a stale context's close (CTX-9). Named for its mechanism, not for CTX-9: phase 6's + # AUTH-19 counter store never calls it, and a general map should not carry a context-shaped + # name. Absence is a no-op, not an error. + # + # @param key [Object] the slot + # @param object [Object] the occupant that must be there for the slot to be cleared + # @return [Boolean] whether the slot was cleared + def delete_if_identical(key, object) + @mutex.synchronize do + next false unless @h.key?(key) && @h[key].equal?(object) + + @h.delete(key) + true + end + end + + # @return [Integer] the number of live entries, at most the cap once inserts quiesce + def size + @mutex.synchronize { @h.size } + end + + private + + # Called under the mutex, after a write. XCUT-14's loop; see the class comment for why it + # runs at most once here. + def drain + @h.shift while @h.size > @cap + end + end + private_constant :BoundedMap +end diff --git a/gems/dexpace-core/lib/dexpace/context.rb b/gems/dexpace-core/lib/dexpace/context.rb new file mode 100644 index 0000000..4d412a6 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/context.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "model" + +module Dexpace + # The module DispatchContext, RequestContext and ExchangeContext all include -- design §5.4's + # "three distinct Data classes sharing a module", read as a module they include and not as a + # namespace containing them (P4-1): Dexpace::Context::Request would shadow phase 1's + # Dexpace::Request for every file inside `module Dexpace; module Context`, which is the hazard + # Dexpace/QualifiedCoreConstant exists for and cannot express a fix for. + # + # CTX-1's terminality -- "the exchange stage is terminal, no method promoting back" -- is + # enforced by ExchangeContext defining no #promote_* method, not by a guard clause here. + # + # #inspect is deliberately NOT overridden, and the consequence is stated rather than + # discovered: `store` is a Data member, so Data's generated #inspect walks it into the map and + # prints one level of every OTHER occupant, each with its own request and response. Ruby's + # recursion guard only elides the second visit to the store itself, so at + # ContextStore::MAX_TRACKED_CONTEXTS any `p ctx`, any assert_equal failure message and any + # string interpolating a context becomes a dump of every in-flight call. Phase 1 shipped no + # #inspect override on Request or Response either, and a redaction-aware rendering is + # OBS-11..OBS-19's and XCUT-19's, which are phase 5's. What 4a owes instead is that no assertion + # in its own suite compares whole contexts where a member comparison would do. + module Context + include Dexpace::Model + + # CTX-9 (identity-conditional eviction), CTX-10 (a promoted intermediate's close is a no-op) + # and CTX-18 (double-close is a well-defined no-op) in one line: every clause of all three is a + # property of ContextStore#release, which this delegates to without a latch (P4-4 -- a frozen + # Data instance cannot carry one; verified on 3.2.11, 3.4.10 and 4.0.6 that a method on a + # frozen Data subclass writing an ivar raises FrozenError). Idempotent through the store: the + # second close finds a different occupant or none. + # + # @return [Boolean] whether this context was the slot's occupant and the slot was cleared + def close + store.release(self) + end + + private + + # The construction validation every flavour's #initialize runs before its own fields and + # before `super`: the two members every flavour carries (SEAM-29's one message form for an + # absent one; CTX-4's key is a non-empty String, so a Symbol is refused rather than silently + # keying a slot no String lookup can find, and an Integer meets the SDK's error rather than + # escaping as a NoMethodError from #empty?), and the store, checked for the two methods a + # context calls on it rather than for its class -- the narrowest duck type + # (api-design/88e6bf12), which is what keeps the suite's fake cheap. Private, so it is no part + # of the NFR-4-locked surface: the design gives Context exactly one public method, #close. + def validate_context!(bundle:, call_key:, store:) + Model.required!("bundle", bundle) + Model.required!("call_key", call_key) + raise InvalidArgumentError, "call_key must be a String" unless call_key.is_a?(String) + raise InvalidArgumentError, "call_key must not be empty" if call_key.empty? + return if store.respond_to?(:set) && store.respond_to?(:release) + + raise InvalidArgumentError, "store must respond to #set and #release" + end + + # CTX-16's two-state rule, in one place for the two flavours that carry a name: nil, or a + # non-empty String. "" is neither state, and a Symbol is not the "schema-defined operation id + # such as 'GetUser'" the requirement describes, so both are refused with the field-named + # message every core model uses for a wrong-typed String member. + def validate_operation_name!(operation_name) + return if operation_name.nil? + unless operation_name.is_a?(String) + raise InvalidArgumentError, "operation_name must be a String" + end + raise InvalidArgumentError, "operation_name must not be empty" if operation_name.empty? + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/context/call_key.rb b/gems/dexpace-core/lib/dexpace/context/call_key.rb new file mode 100644 index 0000000..daac390 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/context/call_key.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../model" + +module Dexpace + # CTX-4's default key generator: a process-wide, monotonically increasing counter appended to a + # 'traceId:spanId' rendering, so a key is debuggable by eye (design §5.4 rejects a bare UUID for + # losing exactly that) and call-unique whatever the prefix is. With Bundle::NONE -- the shared + # singleton every untraced call carries -- the prefix is constant across the whole process, + # which is the condition CTX-15's last clause names, and the suffix is what keeps the key unique + # anyway. + # + # Not public API (P4-3): CTX-4 says outright that "the exact format and the counter mechanism + # are a reference choice, and a port MAY key differently", and NFR-4 locks every public name at + # the first release tag. The public surface is #call_key on a context, a frozen String. A + # private_constant on Dexpace is reachable by a bare name from any `module Dexpace; ...` body + # and from nowhere else (bounded_map.rb, and docs/knowledge/notes/execution-context.md). + # + # One Thread::Mutex, one Integer, incremented under the lock and read nowhere else. A counter on + # a ContextStore instance would mint colliding keys the moment a second store exists, and CTX-6 + # requires distinctness "across the whole process and across all three context flavors", not + # across a store. The mutex is held across the increment and nothing else, and it stays although + # CRuby's GVL would hide its absence: 16 threads x 5000 increments lose no update with OR + # without it on CRuby (design, verified fact 9), which is precisely how an unguarded counter + # ships and then fails on JRuby or TruffleRuby. Ruby's Integer never overflows, so there is no + # wrap handling (verified fact 10). The key is frozen with #freeze, never String#-@: a + # per-call-unique key deduplicates against nothing, so interning pays for an index entry it + # never reuses (verified fact 11), and a frozen String is stored as a Hash key by identity + # where an unfrozen one is copied on every registration (verified fact 12). + module CallKey + @mutex = ::Thread::Mutex.new + @counter = 0 + + # The bundle is checked here as well as in the flavour's #initialize, because a .build mints + # before it constructs and an absent bundle must fail with SEAM-29's message, not with a + # NoMethodError off nil. + # + # @param bundle [Instrumentation::Bundle] the bundle whose two identifiers render the prefix + # @return [String] a frozen, process-unique key of the form "traceId:spanId:n" + def self.mint(bundle) + Model.required!("bundle", bundle) + n = @mutex.synchronize { @counter += 1 } + "#{bundle.trace_id}:#{bundle.span_id}:#{n}".freeze + end + end + private_constant :CallKey +end diff --git a/gems/dexpace-core/lib/dexpace/context/dispatch_context.rb b/gems/dexpace-core/lib/dexpace/context/dispatch_context.rb new file mode 100644 index 0000000..d702138 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/context/dispatch_context.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../context" +require_relative "../context_store" +require_relative "call_key" +require_relative "request_context" + +module Dexpace + # The chain's head, before any request exists: CTX-1, CTX-2, CTX-5, CTX-17. + # + # Every flavour carries `store` as a member so #close needs no argument and no ambient lookup, + # and CTX-9's identity eviction has an unambiguous target. `store` falls through to identity in + # the generated ==, eql? and hash (ContextStore defines neither), which is what two contexts + # sharing one process-wide store want; a pinned call_key therefore restores value equality only + # between contexts built against the same store object. The one Data-generated method it makes + # expensive is #inspect, stated at Dexpace::Context. + # + # `bundle:` is required and never defaulted to Bundle::NONE: CTX-15 requires the no-op bundle to + # be available as the default, which Bundle::NONE is, not that a context silently acquire it -- + # defaulting it would make an untraced chain and a chain whose tracing was dropped by accident + # indistinguishable at the construction site as well as downstream. + class DispatchContext < Data.define(:bundle, :call_key, :store) + include Dexpace::Context + + private_class_method :new + + # CTX-5's off-chain construction, with its explicit-key affordance and its minting default: + # `call_key:` pins a shared key when a caller needs value equality across contexts (and is + # frozen without aliasing the caller's String), and absent it, CallKey mints a call-unique + # one. Construction registers NOTHING (CTX-17): the first store entry a chain ever has is + # installed by the first promotion, below. + # + # @param bundle [Instrumentation::Bundle] the correlation bundle (CTX-14) + # @param call_key [String, nil] an explicit key, or nil to mint one + # @param store [ContextStore] the store the chain registers in; the process-wide one by + # default, which construction only reads + # @return [DispatchContext] + def self.build(bundle:, call_key: nil, store: ContextStore.default) + key = call_key.nil? ? CallKey.mint(bundle) : Model.frozen_string(call_key) + new(bundle: bundle, call_key: key, store: store) + end + + # The shared validation, then Data's own construction. + def initialize(bundle:, call_key:, store:) + validate_context!(bundle: bundle, call_key: call_key, store: store) + + super + end + + # -> RequestContext. Carries forward the SAME bundle object, the SAME call_key and the SAME + # store (CTX-2, CTX-3), adds exactly the one new artefact, introduces operation_name as an + # argument exactly as CTX-2 says ("the dispatch context has no operationName field, so it is + # not 'carried forward'"), and registers the successor with store.set before returning it -- + # the first store entry the chain ever has (CTX-17). The source is untouched. + # + # @param request [Request] the outgoing request that has been assembled + # @param operation_name [String, nil] CTX-16's advisory operation id, or nil + # @return [RequestContext] the successor, already registered + def promote_to_request(request:, operation_name: nil) + ctx = RequestContext.build( + bundle: bundle, call_key: call_key, store: store, + request: request, operation_name: operation_name, + ) + store.set(ctx) + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/context/exchange_context.rb b/gems/dexpace-core/lib/dexpace/context/exchange_context.rb new file mode 100644 index 0000000..5c6e650 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/context/exchange_context.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../context" +require_relative "../context_store" +require_relative "call_key" + +module Dexpace + # The chain's terminus, a response arrived: CTX-1, CTX-2. No #promote_* method anywhere in + # this class -- CTX-1's terminality is the absence, which is the property design §5.4 chose + # three classes to get. Once registered it is what CTX-19 keeps reachable: the Request and the + # Response, whose body can pin a connection, stay in the strong map until #close evicts them or + # the cap does. + class ExchangeContext < Data.define( + :bundle, :call_key, :store, :request, :operation_name, :response, + ) + include Dexpace::Context + + private_class_method :new + + # Off-chain construction (CTX-5), registering nothing (CTX-17). + # + # @param bundle [Instrumentation::Bundle] the correlation bundle (CTX-14) + # @param request [Request] the request that was sent + # @param response [Response] the response that arrived + # @param operation_name [String, nil] CTX-16's advisory operation id, or nil + # @param call_key [String, nil] an explicit key, or nil to mint one + # @param store [ContextStore] the store the chain registers in + # @return [ExchangeContext] + def self.build(bundle:, request:, response:, operation_name: nil, call_key: nil, + store: ContextStore.default) + key = call_key.nil? ? CallKey.mint(bundle) : Model.frozen_string(call_key) + new( + bundle: bundle, call_key: key, store: store, request: request, + operation_name: operation_name.nil? ? nil : Model.frozen_string(operation_name), + response: response, + ) + end + + # The shared validation, the two artefacts, and CTX-16's two-state rule. + def initialize(bundle:, call_key:, store:, request:, operation_name:, response:) + validate_context!(bundle: bundle, call_key: call_key, store: store) + Model.required!("request", request) + Model.required!("response", response) + validate_operation_name!(operation_name) + + super + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/context/request_context.rb b/gems/dexpace-core/lib/dexpace/context/request_context.rb new file mode 100644 index 0000000..8f06cb6 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/context/request_context.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../context" +require_relative "../context_store" +require_relative "call_key" +require_relative "exchange_context" + +module Dexpace + # The chain's middle, an outgoing request assembled: CTX-2, CTX-16. + # + # operation_name is nil or a non-empty frozen String -- CTX-16 gives exactly two states, "a + # schema-defined operation id such as 'GetUser', or absent", and "" is neither. It is advisory + # only: it reaches no request, no dispatch decision and, decisively, not the store key, which + # CallKey.mint derives from the bundle and the counter alone. It is the chain half of SEAM-28's + # postponed operation identifier; phase 5c, Task 4 is the consumer. + class RequestContext < Data.define(:bundle, :call_key, :store, :request, :operation_name) + include Dexpace::Context + + private_class_method :new + + # Off-chain construction (CTX-5), registering nothing (CTX-17). + # + # @param bundle [Instrumentation::Bundle] the correlation bundle (CTX-14) + # @param request [Request] the assembled request + # @param operation_name [String, nil] CTX-16's advisory operation id, or nil + # @param call_key [String, nil] an explicit key, or nil to mint one + # @param store [ContextStore] the store the chain registers in + # @return [RequestContext] + def self.build(bundle:, request:, operation_name: nil, call_key: nil, + store: ContextStore.default) + key = call_key.nil? ? CallKey.mint(bundle) : Model.frozen_string(call_key) + new( + bundle: bundle, call_key: key, store: store, request: request, + operation_name: operation_name.nil? ? nil : Model.frozen_string(operation_name), + ) + end + + # The shared validation, the request, and CTX-16's two-state rule. + def initialize(bundle:, call_key:, store:, request:, operation_name:) + validate_context!(bundle: bundle, call_key: call_key, store: store) + Model.required!("request", request) + validate_operation_name!(operation_name) + + super + end + + # -> ExchangeContext. Carries bundle, call_key, store, request and operation_name forward + # unchanged (CTX-2's "additionally the same request and operationName"), adds the response, + # and calls store.set, which overwrites the same slot (CTX-3). The source is untouched. + # + # @param response [Response] the response that has arrived + # @return [ExchangeContext] the successor, now the slot's occupant + def promote_to_exchange(response:) + ctx = ExchangeContext.build( + bundle: bundle, call_key: call_key, store: store, + request: request, operation_name: operation_name, response: response, + ) + store.set(ctx) + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/context_store.rb b/gems/dexpace-core/lib/dexpace/context_store.rb new file mode 100644 index 0000000..76cf622 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/context_store.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "bounded_map" +require_relative "error/context_conflict_error" + +module Dexpace + # The process-wide registry of in-flight execution contexts, keyed by call key: CTX-7 through + # CTX-13, CTX-18 and CTX-19. + # + # HAS a BoundedMap rather than BEING one, so CTX-8's and CTX-18's two-operation surface is what + # a caller sees and the map's general operations are not part of it. #set and #put take a + # CONTEXT, not a key/value pair, and read #call_key off it: that is the narrowest duck type the + # operation actually uses (api-design/88e6bf12), it makes CTX-3's "all three flavors register + # under the identical store slot" unforgeable at the call site, and it is what lets the suite + # drive every store rule with a two-member fake. + # + # Nothing in core reads a context back out of this store: promotion writes and returns, #close + # conditionally removes and tolerates absence. #[] exists for CTX-18 and for the suite, and + # core calls it nowhere -- which is how CTX-13's "MUST NOT rely on any specific entry + # surviving" is obeyed structurally rather than by care. CTX-19's reachability holds because the + # map is a strong Hash; Dexpace/NoWeakReferences is what keeps it one, and neither the cop nor + # the suite can reach a third-party reimplementation, which CTX-19 addresses and these gates + # cannot. + class ContextStore + # The bound the process-wide store is built with. CTX-11 and XCUT-14 name no number; AUTH-19 + # names 1024 as the default for a bounded store of exactly this shape, and it is the only + # number the specification supplies for one (P4-9). Phase 5a, Task 13 attaches a + # configuration source without changing a signature. + MAX_TRACKED_CONTEXTS = 1024 + + class << self + # The one process-wide instance, assigned at file load into a class-level ivar (the last + # line of this class body). Not a constant: a live store cannot be frozen at assignment, + # which a mutable constant must be. Assigned eagerly rather than memoised with `||=`: that + # is an unsynchronised read-modify-write over shared state (XCUT-11) with allocations + # inside `new`, so two threads reaching #default first could each publish a store, and + # CTX-11's cap and CTX-19's reachability would then hold per store rather than per process. + # Eager assignment is also what keeps CTX-17 inert for a .build that takes this default: + # construction reads the singleton and writes nothing to it. + # + # @return [ContextStore] the process-wide store + attr_reader :default + end + + # @param cap [Integer] the hard bound, a positive Integer; MAX_TRACKED_CONTEXTS unless a + # caller -- a test, or phase 5's configuration -- says otherwise + def initialize(cap: MAX_TRACKED_CONTEXTS) + @map = BoundedMap.new(cap: cap) + end + + # CTX-8's unconditional overwrite: install-or-replace, never raising. Used by both promotions + # and by nothing else in core. + # + # @param context [Context] the occupant, registered under its own #call_key + # @return [Context] `context` + def set(context) + @map.set(context.call_key, context) + context + end + + # CTX-8's reject-on-duplicate insert: install only if absent, failing the loser with an error + # whose message identifies the key. The conflict is detected under the map's mutex and the + # error is raised after it is released. A separate strict-register affordance, as CTX-8 says; + # no promotion uses it. + # + # @param context [Context] the occupant, registered under its own #call_key + # @return [Context] `context`, when this call installed it + # @raise [ContextConflictError] when the slot was already occupied + def put(context) + return context if @map.put(context.call_key, context) + + raise ContextConflictError, context.call_key + end + + # CTX-18's explicit absent result. nil is legitimate here, and is the documented case + # api-design/6ea28c9c reserves: the requirement demands an absent result and forbids raising. + # + # @param call_key [String] the slot + # @return [Context, nil] the current occupant, or nil for an unknown or evicted key + def [](call_key) + @map[call_key] + end + + # CTX-9's identity-conditional eviction, CTX-10's intermediate no-op and CTX-18's unknown-key + # no-op: the slot is cleared only when its current occupant IS `context` (`equal?`), never by + # value equality. Returns whether a slot was cleared, which is how CTX-10 is observable. + # + # @param context [Context] the closing context + # @return [Boolean] whether it was the occupant and the slot was cleared + def release(context) + @map.delete_if_identical(context.call_key, context) + end + + # The only aggregate read; there is no iteration, deliberately (CTX-13). + # + # @return [Integer] the number of registered contexts + def size + @map.size + end + + # Assigned at file load, once, before any thread exists. See .default above. + @default = new + end +end diff --git a/gems/dexpace-core/lib/dexpace/error/context_conflict_error.rb b/gems/dexpace-core/lib/dexpace/error/context_conflict_error.rb new file mode 100644 index 0000000..fdc1838 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/error/context_conflict_error.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../error" + +module Dexpace + # CTX-8's reject-on-duplicate loser: a ContextStore#put lost the race for a call key another + # registration already occupies. + # + # Not Dexpace::InvalidArgumentError, deliberately. The caller passed nothing invalid -- it lost a + # race -- and a caller that cannot tell those two apart cannot retry correctly. The message + # names the key because CTX-8's own words are "an error whose message identifies the key", and + # the key is carried as a member too, so a caller reads it rather than parsing the message. The + # fourth error in phase 2's shape: `< ::StandardError` with the Dexpace::Error marker included. + class ContextConflictError < ::StandardError + include Dexpace::Error + + # @return [String] the call key the losing registration tried to install under + attr_reader :call_key + + # The one-argument shape is what `raise Dexpace::ContextConflictError, key` needs: Ruby hands + # the second argument of `raise` to the class's constructor, so the key travels as itself. + def initialize(call_key) + @call_key = call_key + super("a context is already registered under call key #{call_key.inspect} (CTX-8)") + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/instrumentation/bundle.rb b/gems/dexpace-core/lib/dexpace/instrumentation/bundle.rb new file mode 100644 index 0000000..f3331ce --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/instrumentation/bundle.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../model" +require_relative "trace_id_flavour" +require_relative "no_span" +require_relative "no_tracer" + +module Dexpace + module Instrumentation + # CTX-14's correlation/instrumentation bundle, the one design §8.1 names and roadmap + # cross-phase obligation 1 fixes in phase 4: what lets logs, metrics and spans across the + # dispatch, request and exchange stages correlate into one logical trace. + # + # Nine things CTX-14 says the bundle must EXPOSE; eight are stored members and the ninth, + # validity, is derived (P4-6) -- OBS-26 makes "an all-zero trace/span id MUST be treated as + # invalid" a MUST, so validity is a function of trace_id and span_id and not an independently + # settable fact, and `Bundle.build(trace_id: , span_id: , valid: false)` is not + # representable. The members and their names are NFR-4-locked at the first release tag, and + # the handshake with phase 5 is the design's R3: phase 5 populates through .build or + # NONE.with (both validate), may add METHODS here and to TraceIdFlavour, may give the two + # no-op classes their protocols, and may not rename or remove a member, add one, store + # validity, change the sentinels, replace the flavour with a Symbol or hand this class a + # second NONE. + # + # trace_state is a list of pairs, not a Hash: OBS-26 says "a vendor trace-state list" and W3C + # tracestate is ordered, most recent vendor first, which a Hash would lose. trace_flags stays + # the two-hex-char wire form OBS-26 fixes; a #sampled? predicate is deliberately not shipped + # (CTX-14 does not ask for it, and phase 5 may add it, since adding a method widens). + class Bundle < Data.define( + :trace_id, :span_id, :trace_flags, :trace_state, :flavour, :remote, :span, :tracer_factory, + ) + include Dexpace::Model + + private_class_method :new + + # OBS-26's reserved span-id sentinel: 16 hex zeros. Flavour-independent -- OBS-26 states + # the span-id rule unqualified while OBS-27's flavours scope only the trace id, whose + # sentinel therefore lives on TraceIdFlavour (P4-7). + INVALID_SPAN_ID = ("0" * 16).freeze + + SPAN_ID_PATTERN = Regexp.new("\\A[0-9a-f]{16}\\z", timeout: 1.0) + private_constant :SPAN_ID_PATTERN + + TRACE_FLAGS_PATTERN = Regexp.new("\\A[0-9a-f]{2}\\z", timeout: 1.0) + private_constant :TRACE_FLAGS_PATTERN + + # The validating factory. trace_id:, span_id: and flavour: are required deliberately -- + # defaulting them would make "I forgot to pass a trace id" produce a silently untraced + # bundle, invisible for exactly the reason CTX-15 names: every untraced call's identifiers + # are identical, so nothing downstream can tell an accident from a policy. A caller who + # wants the untraced value names Bundle::NONE. + # + # @param trace_id [String] the trace id, in the flavour's rendering or its sentinel + # @param span_id [String] 16 lowercase hex chars, or INVALID_SPAN_ID + # @param flavour [TraceIdFlavour] the trace-id encoding flavour + # @param trace_flags [String] the two-hex-char W3C flags byte; "00" is unsampled + # @param trace_state [Array] the vendor trace-state list, in order + # @param remote [Boolean] whether the trace context arrived from another service + # @param span [Object] the active span; NO_SPAN when tracing is off + # @param tracer_factory [#tracer] the per-operation tracer factory; NO_TRACER_FACTORY when + # tracing is off + # @return [Bundle] + def self.build(trace_id:, span_id:, flavour:, trace_flags: "00", trace_state: [], + remote: false, span: NO_SPAN, tracer_factory: NO_TRACER_FACTORY) + new( + trace_id: trace_id, span_id: span_id, trace_flags: trace_flags, + trace_state: trace_state, flavour: flavour, remote: remote, + span: span, tracer_factory: tracer_factory, + ) + end + + # Every clause OBS-26 makes a MUST is checked here and nowhere else: `#valid?` only + # compares against the sentinels and would answer true for a malformed span id. Each + # failure names its field (SEAM-29). The checks are split into the two identifiers and + # the two wire fields so the constructor reads as one line per member. + def initialize(trace_id:, span_id:, trace_flags:, trace_state:, flavour:, remote:, span:, + tracer_factory:) + Model.required!("span", span) + Model.required!("tracer_factory", tracer_factory) + validate_identifiers!(flavour, trace_id, span_id) + validate_wire_fields!(trace_flags, trace_state) + + super( + trace_id: Model.frozen_string(trace_id), span_id: Model.frozen_string(span_id), + trace_flags: Model.frozen_string(trace_flags), trace_state: Model.own(trace_state), + flavour: flavour, remote: remote ? true : false, span: span, + tracer_factory: tracer_factory, + ) + end + + # Derived, not stored (P4-6): a function of trace_id and span_id. False for NONE and for + # any bundle carrying either reserved sentinel (OBS-26). + # + # @return [Boolean] + def valid? + flavour.valid_trace_id?(trace_id) && span_id != INVALID_SPAN_ID + end + + # Whether the trace context was propagated in from another service; false for NONE. + # + # @return [Boolean] + def remote? = remote + + # The flavour must be a TraceIdFlavour, the trace id one that flavour renders -- the + # sentinel or a value its pattern accepts -- and the span id OBS-26's 16 lowercase hex + # chars or its reserved sentinel; OBS-27 does not scope the span id, so those two are the + # accepted shapes under every flavour (P4-7). + def validate_identifiers!(flavour, trace_id, span_id) + Model.required!("trace_id", trace_id) + Model.required!("span_id", span_id) + Model.required!("flavour", flavour) + unless flavour.is_a?(TraceIdFlavour) + raise InvalidArgumentError, "flavour must be a TraceIdFlavour" + end + unless flavour.renders?(trace_id) + raise InvalidArgumentError, "trace_id does not match flavour #{flavour.name.inspect}" + end + return if valid_span_id?(span_id) + + raise InvalidArgumentError, "span_id must be 16 lowercase hex chars (OBS-26)" + end + private :validate_identifiers! + + # OBS-26's two wire fields: the flags byte as two lowercase hex chars, and the vendor + # trace-state list as pairs of Strings. + def validate_wire_fields!(trace_flags, trace_state) + Model.required!("trace_flags", trace_flags) + unless trace_flags.is_a?(::String) && TRACE_FLAGS_PATTERN.match?(trace_flags) + raise InvalidArgumentError, "trace_flags must be two lowercase hex chars (OBS-26)" + end + return if trace_state.is_a?(::Array) && trace_state.all? { |pair| string_pair?(pair) } + + raise InvalidArgumentError, "trace_state must be an array of two-element string pairs" + end + private :validate_wire_fields! + + def valid_span_id?(value) + value == INVALID_SPAN_ID || (value.is_a?(::String) && SPAN_ID_PATTERN.match?(value)) + end + private :valid_span_id? + + def string_pair?(value) + value.is_a?(::Array) && value.size == 2 && value.all?(::String) + end + private :string_pair? + + # CTX-15's disabled-tracing default: the shared frozen singleton every untraced call + # carries, with OBS-26's reserved sentinels as its values and the two no-op singletons in + # its slots. CTX-4's call-key derivation stays call-unique across it because the counter, + # not the bundle, supplies the uniqueness (CallKey.mint). Not defaulted into any context + # builder: a caller names it, so an untraced chain is a decision and not an accident. + NONE = build( + trace_id: TraceIdFlavour::NONE.invalid_trace_id, + span_id: INVALID_SPAN_ID, + flavour: TraceIdFlavour::NONE, + ) + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/instrumentation/no_span.rb b/gems/dexpace-core/lib/dexpace/instrumentation/no_span.rb new file mode 100644 index 0000000..66d8088 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/instrumentation/no_span.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +module Dexpace + module Instrumentation + # CTX-15's no-op span, the object behind NO_SPAN. Responds to nothing beyond Object's own + # surface: phase 4 fixes the slot and the identity of the object filling it; OBS-21 and + # OBS-25 fix the protocol, and that is phase 5's (5c, Tasks 3-5), which gives THIS class its + # methods rather than introducing a second no-op span -- the class is a private_constant and + # so not NFR-4-locked, while the object keeps the identity phase 4 published. + class NoSpan; end # rubocop:disable Lint/EmptyClass -- postponed protocol: phase 5c adds it. + private_constant :NoSpan + + # The one shared, frozen no-op span every untraced bundle carries (CTX-14's "an active span", + # CTX-15). One instance, so OBS-25's "MUST NOT allocate per call" is assertable by reference + # identity: `assert_same Dexpace::Instrumentation::NO_SPAN, bundle.span`. Public, where + # BoundedMap is not, because that assertion is a QUALIFIED reference and a qualified reference + # to a private constant raises even from inside Dexpace (design, verified fact 6). + NO_SPAN = NoSpan.new.freeze + end +end diff --git a/gems/dexpace-core/lib/dexpace/instrumentation/no_tracer.rb b/gems/dexpace-core/lib/dexpace/instrumentation/no_tracer.rb new file mode 100644 index 0000000..a872910 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/instrumentation/no_tracer.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +module Dexpace + module Instrumentation + # CTX-20's no-op tracer, the object behind NO_TRACER, returned by NO_TRACER_FACTORY#tracer. + # Responds to nothing beyond Object's own surface for the reason NoSpan does: the tracer + # protocol is OBS-21..OBS-25, phase 5c's, and it lands as methods on this private class. + class NoTracer; end # rubocop:disable Lint/EmptyClass -- postponed protocol: phase 5c adds it. + private_constant :NoTracer + + # The one shared, frozen no-op tracer. Public rather than private for the same reason + # NO_SPAN is: OBS-25's allocation clause is asserted as + # `assert_same Dexpace::Instrumentation::NO_TRACER, factory.tracer`, a qualified reference. + NO_TRACER = NoTracer.new.freeze + + # CTX-20's no-op tracer factory, the class behind NO_TRACER_FACTORY. Not empty: CTX-20's + # embedded MUST ("Its factory method MUST be safe to invoke concurrently from multiple + # threads") forbids that -- a factory with no factory method cannot satisfy a MUST about that + # method. It holds no state, so the concurrency safety is structural. + # + # #tracer's name and its parameter list mirror opentelemetry-api's own + # OpenTelemetry::Trace::TracerProvider#tracer (opentelemetry-api 1.11.0, + # lib/opentelemetry/trace/tracer_provider.rb, fetched from rubygems.org and read 2026-09-08 + # by the plan and again 2026-09-16 at implementation) rather than this repository's own + # keywords-everywhere rule (api-design/1d9e6e0b), because being call-compatible with a + # foreign TracerProvider is this method's entire purpose (P4-8): an application already + # running OpenTelemetry must be able to pass OpenTelemetry.tracer_provider straight into + # Bundle.build(tracer_factory:). The gem keeps the two legacy positional parameters for + # callers written against its older two-argument form while the keyword arguments take + # precedence on the same slot; all five are ignored here. Named parameters, never a `**` + # splat: Dexpace/NoKeywordSplat, because a splat allocates a Hash per call and OBS-25's no-op + # path may allocate nothing. + class NoTracerFactory + # The one method CTX-20 forces into existence. Every argument is part of the mirrored + # signature and deliberately unused; see the class comment and P4-8. + # + # @param deprecated_name [String, nil] the gem's legacy positional instrumentation name + # @param deprecated_version [String, nil] the gem's legacy positional version + # @param name [String, nil] the instrumentation-library name + # @param version [String, nil] the instrumentation-library version + # @param attributes [Hash, nil] instrumentation-scope attributes + # @return [Object] NO_TRACER, always, so selecting the no-op path allocates nothing + # rubocop:disable-next Lint/UnusedMethodArgument + def tracer(deprecated_name = nil, deprecated_version = nil, name: nil, version: nil, + attributes: nil) + NO_TRACER + end + end + private_constant :NoTracerFactory + + # The one shared, frozen no-op tracer factory (CTX-14's "a per-operation tracer factory", + # CTX-15, CTX-20). Its #tracer returns NO_TRACER from every thread, which is OBS-25's + # allocation clause and CTX-20's thread-safety clause in one assertion. + NO_TRACER_FACTORY = NoTracerFactory.new.freeze + end +end diff --git a/gems/dexpace-core/lib/dexpace/instrumentation/trace_id_flavour.rb b/gems/dexpace-core/lib/dexpace/instrumentation/trace_id_flavour.rb new file mode 100644 index 0000000..ecc4828 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/instrumentation/trace_id_flavour.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../model" + +module Dexpace + # The instrumentation subsystem: the correlation bundle every execution context carries and the + # values it is built from. Namespaced because design §8.1 names Dexpace::Instrumentation::Bundle + # itself, and a subsystem the design names with a namespace keeps it (P1-1). + module Instrumentation + # OBS-27's trace-id encoding flavours, and the value CTX-14's "a trace-id encoding flavor" + # slot holds: a frozen Data over a frozen table with an .of factory (type-system/545949a5), + # never a case statement or a bare Symbol -- the per-flavour behaviour, a pattern, a sentinel + # and a range, is data, not code. + # + # The flavour governs the TRACE id only. CTX-14's words are "a trace-id encoding flavor", + # OBS-27's scope is "trace-id generation", and OBS-26 states the span-id rule unqualified, so + # a Datadog-flavoured bundle carries a decimal trace id and a hex span id. That reads oddly + # and it is what the two requirements say together (Bundle::INVALID_SPAN_ID is the one + # span-id sentinel; P4-7). + class TraceIdFlavour < Data.define(:name, :trace_id_pattern, :invalid_trace_id, :max_value) + include Dexpace::Model + + private_class_method :new + + # The validating factory. `max_value` is nil where the pattern is the whole rule. + # + # @param name [Symbol] the flavour's name, what .of resolves + # @param trace_id_pattern [Regexp] the shape a renderable trace id has, anchored + # @param invalid_trace_id [String] this flavour's reserved invalid sentinel + # @param max_value [Integer, nil] the largest integer a rendering may denote + # @return [TraceIdFlavour] + def self.build(name:, trace_id_pattern:, invalid_trace_id:, max_value: nil) + new( + name: name, trace_id_pattern: trace_id_pattern, invalid_trace_id: invalid_trace_id, + max_value: max_value, + ) + end + + # Every member but the range is required (SEAM-29's one message form). + def initialize(name:, trace_id_pattern:, invalid_trace_id:, max_value:) + Model.required!("name", name) + Model.required!("trace_id_pattern", trace_id_pattern) + Model.required!("invalid_trace_id", invalid_trace_id) + + super( + name: name, trace_id_pattern: trace_id_pattern, + invalid_trace_id: Model.frozen_string(invalid_trace_id), max_value: max_value, + ) + end + + # A trace id that identifies a real trace under this flavour: renderable and not the + # sentinel (OBS-26's "an all-zero trace/span id MUST be treated as invalid/no-trace"). + # + # @param trace_id [String] + # @return [Boolean] + def valid_trace_id?(trace_id) + trace_id != invalid_trace_id && renderable?(trace_id) + end + + # A trace id a bundle of this flavour may carry at all: the sentinel, or a renderable + # value. This is what Bundle's own validation asks; the two predicates disagree at exactly + # one input, the sentinel, which is why both exist. + # + # @param trace_id [String] + # @return [Boolean] + def renders?(trace_id) + trace_id == invalid_trace_id || renderable?(trace_id) + end + + # The pattern fixes the SHAPE and max_value fixes the RANGE, because a digit count cannot + # express one: OBS-27's Datadog flavour is "a 64-bit unsigned integer rendered as a decimal + # string", and \A[0-9]{1,20}\z also accepts 99999999999999999999, which is larger than + # 2**64 - 1. The bound is a member rather than a branch so the table stays the whole of the + # per-flavour behaviour; nil means the pattern is the whole rule (both hex flavours). + def renderable?(trace_id) + return false unless trace_id.is_a?(::String) && trace_id_pattern.match?(trace_id) + + bound = max_value + bound.nil? || trace_id.to_i <= bound + end + private :renderable? + + # OBS-27's no-op flavour: "always yields the invalid sentinel". The pattern matches nothing, + # so only OBS-26's 32 hex zeros renders -- a disabled-tracing bundle carries no trace id of + # its own. The sentinel is OBS-26's exact value and not a flavour-specific zero draw, so + # Bundle::NONE carries the pair CTX-15 names verbatim (P4-7). + NONE = build( + name: :none, + trace_id_pattern: Regexp.new("\\A(?!)\\z", timeout: 1.0), + invalid_trace_id: "0" * 32, + ) + + # OBS-27's W3C flavour: a 128-bit value rendered as 32 lowercase hex chars. Lowercase-only, + # and an uppercase input is rejected rather than folded (OBS-26). + W3C = build( + name: :w3c, + trace_id_pattern: Regexp.new("\\A[0-9a-f]{32}\\z", timeout: 1.0), + invalid_trace_id: "0" * 32, + ) + + # OBS-27's Datadog flavour: a 64-bit unsigned integer rendered as a decimal string. Its own + # zero draw is "0" -- the one OBS-27 says "MUST be coerced to a non-zero value" -- and is + # distinct from OBS-26's 32-hex-zero sentinel (P4-7). The bound is 2**64 - 1, spelled as a + # shift because rbs types Integer#** as Numeric and strict Steep refuses it for an Integer?. + DATADOG = build( + name: :datadog, + trace_id_pattern: Regexp.new("\\A[0-9]{1,20}\\z", timeout: 1.0), + invalid_trace_id: "0", + max_value: (1 << 64) - 1, + ) + + # The closed set, in OBS-27's order. Phase 5 may add a method to the flavour; it may not + # replace the type with a Symbol (roadmap obligation 1). + ALL = [NONE, W3C, DATADOG].freeze + + # Resolves a name against the closed set. Raises on an unrecognised one -- the + # Protocol.parse behaviour, not the deliberately total Status.of one, because a flavour is + # a closed set and a status code is not. + # + # @param name [Symbol] `:none`, `:w3c` or `:datadog` + # @return [TraceIdFlavour] the shared frozen instance + # @raise [InvalidArgumentError] on any other name + def self.of(name) + ALL.find { |flavour| flavour.name == name } || + raise(InvalidArgumentError, "unrecognised trace-id flavour: #{name.inspect}") + end + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/bounded_map.rbs b/gems/dexpace-core/sig/dexpace/bounded_map.rbs new file mode 100644 index 0000000..1dee4ed --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/bounded_map.rbs @@ -0,0 +1,21 @@ +# Dexpace::BoundedMap is a private_constant and not public API (design P4-3): this declaration +# exists because the strict `core` Steep target checks every file under lib/ and needs the class +# declared to type ContextStore's call sites, exactly as hooks.rbs does for Dexpace::Hooks. RBS +# has no visibility for a constant, so the privacy lives in lib/dexpace/bounded_map.rb alone, and +# the class takes no surface-manifest row (Module#constants excludes it) and no test/ mirror. +module Dexpace + class BoundedMap + @cap: Integer + @h: Hash[untyped, untyped] + @mutex: Thread::Mutex + + def initialize: (cap: Integer) -> void + def set: (untyped key, untyped value) -> untyped + def put: (untyped key, untyped value) -> bool + def []: (untyped key) -> untyped + def delete_if_identical: (untyped key, untyped object) -> bool + def size: () -> Integer + + private def drain: () -> void + end +end diff --git a/gems/dexpace-core/sig/dexpace/context.rbs b/gems/dexpace-core/sig/dexpace/context.rbs new file mode 100644 index 0000000..315910f --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/context.rbs @@ -0,0 +1,21 @@ +module Dexpace + # What an includer of Context must be beyond a Model: a Data whose members include the store + # #close releases through and the key it is registered under. The store is `untyped` because + # #validate_context! accepts any object answering #set and #release -- the narrowest duck type, + # api-design/88e6bf12 -- and strict Steep refuses respond_to? on an interface type (phase 3a). + interface _ContextHost + include _ModelInstance + + def call_key: () -> String + def store: () -> untyped + end + + module Context : _ContextHost + include Model + + def close: () -> bool + + private def validate_context!: (bundle: untyped, call_key: String, store: untyped) -> nil + private def validate_operation_name!: (String? operation_name) -> nil + end +end diff --git a/gems/dexpace-core/sig/dexpace/context/call_key.rbs b/gems/dexpace-core/sig/dexpace/context/call_key.rbs new file mode 100644 index 0000000..e2f66c2 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/context/call_key.rbs @@ -0,0 +1,14 @@ +# Dexpace::CallKey is a private_constant and not public API (design P4-3): this declaration +# exists because the strict `core` Steep target checks every file under lib/ and needs the module +# and its one method declared to type the three .build call sites, exactly as hooks.rbs does for +# Dexpace::Hooks. RBS has no visibility for a constant, so the privacy lives in +# lib/dexpace/context/call_key.rb alone, and the module takes no surface-manifest row and no +# test/ mirror -- its behaviour is asserted at its call sites (dispatch_context_test.rb). +module Dexpace + module CallKey + self.@mutex: Thread::Mutex + self.@counter: Integer + + def self.mint: (Instrumentation::Bundle bundle) -> String + end +end diff --git a/gems/dexpace-core/sig/dexpace/context/dispatch_context.rbs b/gems/dexpace-core/sig/dexpace/context/dispatch_context.rbs new file mode 100644 index 0000000..dc62215 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/context/dispatch_context.rbs @@ -0,0 +1,22 @@ +module Dexpace + class DispatchContext < Data + include Context + + attr_reader bundle: Instrumentation::Bundle + attr_reader call_key: String + # untyped rather than ContextStore: #validate_context! accepts any object answering #set and + # #release (the narrowest duck type), and the suite's FakeStore relies on that. + attr_reader store: untyped + + private def self.new: ( + bundle: Instrumentation::Bundle, call_key: String, store: untyped, + ) -> instance + def initialize: (bundle: Instrumentation::Bundle, call_key: String, store: untyped) -> void + + def self.build: ( + bundle: Instrumentation::Bundle, ?call_key: String?, ?store: untyped, + ) -> DispatchContext + + def promote_to_request: (request: Dexpace::Request, ?operation_name: String?) -> RequestContext + end +end diff --git a/gems/dexpace-core/sig/dexpace/context/exchange_context.rbs b/gems/dexpace-core/sig/dexpace/context/exchange_context.rbs new file mode 100644 index 0000000..c905d81 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/context/exchange_context.rbs @@ -0,0 +1,26 @@ +module Dexpace + class ExchangeContext < Data + include Context + + attr_reader bundle: Instrumentation::Bundle + attr_reader call_key: String + attr_reader store: untyped + attr_reader request: Dexpace::Request + attr_reader operation_name: String? + attr_reader response: Dexpace::Response + + private def self.new: ( + bundle: Instrumentation::Bundle, call_key: String, store: untyped, + request: Dexpace::Request, operation_name: String?, response: Dexpace::Response, + ) -> instance + def initialize: ( + bundle: Instrumentation::Bundle, call_key: String, store: untyped, + request: Dexpace::Request, operation_name: String?, response: Dexpace::Response, + ) -> void + + def self.build: ( + bundle: Instrumentation::Bundle, request: Dexpace::Request, response: Dexpace::Response, + ?operation_name: String?, ?call_key: String?, ?store: untyped, + ) -> ExchangeContext + end +end diff --git a/gems/dexpace-core/sig/dexpace/context/request_context.rbs b/gems/dexpace-core/sig/dexpace/context/request_context.rbs new file mode 100644 index 0000000..73a175e --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/context/request_context.rbs @@ -0,0 +1,27 @@ +module Dexpace + class RequestContext < Data + include Context + + attr_reader bundle: Instrumentation::Bundle + attr_reader call_key: String + attr_reader store: untyped + attr_reader request: Dexpace::Request + attr_reader operation_name: String? + + private def self.new: ( + bundle: Instrumentation::Bundle, call_key: String, store: untyped, + request: Dexpace::Request, operation_name: String?, + ) -> instance + def initialize: ( + bundle: Instrumentation::Bundle, call_key: String, store: untyped, + request: Dexpace::Request, operation_name: String?, + ) -> void + + def self.build: ( + bundle: Instrumentation::Bundle, request: Dexpace::Request, + ?operation_name: String?, ?call_key: String?, ?store: untyped, + ) -> RequestContext + + def promote_to_exchange: (response: Dexpace::Response) -> ExchangeContext + end +end diff --git a/gems/dexpace-core/sig/dexpace/context_store.rbs b/gems/dexpace-core/sig/dexpace/context_store.rbs new file mode 100644 index 0000000..c741aea --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/context_store.rbs @@ -0,0 +1,17 @@ +module Dexpace + class ContextStore + MAX_TRACKED_CONTEXTS: Integer + + self.@default: ContextStore + @map: BoundedMap + + def self.default: () -> ContextStore + + def initialize: (?cap: Integer) -> void + def set: (Context context) -> Context + def put: (Context context) -> Context + def []: (String call_key) -> Context? + def release: (Context context) -> bool + def size: () -> Integer + end +end diff --git a/gems/dexpace-core/sig/dexpace/error/context_conflict_error.rbs b/gems/dexpace-core/sig/dexpace/error/context_conflict_error.rbs new file mode 100644 index 0000000..0d61dc8 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/error/context_conflict_error.rbs @@ -0,0 +1,9 @@ +module Dexpace + class ContextConflictError < ::StandardError + include Dexpace::Error + + attr_reader call_key: String + + def initialize: (String call_key) -> void + end +end diff --git a/gems/dexpace-core/sig/dexpace/instrumentation/bundle.rbs b/gems/dexpace-core/sig/dexpace/instrumentation/bundle.rbs new file mode 100644 index 0000000..e60ebbc --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/instrumentation/bundle.rbs @@ -0,0 +1,47 @@ +module Dexpace + module Instrumentation + class Bundle < Data + include Model + + INVALID_SPAN_ID: String + SPAN_ID_PATTERN: Regexp + TRACE_FLAGS_PATTERN: Regexp + + NONE: Bundle + + attr_reader trace_id: String + attr_reader span_id: String + attr_reader trace_flags: String + attr_reader trace_state: Array[[String, String]] + attr_reader flavour: TraceIdFlavour + attr_reader remote: bool + attr_reader span: _Span + attr_reader tracer_factory: _TracerFactory + + private def self.new: ( + trace_id: String, span_id: String, trace_flags: String, + trace_state: Array[[String, String]], flavour: TraceIdFlavour, remote: bool, + span: _Span, tracer_factory: _TracerFactory, + ) -> instance + def initialize: ( + trace_id: String, span_id: String, trace_flags: String, + trace_state: Array[[String, String]], flavour: TraceIdFlavour, remote: bool, + span: _Span, tracer_factory: _TracerFactory, + ) -> void + + def self.build: ( + trace_id: String, span_id: String, flavour: TraceIdFlavour, + ?trace_flags: String, ?trace_state: Array[[String, String]], ?remote: bool, + ?span: _Span, ?tracer_factory: _TracerFactory, + ) -> Bundle + + def valid?: () -> bool + def remote?: () -> bool + + private def validate_identifiers!: (TraceIdFlavour flavour, String trace_id, String span_id) -> void + private def validate_wire_fields!: (String trace_flags, Array[[String, String]] trace_state) -> void + private def valid_span_id?: (String value) -> bool + private def string_pair?: (untyped value) -> bool + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/instrumentation/no_span.rbs b/gems/dexpace-core/sig/dexpace/instrumentation/no_span.rbs new file mode 100644 index 0000000..feb9f6f --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/instrumentation/no_span.rbs @@ -0,0 +1,15 @@ +module Dexpace + module Instrumentation + # Empty on purpose: phase 4 fixes the slot, phase 5c fixes the protocol, and an empty + # interface says so in the type system rather than in a comment. + interface _Span + end + + # A private_constant with no visibility in RBS; declared because the strict `core` Steep + # target types NO_SPAN's construction. The privacy lives in lib/dexpace/instrumentation/no_span.rb. + class NoSpan + end + + NO_SPAN: _Span + end +end diff --git a/gems/dexpace-core/sig/dexpace/instrumentation/no_tracer.rbs b/gems/dexpace-core/sig/dexpace/instrumentation/no_tracer.rbs new file mode 100644 index 0000000..1490a33 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/instrumentation/no_tracer.rbs @@ -0,0 +1,31 @@ +module Dexpace + module Instrumentation + # Empty on purpose, like _Span: phase 5c fixes the tracer protocol. + interface _Tracer + end + + # The one method CTX-20 forces, in opentelemetry-api 1.11.0's own shape (P4-8). + interface _TracerFactory + def tracer: ( + ?String? deprecated_name, ?String? deprecated_version, + ?name: String?, ?version: String?, ?attributes: Hash[String, untyped]?, + ) -> _Tracer + end + + # Two private_constants with no visibility in RBS; declared because the strict `core` Steep + # target types the two singletons' construction. The privacy lives in + # lib/dexpace/instrumentation/no_tracer.rb. + class NoTracer + end + + class NoTracerFactory + def tracer: ( + ?String? deprecated_name, ?String? deprecated_version, + ?name: String?, ?version: String?, ?attributes: Hash[String, untyped]?, + ) -> _Tracer + end + + NO_TRACER: _Tracer + NO_TRACER_FACTORY: _TracerFactory + end +end diff --git a/gems/dexpace-core/sig/dexpace/instrumentation/trace_id_flavour.rbs b/gems/dexpace-core/sig/dexpace/instrumentation/trace_id_flavour.rbs new file mode 100644 index 0000000..7da12fd --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/instrumentation/trace_id_flavour.rbs @@ -0,0 +1,33 @@ +module Dexpace + module Instrumentation + class TraceIdFlavour < Data + include Model + + NONE: TraceIdFlavour + W3C: TraceIdFlavour + DATADOG: TraceIdFlavour + ALL: Array[TraceIdFlavour] + + attr_reader name: Symbol + attr_reader trace_id_pattern: Regexp + attr_reader invalid_trace_id: String + attr_reader max_value: Integer? + + private def self.new: ( + name: Symbol, trace_id_pattern: Regexp, invalid_trace_id: String, max_value: Integer?, + ) -> instance + def initialize: ( + name: Symbol, trace_id_pattern: Regexp, invalid_trace_id: String, max_value: Integer?, + ) -> void + + def self.build: ( + name: Symbol, trace_id_pattern: Regexp, invalid_trace_id: String, ?max_value: Integer?, + ) -> TraceIdFlavour + def self.of: (Symbol name) -> TraceIdFlavour + + def valid_trace_id?: (String trace_id) -> bool + def renders?: (String trace_id) -> bool + private def renderable?: (String trace_id) -> bool + end + end +end diff --git a/gems/dexpace-core/test/dexpace/context/dispatch_context_test.rb b/gems/dexpace-core/test/dexpace/context/dispatch_context_test.rb new file mode 100644 index 0000000..e09a970 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/context/dispatch_context_test.rb @@ -0,0 +1,270 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace" + +# CTX-1 (the head of the chain), CTX-2, CTX-3, CTX-4, CTX-5, CTX-6, CTX-7, CTX-15, CTX-17. +# +# One class per behaviour group, because Metrics/ClassLength caps a class at 100 lines: the key +# and its equality here, then Promotion and Construction below. +class DexpaceDispatchContextTest < DexpaceTestCase + # The untraced bundle every case in this file builds from (CTX-15). + NONE = Dexpace::Instrumentation::Bundle::NONE + + test "CTX-5/CTX-6: two default-constructed contexts from the same bundle are NOT equal" do + a = Dexpace::DispatchContext.build(bundle: NONE) + b = Dexpace::DispatchContext.build(bundle: NONE) + + refute_equal(a, b) + refute_operator(a, :eql?, b) + refute_equal(a.hash, b.hash) + assert_equal(2, { a => 1, b => 2 }.size) + end + + test "CTX-5: an explicit shared call_key restores value equality" do + a = Dexpace::DispatchContext.build(bundle: NONE, call_key: "shared") + b = Dexpace::DispatchContext.build(bundle: NONE, call_key: "shared") + + assert_equal(a, b) + assert_operator(a, :eql?, b) + assert_equal(a.hash, b.hash) + assert_equal(1, { a => 1, b => 2 }.size) + end + + # This is the test that would fail if the key were ever derived from the bundle: the two + # contexts share the very same Bundle::NONE object (asserted equal?, not merely ==), so every + # bundle field is identical, and the keys still differ. + test "CTX-15: minting from the SAME Bundle::NONE object still yields distinct keys" do + bundle = NONE + a = Dexpace::DispatchContext.build(bundle: bundle) + b = Dexpace::DispatchContext.build(bundle: bundle) + + assert_same(bundle, a.bundle) + assert_same(bundle, b.bundle) + refute_equal(a.call_key, b.call_key) + end + + # CTX-4's reference rendering, 'traceId:spanId:n': a rendered prefix plus a counter, so a key is + # debuggable by eye, and the counter is what makes it call-unique when the prefix is not. + test "CTX-4: a minted key is the bundle's trace id, its span id and a counter" do + ctx = Dexpace::DispatchContext.build(bundle: NONE) + prefix = "#{NONE.trace_id}:#{NONE.span_id}:" + + assert_operator(ctx.call_key, :start_with?, prefix) + assert_match(/\A#{Regexp.escape(prefix)}[1-9][0-9]*\z/, ctx.call_key) + end + + test "CTX-4: successive minted keys carry a strictly increasing counter" do + counters = Array.new(5) do + Dexpace::DispatchContext.build(bundle: NONE).call_key.split(":").last.to_i + end + + assert_equal(counters, counters.sort) + assert_equal(5, counters.uniq.size) + end + + # One counter serves all three flavours: CTX-6 requires distinctness "across the whole + # process and across all three context flavors", not across a store. Distinctness alone is + # not the discriminating assertion -- a counter per flavour yields three distinct keys too, + # whenever the shared counter has already moved past the per-flavour one -- so the suffix is + # asserted to increase strictly ACROSS flavours in build order, which only one counter does. + test "CTX-6: three flavours built from one untraced bundle draw from one counter" do + bundle = NONE + keys = Array.new(3).flat_map do + [ + Dexpace::DispatchContext.build(bundle: bundle).call_key, + Dexpace::RequestContext.build(bundle: bundle, request: :req).call_key, + Dexpace::ExchangeContext.build(bundle: bundle, request: :req, response: :resp).call_key, + ] + end + counters = keys.map { |key| key.split(":").last.to_i } + + assert_equal(9, keys.uniq.size) + assert_equal(counters.sort, counters) + assert_equal(counters.first + 8, counters.last) + end + + # CTX-6 across stores: the counter is process-wide, not per store, so two stores built for two + # tests cannot mint a colliding key. + test "CTX-6: contexts built against two different stores never share a minted key" do + keys = Array.new(2) do + Dexpace::DispatchContext.build(bundle: NONE, store: Dexpace::ContextStore.new(cap: 8)) + .call_key + end + + assert_equal(2, keys.uniq.size) + end + + # The mint is exact under contention (design, verified fact 9): 16 threads minting 250 keys + # each yield 4000 distinct keys. The mutex is what makes that true off CRuby. + test "CTX-4/CTX-6: 16 threads minting concurrently never collide" do + keys = ::Thread::Queue.new + threads = Array.new(16) do + ::Thread.new { 250.times { keys << Dexpace::DispatchContext.build(bundle: NONE).call_key } } + end + threads.each(&:join) + minted = Array.new(4000) { keys.pop } + + assert_equal(4000, minted.uniq.size) + end + + # CTX-2, CTX-3, CTX-7, CTX-17: what a promotion does and what construction does not. + class PromotionTest < DexpaceTestCase + test "CTX-17: constructing a dispatch context registers nothing" do + store = Dexpace::ContextStore.new(cap: 8) + ctx = Dexpace::DispatchContext.build(bundle: NONE, store: store) + + assert_equal(0, store.size) + assert_nil(store[ctx.call_key]) + end + + test "CTX-17: a dispatch context never promoted closes as a harmless no-op" do + store = Dexpace::ContextStore.new(cap: 8) + ctx = Dexpace::DispatchContext.build(bundle: NONE, store: store) + + refute(ctx.close) + assert_equal(0, store.size) + end + + # CTX-2: assert_same, never assert_equal -- "the same InstrumentationContext reference", and + # value equality would pass against an implementation that rebuilt the bundle. The source is + # asserted unchanged in the same test, which is the requirement's own conformance step. + test "CTX-2: promotion is additive, non-mutating, and carries members forward by identity" do + store = Dexpace::ContextStore.new(cap: 8) + source = Dexpace::DispatchContext.build(bundle: NONE, store: store) + request = :the_request + key_before = source.call_key + + promoted = source.promote_to_request(request: request) + + assert_instance_of(Dexpace::RequestContext, promoted) + assert_same(source.bundle, promoted.bundle) + assert_same(source.call_key, promoted.call_key) + assert_same(source.store, promoted.store) + assert_same(request, promoted.request) + assert_nil(promoted.operation_name) + refute_respond_to(source, :request) + assert_same(key_before, source.call_key) + assert_same(NONE, source.bundle) + end + + test "CTX-2: the operation name is an argument to the dispatch->request promotion" do + store = Dexpace::ContextStore.new(cap: 8) + source = Dexpace::DispatchContext.build(bundle: NONE, store: store) + + promoted = source.promote_to_request(request: :req, operation_name: "GetUser") + + assert_equal("GetUser", promoted.operation_name) + refute_respond_to(source, :operation_name) + end + + test "CTX-17: the first promotion is the first store entry the chain ever has" do + store = Dexpace::ContextStore.new(cap: 8) + source = Dexpace::DispatchContext.build(bundle: NONE, store: store) + + assert_equal(0, store.size) + promoted = source.promote_to_request(request: :req) + + assert_equal(1, store.size) + assert_same(promoted, store[source.call_key]) + end + + # CTX-7's first clause -- "Contexts MUST be immutable and safe to share across threads + # without external synchronization" -- which Data supplies and which nothing else asserts. + test "CTX-7: every context flavour is frozen on construction" do + store = Dexpace::ContextStore.new(cap: 8) + dispatch = Dexpace::DispatchContext.build(bundle: NONE, store: store) + request = dispatch.promote_to_request(request: :req) + + assert_predicate(dispatch, :frozen?) + assert_predicate(request, :frozen?) + assert_predicate(request.promote_to_exchange(response: :resp), :frozen?) + end + + test "there is no reverse promotion: DispatchContext has no #promote_to_dispatch" do + refute_respond_to( + Dexpace::DispatchContext.build(bundle: NONE), :promote_to_dispatch, + ) + end + + # #with is not a promotion and cannot be used as one: it reaches only the members its own + # flavour declares, so #promote_to_request is the only way to add a request, which is what + # keeps CTX-17's registration on the promotion path and off the copy path. + test "#with cannot promote: an unknown member is refused, and a derivation keeps the key" do + store = Dexpace::ContextStore.new(cap: 8) + ctx = Dexpace::DispatchContext.build(bundle: NONE, store: store) + + assert_raises(ArgumentError) { ctx.with(request: :req) } + derived = ctx.with(bundle: NONE) + + assert_same(ctx.call_key, derived.call_key) + assert_equal(ctx, derived) + assert_equal(0, store.size) + end + end + + # CTX-4 (the key's frozenness), CTX-5 (the pinned key), and the construction pattern. + class ConstructionTest < DexpaceTestCase + # CTX-4's key is a frozen String on both paths: minted, and pinned by a caller. Verified fact + # 12 -- a frozen String is stored as a Hash key by identity, an unfrozen one is copied and + # frozen on every registration -- so this is correctness and one fewer allocation. + test "CTX-4: the call key is a frozen String whether it is minted or pinned" do + pinned = +"shared" + + assert_predicate(Dexpace::DispatchContext.build(bundle: NONE).call_key, :frozen?) + + ctx = Dexpace::DispatchContext.build(bundle: NONE, call_key: pinned) + + assert_predicate(ctx.call_key, :frozen?) + refute_same(pinned, ctx.call_key) + pinned << "-mutated" + + assert_equal("shared", ctx.call_key) + end + + test "bundle: is required with SEAM-29's message, on the minting path too" do + error = assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::DispatchContext.build(bundle: nil) + end + + assert_equal("bundle is required", error.message) + error = assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::DispatchContext.build(bundle: nil, call_key: "pinned") + end + assert_equal("bundle is required", error.message) + end + + # The .build path hands a frozen Symbol or Integer through Model.frozen_string untouched, so + # the refusal has to come from the shared validation -- on .build and on #with alike. + test "CTX-4: a pinned key that is not a String is refused, on .build and on #with" do + [:sym, 5].each do |key| + error = assert_raises(Dexpace::InvalidArgumentError, key.inspect) do + Dexpace::DispatchContext.build(bundle: NONE, call_key: key) + end + + assert_equal("call_key must be a String", error.message, key.inspect) + end + error = assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::DispatchContext.build(bundle: NONE, call_key: "pinned").with(call_key: :sym) + end + + assert_equal("call_key must be a String", error.message) + end + + test "an empty pinned key and a store without #set/#release are refused" do + assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::DispatchContext.build(bundle: NONE, call_key: "") + end + assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::DispatchContext.build(bundle: NONE, store: Object.new) + end + end + + test "the construction pattern: new is private and .build is the one way in" do + refute_respond_to(Dexpace::DispatchContext, :new) + assert_includes(Dexpace::DispatchContext.ancestors, Dexpace::Context) + assert_equal(%i[bundle call_key store], Dexpace::DispatchContext.members) + end + end +end diff --git a/gems/dexpace-core/test/dexpace/context/exchange_context_test.rb b/gems/dexpace-core/test/dexpace/context/exchange_context_test.rb new file mode 100644 index 0000000..c11781f --- /dev/null +++ b/gems/dexpace-core/test/dexpace/context/exchange_context_test.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace" + +# CTX-1: the exchange stage is terminal. Conformance: the exchange type exposes no method +# promoting back. CTX-18 and CTX-19 from the chain's side: a real Response reaches the store and +# stays reachable until the close that evicts it. +class DexpaceExchangeContextTest < DexpaceTestCase + # The untraced bundle every case in this file builds from (CTX-15). + NONE = Dexpace::Instrumentation::Bundle::NONE + + test "CTX-1: ExchangeContext defines no promotion method at all" do + ctx = Dexpace::ExchangeContext.build( + bundle: NONE, request: :req, response: :resp, + ) + + refute_respond_to(ctx, :promote_to_request) + refute_respond_to(ctx, :promote_to_exchange) + refute_respond_to(ctx, :promote_to_dispatch) + assert_empty(Dexpace::ExchangeContext.public_instance_methods(false).grep(/\Apromote_to_/)) + assert_empty(Dexpace::ExchangeContext.public_instance_methods.grep(/\Apromote/)) + end + + test "an empty operation_name is rejected" do + assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::ExchangeContext.build( + bundle: NONE, request: :req, response: :resp, + operation_name: "", + ) + end + end + + test "a non-String operation_name is rejected with the field-named message" do + [:GetUser, 7].each do |name| + error = assert_raises(Dexpace::InvalidArgumentError, name.inspect) do + Dexpace::ExchangeContext.build( + bundle: NONE, request: :req, response: :resp, + operation_name: name, + ) + end + + assert_equal("operation_name must be a String", error.message, name.inspect) + end + end + + test "request: and response: are required, with SEAM-29's one message form" do + error = assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::ExchangeContext.build(bundle: NONE, request: :req, response: nil) + end + + assert_equal("response is required", error.message) + error = assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::ExchangeContext.build(bundle: NONE, request: nil, response: :resp) + end + assert_equal("request is required", error.message) + end + + test "CTX-18: #close on an exchange context evicts it when it is the current occupant" do + store = Dexpace::ContextStore.new(cap: 8) + dispatch = Dexpace::DispatchContext.build(bundle: NONE, store: store) + exchange = dispatch.promote_to_request(request: :req).promote_to_exchange(response: :resp) + + assert(exchange.close) + assert_nil(store[dispatch.call_key]) + + refute(exchange.close) + end + + # Builds a real phase-1 Request and a real phase-3b Response, promotes a chain over them into + # `store`, and returns only the key: the two objects are then reachable from the store alone. + def register_real_exchange(store) + request_builder = Dexpace::Request.builder + request_builder.url = "https://example.test/pets" + request = request_builder.build + response_builder = Dexpace::Response.builder + response_builder.request = request + response_builder.protocol = Dexpace::Protocol::HTTP_1_1 + response_builder.status = 200 + response_builder.body = Dexpace::ResponseBody.new( + source: Dexpace::IO::BufferedSource.of_bytes("ok"), media_type: nil, + ) + Dexpace::DispatchContext.build(bundle: NONE, store: store) + .promote_to_request(request: request, operation_name: "ListPets") + .promote_to_exchange(response: response_builder.build) + .call_key + end + + # CTX-19's rationale, end to end over phase 1's and 3b's real types: the registered exchange + # context keeps its Request+Response graph -- a body included -- reachable until it is closed. + test "CTX-19: a registered exchange context keeps a real Request and Response reachable" do + store = Dexpace::ContextStore.new(cap: 8) + key = register_real_exchange(store) + + 3.times { GC.start } + + found = store[key] + + refute_nil(found) + assert_equal("https://example.test/pets", found.request.url.to_s) + assert_equal("ok", found.response.body_string) + assert_equal("ListPets", found.operation_name) + assert(found.close) + assert_nil(store[key]) + end + + test "CTX-17: off-chain construction of an exchange context registers nothing" do + store = Dexpace::ContextStore.new(cap: 8) + ctx = Dexpace::ExchangeContext.build( + bundle: NONE, request: :req, response: :resp, store: store, + ) + + assert_equal(0, store.size) + refute(ctx.close) + end + + test "the construction pattern: new is private and the members are the design's" do + refute_respond_to(Dexpace::ExchangeContext, :new) + assert_equal( + %i[bundle call_key store request operation_name response], + Dexpace::ExchangeContext.members, + ) + end +end diff --git a/gems/dexpace-core/test/dexpace/context/request_context_test.rb b/gems/dexpace-core/test/dexpace/context/request_context_test.rb new file mode 100644 index 0000000..1c5a349 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/context/request_context_test.rb @@ -0,0 +1,191 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace" + +# CTX-2 (the request -> exchange half), CTX-3, CTX-10, CTX-16, CTX-17. +# +# One class per behaviour group, because Metrics/ClassLength caps a class at 100 lines: the +# promotion here, CTX-16's operation name and the construction rules below. +class DexpaceRequestContextTest < DexpaceTestCase + # The untraced bundle every case in this file builds from (CTX-15). + NONE = Dexpace::Instrumentation::Bundle::NONE + + test "CTX-2: request -> exchange carries every source member forward by identity" do + store = Dexpace::ContextStore.new(cap: 8) + request = :the_request + source = Dexpace::RequestContext.build( + bundle: NONE, request: request, + operation_name: "GetUser", store: store, + ) + response = :the_response + + promoted = source.promote_to_exchange(response: response) + + assert_instance_of(Dexpace::ExchangeContext, promoted) + assert_same(source.bundle, promoted.bundle) + assert_same(source.call_key, promoted.call_key) + assert_same(source.store, promoted.store) + assert_same(source.request, promoted.request) + assert_same(source.operation_name, promoted.operation_name) + assert_same(response, promoted.response) + refute_respond_to(source, :response) + end + + test "CTX-3: the promotion overwrites the same store slot" do + store = Dexpace::ContextStore.new(cap: 8) + dispatch = Dexpace::DispatchContext.build(bundle: NONE, store: store) + request_ctx = dispatch.promote_to_request(request: :req) + + assert_equal(1, store.size) + assert_same(request_ctx, store[dispatch.call_key]) + + exchange_ctx = request_ctx.promote_to_exchange(response: :resp) + + assert_equal(1, store.size) + assert_same(exchange_ctx, store[dispatch.call_key]) + assert_same(dispatch.call_key, exchange_ctx.call_key) + end + + # CTX-10 from the chain's side: the intermediate was promoted, so its close is a no-op and the + # successor keeps the slot. + test "CTX-10: closing a promoted request context is a no-op; the exchange keeps the slot" do + store = Dexpace::ContextStore.new(cap: 8) + request_ctx = Dexpace::DispatchContext.build(bundle: NONE, store: store) + .promote_to_request(request: :req) + exchange_ctx = request_ctx.promote_to_exchange(response: :resp) + + refute(request_ctx.close) + assert_same(exchange_ctx, store[request_ctx.call_key]) + assert(exchange_ctx.close) + assert_nil(store[request_ctx.call_key]) + end + + test "there is no reverse promotion: RequestContext has no #promote_to_dispatch" do + refute_respond_to( + Dexpace::RequestContext.build(bundle: NONE, request: :req), + :promote_to_dispatch, + ) + end + + # CTX-16: an optional operation name, carried forward unchanged, advisory only. + class OperationNameTest < DexpaceTestCase + # CTX-16's advisory rule, asserted as a negative: the operation name influences none of the + # three things it is forbidden from influencing. A positive test that the name is carried + # forward proves nothing about those three. + test "CTX-16: operation_name changes neither the call key, the request, nor the slot" do + store = Dexpace::ContextStore.new(cap: 8) + request = :shared_request + dispatch_a = Dexpace::DispatchContext.build( + bundle: NONE, call_key: "shared", store: store, + ) + dispatch_b = Dexpace::DispatchContext.build( + bundle: NONE, call_key: "shared", store: store, + ) + + named = dispatch_a.promote_to_request(request: request, operation_name: "GetUser") + unnamed = dispatch_b.promote_to_request(request: request) + + assert_equal(named.call_key, unnamed.call_key) + assert_same(named.request, unnamed.request) + assert_same(unnamed, store["shared"]) + assert_equal(1, store.size) + end + + test "CTX-16: operation_name is carried forward unchanged across promotion" do + store = Dexpace::ContextStore.new(cap: 8) + source = Dexpace::RequestContext.build( + bundle: NONE, request: :req, + operation_name: "GetUser", store: store, + ) + + promoted = source.promote_to_exchange(response: :resp) + + assert_same(source.operation_name, promoted.operation_name) + end + + test "operation_name may be absent" do + ctx = Dexpace::RequestContext.build(bundle: NONE, request: :req) + + assert_nil(ctx.operation_name) + end + + # CTX-16 gives exactly two states -- "GetUser", or absent -- and "" is neither. + test "an empty operation_name is rejected, off-chain and at promotion" do + assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::RequestContext.build( + bundle: NONE, request: :req, operation_name: "", + ) + end + assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::DispatchContext.build(bundle: NONE, store: Dexpace::ContextStore.new(cap: 8)) + .promote_to_request(request: :req, operation_name: "") + end + end + + # CTX-16's "schema-defined operation id such as 'GetUser'" is a String: a Symbol spelling of + # the same id is refused with the field-named message, and so is anything without #empty?. + test "a non-String operation_name is rejected, off-chain and at promotion" do + store = Dexpace::ContextStore.new(cap: 8) + + [:GetUser, 7].each do |name| + error = assert_raises(Dexpace::InvalidArgumentError, name.inspect) do + Dexpace::RequestContext.build(bundle: NONE, request: :req, operation_name: name) + end + + assert_equal("operation_name must be a String", error.message, name.inspect) + error = assert_raises(Dexpace::InvalidArgumentError, name.inspect) do + Dexpace::DispatchContext.build(bundle: NONE, store: store) + .promote_to_request(request: :req, operation_name: name) + end + assert_equal("operation_name must be a String", error.message, name.inspect) + end + + assert_equal(0, store.size) + end + + test "operation_name is frozen without aliasing the caller's string" do + name = +"GetUser" + ctx = Dexpace::RequestContext.build(bundle: NONE, request: :req, operation_name: name) + name << "!" + + assert_equal("GetUser", ctx.operation_name) + assert_predicate(ctx.operation_name, :frozen?) + end + end + + # CTX-5, CTX-17 and the construction pattern for the middle flavour. + class ConstructionTest < DexpaceTestCase + test "request: is required, with SEAM-29's one message form" do + error = assert_raises(Dexpace::InvalidArgumentError) do + Dexpace::RequestContext.build(bundle: NONE, request: nil) + end + + assert_equal("request is required", error.message) + end + + test "CTX-17: off-chain construction of a request context registers nothing" do + store = Dexpace::ContextStore.new(cap: 8) + ctx = Dexpace::RequestContext.build(bundle: NONE, request: :req, store: store) + + assert_equal(0, store.size) + refute(ctx.close) + end + + test "CTX-5: a pinned key restores value equality between two request contexts" do + a = Dexpace::RequestContext.build(bundle: NONE, request: :req, call_key: "shared") + b = Dexpace::RequestContext.build(bundle: NONE, request: :req, call_key: "shared") + + assert_equal(a, b) + refute_equal(a, Dexpace::RequestContext.build(bundle: NONE, request: :req)) + end + + test "the construction pattern: new is private and the members are the design's" do + refute_respond_to(Dexpace::RequestContext, :new) + assert_equal( + %i[bundle call_key store request operation_name], Dexpace::RequestContext.members, + ) + end + end +end diff --git a/gems/dexpace-core/test/dexpace/context_store_test.rb b/gems/dexpace-core/test/dexpace/context_store_test.rb new file mode 100644 index 0000000..3ded07e --- /dev/null +++ b/gems/dexpace-core/test/dexpace/context_store_test.rb @@ -0,0 +1,389 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../test_helper" +require "dexpace" +require_relative "../support/fake_context" +require "open3" +require "rbconfig" + +# CTX-7, CTX-8, CTX-9, CTX-10, CTX-11, CTX-12, CTX-13, CTX-18, CTX-19, XCUT-14, and the Fiber[] +# boundary docs/knowledge/notes/observability.md draws. Every store here is a fresh +# Dexpace::ContextStore.new(cap:), never .default: this suite passes alone and in any order and +# never touches the process-wide store. Every thread a case starts is joined before it returns, +# which DexpaceTestCase's teardown asserts. +# +# One class per behaviour group, because Metrics/ClassLength caps a class at 100 lines: the two +# operations and the eviction rules here, then Concurrency, Bound and Reachability below. +class DexpaceContextStoreTest < DexpaceTestCase + # THE trap. Two contexts equal by value and distinct by identity are constructible only with a + # pinned explicit call_key (CTX-5's escape hatch) -- FakeContext has no value equality at all + # (plain Object#==), so this is the one case in the suite that needs a real Data context. A test + # over two default-constructed contexts would pass against a ==-based release, because their + # keys differ and the value comparison would never fire. + test "CTX-9: release evicts only the reference-identical occupant, never a value-equal sibling" do + store = Dexpace::ContextStore.new(cap: 8) + bundle = Dexpace::Instrumentation::Bundle::NONE + first = Dexpace::DispatchContext.build(bundle: bundle, call_key: "shared", store: store) + second = Dexpace::DispatchContext.build(bundle: bundle, call_key: "shared", store: store) + + assert_equal(first, second) + refute_same(first, second) + + store.set(first) + + refute(store.release(second)) + assert_same(first, store[first.call_key]) + + assert(store.release(first)) + assert_nil(store[first.call_key]) + end + + test "CTX-8: #set is install-or-replace and never raises; #put is install-only-if-absent" do + store = Dexpace::ContextStore.new(cap: 8) + first = FakeContext.new(call_key: "k", store: store) + second = FakeContext.new(call_key: "k", store: store) + + assert_same(first, store.put(first)) + assert_same(second, store.set(second)) + assert_same(second, store["k"]) + + error = assert_raises(Dexpace::ContextConflictError) { store.put(first) } + + assert_equal("k", error.call_key) + assert_same(second, store["k"], "a losing #put leaves the occupant untouched") + end + + test "CTX-10: releasing a promoted intermediate is a no-op; the successor keeps the slot" do + store = Dexpace::ContextStore.new(cap: 8) + intermediate = FakeContext.new(call_key: "k", store: store) + successor = FakeContext.new(call_key: "k", store: store) + store.set(intermediate) + store.set(successor) + + refute(store.release(intermediate)) + assert_same(successor, store["k"]) + assert_equal(1, store.size) + end + + test "CTX-18: an unknown key resolves to nil and never raises" do + store = Dexpace::ContextStore.new(cap: 8) + + assert_nil(store["nope"]) + end + + test "CTX-18: double-close and cleanup-path close are well-defined no-ops" do + store = Dexpace::ContextStore.new(cap: 8) + ctx = FakeContext.new(call_key: "x", store: store) + + refute(store.release(ctx)) + + store.set(ctx) + + assert(store.release(ctx)) + refute(store.release(ctx)) + assert_equal(0, store.size) + end + + # Verified fact 12: a frozen String is stored as a Hash key by identity, an unfrozen one is + # duplicated and frozen. The store reads whatever #call_key answers, so a fake's unfrozen key + # still resolves -- the freezing is the contexts' own job (CTX-4) and not the store's. + test "the store resolves an unfrozen key the same as a frozen one" do + store = Dexpace::ContextStore.new(cap: 8) + ctx = FakeContext.new(call_key: +"mutable", store: store) + store.set(ctx) + + assert_same(ctx, store["mutable"]) + assert_same(ctx, store[+"mutable"]) + end + + # CTX-7 and CTX-8 under contention, and the Fiber[] boundary. + class ConcurrencyTest < DexpaceTestCase + test "CTX-7: 16 threads registering 1000 distinct keys each lose nothing" do + store = Dexpace::ContextStore.new(cap: 20_000) + + threads = Array.new(16) do |t| + ::Thread.new do + 1000.times { |i| store.set(FakeContext.new(call_key: "t#{t}-#{i}", store: store)) } + end + end + threads.each(&:join) + + assert_equal(16_000, store.size) + end + + # testing/80c44c7f: the raised object is asserted on, not rescued and ignored. + test "CTX-8: 32 threads racing one #put admit exactly one winner; every loser names the key" do + store = Dexpace::ContextStore.new(cap: 64) + gate = ::Thread::Queue.new + outcomes = ::Thread::Queue.new + + threads = Array.new(32) do + ::Thread.new do + gate.pop + outcomes << begin + store.put(FakeContext.new(call_key: "shared", store: store)) + :ok + rescue Dexpace::ContextConflictError => error + error + end + end + end + 32.times { gate << true } + threads.each(&:join) + results = Array.new(32) { outcomes.pop } + + winners = results.count { |r| r == :ok } + losers = results.grep(Dexpace::ContextConflictError) + + assert_equal(1, winners) + assert_equal(31, losers.size) + losers.each do |error| + assert_equal("shared", error.call_key) + assert_includes(error.message, "shared") + end + assert_equal(1, store.size) + end + + # The store is process-wide, not fiber-scoped: the line docs/knowledge/notes/observability.md + # draws between CTX's store and the Fiber[] diagnostic-context carrier. The setup guard is + # what makes the three store assertions mean something, and it covers every carrier they do: + # with a fiber-storage slot and a fiber-local slot both written on the main fiber, a child + # Fiber, a new ::Thread and an Enumerator's internal fiber each inherit the first and see + # none of the second (observability/016d9154, verified on 3.2.11, 3.4.10 and 4.0.6). That is + # what establishes the three as distinct execution contexts -- without it, the store being + # visible everywhere would prove nothing. Fiber[:probe] = v emits no warning on any supported + # Ruby (design, verified fact 13); only Fiber#storage= does, and nothing here calls it. + test "the store is process-wide, not fiber-scoped -- the Fiber[] boundary" do + store = Dexpace::ContextStore.new(cap: 8) + ctx = FakeContext.new(call_key: "fiber-probe", store: store) + store.set(ctx) + probe = -> { [Fiber[:probe], ::Thread.current[:probe]] } + + Fiber[:probe] = :fiber_storage + ::Thread.current[:probe] = :fiber_local + + assert_equal(%i[fiber_storage fiber_local], probe.call) + assert_equal([:fiber_storage, nil], Fiber.new { probe.call }.resume) + assert_equal([:fiber_storage, nil], ::Thread.new { probe.call }.value) + assert_equal([:fiber_storage, nil], Enumerator.new { |y| y << probe.call }.next) + + from_fiber = Fiber.new { store["fiber-probe"] }.resume + from_thread = ::Thread.new { store["fiber-probe"] }.value + from_enumerator = Enumerator.new { |y| y << store["fiber-probe"] }.next + + assert_same(ctx, from_fiber) + assert_same(ctx, from_thread) + assert_same(ctx, from_enumerator) + ensure + Fiber[:probe] = nil + ::Thread.current[:probe] = nil + end + end + + # CTX-11, CTX-12, CTX-13 and XCUT-14: the bound, the drain and the victim policy. + class BoundTest < DexpaceTestCase + test "CTX-13: cap pressure evicts the first-registered; re-setting a key does not refresh it" do + store = Dexpace::ContextStore.new(cap: 3) + a = FakeContext.new(call_key: "a", store: store) + b = FakeContext.new(call_key: "b", store: store) + c = FakeContext.new(call_key: "c", store: store) + store.set(a) + store.set(b) + store.set(c) + + # Simulates a promotion overwriting "a"'s slot: Hash#[]= on an existing key does not move + # it (verified fact 8), so "a" is still the oldest registration and the first victim. + store.set(FakeContext.new(call_key: "a", store: store)) + store.set(FakeContext.new(call_key: "d", store: store)) + + assert_equal(3, store.size) + assert_nil(store["a"]) + assert_same(b, store["b"]) + assert_same(c, store["c"]) + refute_nil(store["d"]) + end + + # The same policy through a REAL promotion, which the fake above only simulates. A + # #promote_to_exchange that released its source before setting the successor would pass + # every FakeContext case in this file and still refresh the chain's eviction position (the + # slot is deleted and re-inserted at the end of the hash) -- and leave the slot transiently + # empty between two mutex acquisitions. Cap 3, three chains at the request stage under keys + # a, b and c; a's request context promoted to exchange, re-setting a's slot through the real + # path; a fourth chain promoted; a is still the oldest registration and still the victim, and + # both of a's links close as CTX-18's false. This is the case the release-then-set mutation + # goes red on. + test "CTX-13: a real promotion re-sets the slot without refreshing its eviction position" do + store = Dexpace::ContextStore.new(cap: 3) + bundle = Dexpace::Instrumentation::Bundle::NONE + promote = lambda do |key| + Dexpace::DispatchContext.build(bundle: bundle, call_key: key, store: store) + .promote_to_request(request: :req) + end + a = promote.call("a") + b = promote.call("b") + c = promote.call("c") + + a_exchange = a.promote_to_exchange(response: :resp) + + assert_same(a_exchange, store["a"]) + assert_equal(3, store.size) + + d = promote.call("d") + + assert_equal(%w[b c d], %w[a b c d].select { |key| store[key] }) + assert_equal(3, store.size) + assert_same(b, store["b"]) + assert_same(c, store["c"]) + assert_same(d, store["d"]) + refute(a_exchange.close) + refute(a.close) + end + + test "CTX-13/CTX-18: nothing in the store's own behaviour depends on any entry surviving" do + store = Dexpace::ContextStore.new(cap: 1) + a = FakeContext.new(call_key: "a", store: store) + store.set(a) + store.set(FakeContext.new(call_key: "b", store: store)) + + refute(store.release(a)) + assert_equal(1, store.size) + end + + test "CTX-11/CTX-12/XCUT-14: the cap bound holds under concurrent inserts, from 16 threads" do + store = Dexpace::ContextStore.new(cap: 64) + + threads = Array.new(16) do |t| + ::Thread.new do + 500.times { |i| store.set(FakeContext.new(call_key: "#{t}-#{i}", store: store)) } + end + end + threads.each(&:join) + + assert_equal(64, store.size) + end + + # CTX-12/XCUT-14, and the assertion that is NOT the obvious one. The aggregate + # drain-iteration count (inserts - final size) is an arithmetic identity that any + # one-eviction-per-iteration drain reproduces, split-lock or no loop at all -- + # docs/knowledge/notes/execution-context.md records the measurement and the trap. What + # discriminates single-threaded is this: from a store already at cap, every further insert + # evicts EXACTLY ONE prior occupant and the size is never observed above the cap, which is + # the drain body running at most once per insert. It fails against a drain that evicts two + # per insert and against a #set path with no drain at all. + # + # What this canNOT reach, stated so nobody reads more into it: `while` vs a single `if` is + # behaviourally identical under the one-synchronize insert-and-drain (the loop is written + # because XCUT-14 makes it a MUST), and the split-lock overshoot the note measures is + # invisible through this class's public surface -- #size takes the same mutex. Proving + # CTX-7/CTX-8's drain needs a GVL-free interpreter: docs/first-release.md, Post-release + # triggers, the IO-38 row. + test "CTX-12/XCUT-14: from cap, each insert evicts exactly one, and size never exceeds cap" do + store = Dexpace::ContextStore.new(cap: 8) + seeds = Array.new(8) { |i| "seed-#{i}" } + seeds.each { |key| store.set(FakeContext.new(call_key: key, store: store)) } + + sizes = [] + evictions = [] + keys = seeds.dup + 100.times do |i| + before = keys.count { |key| store[key] } + store.set(FakeContext.new(call_key: "fill-#{i}", store: store)) + sizes << store.size + evictions << (before - keys.count { |key| store[key] }) + keys << "fill-#{i}" + end + + assert_equal([8], sizes.uniq) + assert_equal([1], evictions.uniq) + end + + # XCUT-14 says "after each insert", and #put is an insert. Without this case the drain can + # be deleted from the reject-on-duplicate path with the whole suite still green. + test "CTX-8/CTX-11/XCUT-14: the reject-on-duplicate insert drains to the cap too" do + store = Dexpace::ContextStore.new(cap: 3) + + 6.times { |i| store.put(FakeContext.new(call_key: "p#{i}", store: store)) } + + assert_equal(3, store.size) + assert_nil(store["p0"]) + refute_nil(store["p5"]) + end + + # P4-9's number, asserted rather than assumed: AUTH-19's stated default for a store of this + # shape is the one number the specification supplies, and phase 5a (Task 13) attaches a + # configuration source to it without changing a signature. + test "CTX-11: MAX_TRACKED_CONTEXTS is 1024 and is the cap a default-constructed store uses" do + assert_equal(1024, Dexpace::ContextStore::MAX_TRACKED_CONTEXTS) + assert_predicate(Dexpace::ContextStore::MAX_TRACKED_CONTEXTS, :frozen?) + + store = Dexpace::ContextStore.new + + 2000.times { |i| store.set(FakeContext.new(call_key: "d#{i}", store: store)) } + + assert_equal(Dexpace::ContextStore::MAX_TRACKED_CONTEXTS, store.size) + end + + # A cap of 0 would evict every entry including the one just inserted, and a negative cap + # would make `shift while size > cap` spin forever on an empty hash: both are refused at + # construction, with SEAM-29's message for the absent case. + test "cap: must be a positive Integer, with SEAM-29's one message form when absent" do + error = assert_raises(Dexpace::InvalidArgumentError) { Dexpace::ContextStore.new(cap: nil) } + + assert_equal("cap is required", error.message) + assert_raises(Dexpace::InvalidArgumentError) { Dexpace::ContextStore.new(cap: 0) } + assert_raises(Dexpace::InvalidArgumentError) { Dexpace::ContextStore.new(cap: -1) } + assert_raises(Dexpace::InvalidArgumentError) { Dexpace::ContextStore.new(cap: 1.5) } + end + end + + # CTX-19 and the process-wide instance. + class ReachabilityTest < DexpaceTestCase + # A discriminator against ObjectSpace::WeakMap and NOT a tautology: the same run against a + # WeakMap returns 0 of 1000 after three GC.starts on all three interpreters (design, verified + # fact 3). It is equally NOT a discriminator against ObjectSpace::WeakKeyMap, which passes it + # -- a WeakKeyMap holds its values strongly and the stored context strongly holds the frozen + # String that is its key, so no entry is ever collectable (verified fact 3a). That spelling + # is forbidden by Dexpace/NoWeakReferences and by nothing here. It names no ObjectSpace + # constant, so it runs identically on 3.2.11, where WeakKeyMap is undefined. + test "CTX-19: 1000 registered contexts stay reachable after the caller drops every local" do + store = Dexpace::ContextStore.new(cap: 2048) + keys = Array.new(1000) { |i| "k#{i}" } + keys.each { |key| store.set(FakeContext.new(call_key: key, store: store)) } + + 3.times { GC.start } + + assert_equal(1000, store.size) + refute_nil(store[keys.first]) + refute_nil(store[keys.last]) + end + + # .default is the one process-wide instance; this suite never writes to it (CTX-17 keeps + # construction off the store), so what is asserted here is its identity across calls and + # that it is a store. The eager-assignment half is the next case's. + test ".default is one process-wide instance, the same object on every call" do + assert_same(Dexpace::ContextStore.default, Dexpace::ContextStore.default) + assert_instance_of(Dexpace::ContextStore, Dexpace::ContextStore.default) + end + + # Why .default is assigned at file load rather than memoised: `@default ||= new` is an + # unsynchronised read-modify-write over shared state (XCUT-11), and eager assignment is also + # what keeps CTX-17 inert for a .build that takes the default store -- construction reads a + # singleton that already exists and writes nothing. The identity case cannot see the + # difference: by the time it runs, some earlier .build in this process has called .default + # and a memoised store would already exist. Only a fresh process can, so this asks one -- + # `require "dexpace"` and nothing else, then whether the ivar is set before any call. A + # memoised .default with the load-time line deleted prints false. + test ".default is assigned at file load: a fresh process holds it before any call" do + lib = File.expand_path("../../lib", __dir__) + out, err, status = Open3.capture3( + RbConfig.ruby, "-w", "-W:deprecated", "-I", lib, "-e", + 'require "dexpace"; print Dexpace::ContextStore.instance_variable_defined?(:@default)', + ) + + assert_predicate(status, :success?, err) + assert_empty(err) + assert_equal("true", out) + end + end +end diff --git a/gems/dexpace-core/test/dexpace/context_test.rb b/gems/dexpace-core/test/dexpace/context_test.rb new file mode 100644 index 0000000..9af6cef --- /dev/null +++ b/gems/dexpace-core/test/dexpace/context_test.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../test_helper" +require "dexpace" +require_relative "../support/fake_context" + +# CTX-9, CTX-10, CTX-18: #close is `store.release(self)`, and every clause of all three is a +# property of the store, not of Context itself. The private construction validation every +# flavour's #initialize runs is driven here through an includer shaped exactly like the three. +class DexpaceContextTest < DexpaceTestCase + # The minimal store double: an object answering #set and #release, which is the whole of what a + # context asks of its store (api-design/88e6bf12, the narrowest duck type). + class FakeStore + attr_accessor :released_with + + def set(context) = context + + def release(context) + self.released_with = context + context + end + end + + # A Data includer in the flavours' own shape -- the validation before `super` -- so the private + # #validate_context! is exercised the way DispatchContext, RequestContext and ExchangeContext + # exercise it, and stays off the public surface. + class ProbeContext < Data.define(:bundle, :call_key, :store) + include Dexpace::Context + + def initialize(bundle:, call_key:, store:) + validate_context!(bundle: bundle, call_key: call_key, store: store) + + super + end + end + + test "#close delegates to store.release(self)" do + store = FakeStore.new + ctx = FakeContext.new(call_key: "k", store: store) + + assert_same(ctx, ctx.close) + assert_same(ctx, store.released_with) + end + + test "#close returns what the store returned, so CTX-10's no-op is observable" do + store = FakeStore.new + store.define_singleton_method(:release) { |_context| false } + ctx = FakeContext.new(call_key: "k", store: store) + + refute(ctx.close) + end + + test "the validation requires a non-nil bundle, with SEAM-29's one message form" do + error = assert_raises(Dexpace::InvalidArgumentError) do + ProbeContext.new(bundle: nil, call_key: "k", store: FakeStore.new) + end + + assert_equal("bundle is required", error.message) + end + + test "the validation requires a non-nil, non-empty call_key" do + store = FakeStore.new + + error = assert_raises(Dexpace::InvalidArgumentError) do + ProbeContext.new(bundle: :b, call_key: nil, store: store) + end + assert_equal("call_key is required", error.message) + + assert_raises(Dexpace::InvalidArgumentError) do + ProbeContext.new(bundle: :b, call_key: "", store: store) + end + end + + # CTX-4's key is a String. A Symbol is refused rather than silently keying a slot that no String + # lookup finds, and an Integer meets the SDK's error rather than a NoMethodError from #empty?. + test "the validation requires the call_key to be a String, with the field-named message" do + store = FakeStore.new + + [:sym, 5].each do |key| + error = assert_raises(Dexpace::InvalidArgumentError, key.inspect) do + ProbeContext.new(bundle: :b, call_key: key, store: store) + end + + assert_equal("call_key must be a String", error.message, key.inspect) + end + end + + test "the validation requires a store that responds to #set and #release" do + bad_store = Object.new + only_set = Object.new.tap { |o| o.define_singleton_method(:set) { |c| c } } + + assert_raises(Dexpace::InvalidArgumentError) do + ProbeContext.new(bundle: :b, call_key: "k", store: bad_store) + end + assert_raises(Dexpace::InvalidArgumentError) do + ProbeContext.new(bundle: :b, call_key: "k", store: only_set) + end + end + + test "the validation passes for a store responding to both, and the instance is frozen" do + ctx = ProbeContext.new(bundle: :b, call_key: "k", store: FakeStore.new) + + assert_equal("k", ctx.call_key) + assert_predicate(ctx, :frozen?) + end + + # The design gives Context exactly one public method, #close (P4-11); the validation is + # private and takes no surface-manifest row. + test "Context's public surface is #close alone" do + assert_equal([:close], Dexpace::Context.public_instance_methods(false)) + assert_includes(Dexpace::Context.private_instance_methods(false), :validate_context!) + assert_includes(Dexpace::Context.private_instance_methods(false), :validate_operation_name!) + assert_empty(Dexpace::Context.singleton_methods) + end + + # The module includes Dexpace::Model, so every flavour gets the validating #with by inclusion + # (P1-4) -- the two-level ancestry verified fact 14 rests on. + test "Context includes Dexpace::Model, so an includer's #with is Model's" do + assert_includes(Dexpace::Context.ancestors, Dexpace::Model) + assert_equal(Dexpace::Model, FakeContext.instance_method(:with).owner) + assert_equal(Dexpace::Model, ProbeContext.instance_method(:with).owner) + end +end diff --git a/gems/dexpace-core/test/dexpace/error/context_conflict_error_test.rb b/gems/dexpace-core/test/dexpace/error/context_conflict_error_test.rb new file mode 100644 index 0000000..78e5e29 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/error/context_conflict_error_test.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace" + +# CTX-8: the reject-on-duplicate loser's error names the key. +class DexpaceContextConflictErrorTest < DexpaceTestCase + test "is caught by rescue Dexpace::Error through Module#===" do + caught = begin + raise Dexpace::ContextConflictError, "k1" + rescue Dexpace::Error => error + error + end + + assert_instance_of(Dexpace::ContextConflictError, caught) + end + + test "the message identifies the key, per CTX-8's own words" do + error = Dexpace::ContextConflictError.new("call-key-42") + + assert_equal("call-key-42", error.call_key) + assert_includes(error.message, "call-key-42") + end + + test "is not Dexpace::InvalidArgumentError -- a lost race is not an invalid argument" do + refute_operator(Dexpace::ContextConflictError, :<, Dexpace::InvalidArgumentError) + end + + # Phase 2's three seam errors are all `< ::StandardError` with the marker included; the fourth + # follows, so a `rescue StandardError` at an application boundary still sees it. + test "is a StandardError carrying the Dexpace::Error marker, like phase 2's three" do + assert_operator(Dexpace::ContextConflictError, :<, ::StandardError) + assert_includes(Dexpace::ContextConflictError.ancestors, Dexpace::Error) + end +end diff --git a/gems/dexpace-core/test/dexpace/instrumentation/bundle_test.rb b/gems/dexpace-core/test/dexpace/instrumentation/bundle_test.rb new file mode 100644 index 0000000..5e1f86a --- /dev/null +++ b/gems/dexpace-core/test/dexpace/instrumentation/bundle_test.rb @@ -0,0 +1,250 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace" + +# CTX-14, CTX-15, OBS-26, OBS-27; P4-6 (validity derived) and P4-7 (the two sentinels split). +# +# One class per behaviour group, because Metrics/ClassLength caps a class at 100 lines: the +# sentinels and the required members here, then Validation and Ownership below. +class DexpaceInstrumentationBundleTest < DexpaceTestCase + Bundle = Dexpace::Instrumentation::Bundle + Flavour = Dexpace::Instrumentation::TraceIdFlavour + W3C = Flavour::W3C + + test "CTX-15: Bundle::NONE carries OBS-26's reserved sentinels, isValid false, isRemote false" do + bundle = Bundle::NONE + + assert_equal("0" * 32, bundle.trace_id) + assert_equal("0" * 16, bundle.span_id) + assert_equal("00", bundle.trace_flags) + assert_equal([], bundle.trace_state) + assert_same(Flavour::NONE, bundle.flavour) + refute_predicate(bundle, :valid?) + refute_predicate(bundle, :remote?) + refute(bundle.remote) + assert_same(Dexpace::Instrumentation::NO_SPAN, bundle.span) + assert_same(Dexpace::Instrumentation::NO_TRACER_FACTORY, bundle.tracer_factory) + end + + # CTX-14's nine: eight stored members and the derived ninth. The member list is the contract + # phase 5 may not widen (roadmap obligation 1; the design's handshake, clause 4). + test "CTX-14: exactly eight stored members, in the design's order, and validity derived" do + assert_equal( + %i[trace_id span_id trace_flags trace_state flavour remote span tracer_factory], + Bundle.members, + ) + refute_includes(Bundle.members, :valid) + assert_respond_to(Bundle::NONE, :valid?) + end + + test "trace_id:, span_id: and flavour: are required -- there is no silent untraced default" do + error = assert_raises(ArgumentError) do + Bundle.build(span_id: "a" * 16, flavour: W3C) + end + + # Both halves are load-bearing. Dexpace::InvalidArgumentError IS an ::ArgumentError (phase 1, + # P1-3), so `assert_raises(ArgumentError)` alone passes just as well against a .build that + # defaulted trace_id: to nil and let Model.required! reject it -- which is exactly the silent + # untraced default this test exists to forbid. Ruby's own missing-keyword failure is the + # assertion; the refute is what makes it discriminating. + refute_kind_of(Dexpace::InvalidArgumentError, error) + assert_includes(error.message, "missing keyword") + assert_raises(ArgumentError) { Bundle.build(trace_id: "a" * 32, flavour: W3C) } + assert_raises(ArgumentError) { Bundle.build(trace_id: "a" * 32, span_id: "b" * 16) } + end + + test "OBS-26: an all-zero span id makes a bundle invalid even when its trace id is real" do + bundle = Bundle.build( + trace_id: "a" * 32, span_id: Bundle::INVALID_SPAN_ID, flavour: W3C, + ) + + refute_predicate(bundle, :valid?) + end + + test "OBS-26: an all-zero trace id makes a bundle invalid even when its span id is real" do + bundle = Bundle.build(trace_id: W3C.invalid_trace_id, span_id: "b" * 16, flavour: W3C) + + refute_predicate(bundle, :valid?) + end + + # OBS-26's per-member validation and the flavour rule. + class ValidationTest < DexpaceTestCase + test "OBS-26: span_id, trace_flags and trace_state are each validated on their own" do + base = { trace_id: "a" * 32, span_id: "b" * 16, flavour: W3C } + + assert_raises(Dexpace::InvalidArgumentError) { Bundle.build(**base, span_id: "B" * 16) } + assert_raises(Dexpace::InvalidArgumentError) { Bundle.build(**base, span_id: "b" * 15) } + assert_raises(Dexpace::InvalidArgumentError) { Bundle.build(**base, trace_flags: "0") } + assert_raises(Dexpace::InvalidArgumentError) { Bundle.build(**base, trace_flags: "ZZ") } + assert_raises(Dexpace::InvalidArgumentError) { Bundle.build(**base, trace_flags: "0A") } + assert_raises(Dexpace::InvalidArgumentError) { Bundle.build(**base, trace_state: [["v"]]) } + assert_raises(Dexpace::InvalidArgumentError) do + Bundle.build(**base, trace_state: [%w[vendor value], :not_a_pair]) + end + assert_raises(Dexpace::InvalidArgumentError) do + Bundle.build(**base, trace_state: [["vendor", 1]]) + end + assert_raises(Dexpace::InvalidArgumentError) { Bundle.build(**base, trace_state: "a=b") } + assert_raises(Dexpace::InvalidArgumentError) { Bundle.build(**base, flavour: :w3c) } + end + + test "the wire forms are accepted: sampled flags, and a vendor trace-state list" do + bundle = Bundle.build( + trace_id: "a" * 32, span_id: "b" * 16, flavour: W3C, trace_flags: "01", + trace_state: [%w[vendor value], %w[other v2]], remote: true, + ) + + assert_equal("01", bundle.trace_flags) + assert_equal([%w[vendor value], %w[other v2]], bundle.trace_state) + assert_predicate(bundle, :remote?) + assert_predicate(bundle, :valid?) + end + + test "CTX-7: a bundle is frozen on construction, Bundle::NONE included" do + assert_predicate(Bundle::NONE, :frozen?) + assert_predicate( + Bundle.build(trace_id: "a" * 32, span_id: "b" * 16, flavour: W3C), + :frozen?, + ) + end + + test "an explicit nil trace_id gets SEAM-29's message, not a missing-keyword error" do + error = assert_raises(Dexpace::InvalidArgumentError) do + Bundle.build(trace_id: nil, span_id: "a" * 16, flavour: W3C) + end + + assert_equal("trace_id is required", error.message) + error = assert_raises(Dexpace::InvalidArgumentError) do + Bundle.build(trace_id: "a" * 32, span_id: "a" * 16, flavour: nil) + end + assert_equal("flavour is required", error.message) + end + + test "a valid W3C bundle reports valid? true" do + bundle = Bundle.build(trace_id: "a" * 32, span_id: "b" * 16, flavour: W3C) + + assert_predicate(bundle, :valid?) + end + + # OBS-26/OBS-27 together: the flavour governs the trace id only, so a Datadog bundle carries a + # decimal trace id and a hex span id -- odd to read, and what the two requirements say. + test "a DATADOG bundle carries a decimal trace id and a 16-hex span id" do + bundle = Bundle.build(trace_id: "42", span_id: "b" * 16, flavour: Flavour::DATADOG) + + assert_predicate(bundle, :valid?) + assert_raises(Dexpace::InvalidArgumentError) do + Bundle.build(trace_id: "42", span_id: "42", flavour: Flavour::DATADOG) + end + refute_predicate(Bundle.build(trace_id: "0", span_id: "b" * 16, flavour: Flavour::DATADOG), + :valid?,) + end + + test "a malformed span_id is rejected even though valid? would not have caught it" do + assert_raises(Dexpace::InvalidArgumentError) do + Bundle.build(trace_id: "a" * 32, span_id: "not-hex", flavour: W3C) + end + end + + test "a trace_id that does not match its flavour is rejected, naming the flavour" do + error = assert_raises(Dexpace::InvalidArgumentError) do + Bundle.build(trace_id: "42", span_id: "b" * 16, flavour: W3C) + end + + assert_includes(error.message, ":w3c") + assert_raises(Dexpace::InvalidArgumentError) do + Bundle.build(trace_id: "a" * 32, span_id: "b" * 16, flavour: Flavour::NONE) + end + end + end + + # XCUT-15's ownership, the round trip, #with and the two constants. + class OwnershipTest < DexpaceTestCase + # XCUT-15: the model owns its collection outright. Model.own deep-copies then deep-freezes, + # so the caller's array is untouched and the bundle's is frozen at every level -- which Data's + # own shallow freeze would not do (design, verified fact 15). + test "trace_state is deep-frozen through Model.own, independent of the caller's array" do + source = [%w[vendor value]] + bundle = Bundle.build( + trace_id: "a" * 32, span_id: "b" * 16, flavour: W3C, trace_state: source, + ) + source << %w[other value] + source.first << "extra" + + assert_equal([%w[vendor value]], bundle.trace_state) + assert_predicate(bundle.trace_state, :frozen?) + assert_predicate(bundle.trace_state.first, :frozen?) + assert_predicate(bundle.trace_state.first.first, :frozen?) + assert_same(bundle.trace_state, bundle.trace_state) + end + + test "the string members are frozen without aliasing the caller's strings" do + trace_id = +"a" * 32 + bundle = Bundle.build( + trace_id: trace_id, span_id: +"b" * 16, flavour: W3C, trace_flags: +"01", + ) + trace_id << "z" + + assert_equal("a" * 32, bundle.trace_id) + assert_predicate(bundle.trace_id, :frozen?) + assert_predicate(bundle.span_id, :frozen?) + assert_predicate(bundle.trace_flags, :frozen?) + end + + # testing/f36a19cd's round trip, over both hex flavours and the sentinel or a real id. + test "Bundle.build(**bundle.to_h) == bundle for every generated bundle" do + sample(count: 32, seed: 20_260_908) do |rng| + flavour = rng.rand < 0.5 ? W3C : Flavour::DATADOG + real = flavour == W3C ? "a" * 32 : "42" + trace_id = rng.rand < 0.5 ? real : flavour.invalid_trace_id + span_id = rng.rand < 0.5 ? "b" * 16 : Bundle::INVALID_SPAN_ID + bundle = Bundle.build( + trace_id: trace_id, span_id: span_id, flavour: flavour, + trace_flags: rng.rand < 0.5 ? "00" : "01", remote: rng.rand < 0.5, + ) + + assert_equal(bundle, Bundle.build(**bundle.to_h)) + assert_equal(bundle.hash, Bundle.build(**bundle.to_h).hash) + end + end + + test "#with preserves member identity for members it is not handed" do + bundle = Bundle.build(trace_id: "a" * 32, span_id: "b" * 16, flavour: W3C) + changed = bundle.with(remote: true) + + assert_same(bundle.span, changed.span) + assert_same(bundle.tracer_factory, changed.tracer_factory) + assert_same(bundle.trace_id, changed.trace_id) + assert_predicate(changed, :remote?) + refute_predicate(bundle, :remote?) + end + + # P1-4: #with routes through the validating .build on every supported Ruby, which is what lets + # phase 5 populate through Bundle::NONE.with(...) and get the same validation as .build. + test "#with re-validates through .build: NONE.with(trace_id: real) is refused by NONE" do + error = assert_raises(Dexpace::InvalidArgumentError) do + Bundle::NONE.with(trace_id: "a" * 32) + end + + assert_includes(error.message, ":none") + populated = Bundle::NONE.with(trace_id: "a" * 32, span_id: "b" * 16, flavour: W3C) + + assert_predicate(populated, :valid?) + refute_predicate(Bundle::NONE, :valid?) + end + + test "INVALID_SPAN_ID is OBS-26's 16 hex zeros, frozen, and the same across flavours" do + assert_equal("0" * 16, Bundle::INVALID_SPAN_ID) + assert_predicate(Bundle::INVALID_SPAN_ID, :frozen?) + assert_equal( + Bundle::INVALID_SPAN_ID, + Bundle.build(trace_id: "42", span_id: "0" * 16, flavour: Flavour::DATADOG).span_id, + ) + end + + test "the construction pattern: new is private and .build is the one way in" do + refute_respond_to(Bundle, :new) + end + end +end diff --git a/gems/dexpace-core/test/dexpace/instrumentation/no_span_test.rb b/gems/dexpace-core/test/dexpace/instrumentation/no_span_test.rb new file mode 100644 index 0000000..b88d5db --- /dev/null +++ b/gems/dexpace-core/test/dexpace/instrumentation/no_span_test.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace" + +# CTX-14's "an active span" slot and CTX-15's "a no-op span": one shared, frozen object, so +# OBS-25's "MUST NOT allocate per call" is assertable by reference identity (phase 5c gives its +# class the protocol; phase 4 fixes the object's identity). +class DexpaceInstrumentationNoSpanTest < DexpaceTestCase + test "NO_SPAN is a single frozen instance" do + assert_predicate(Dexpace::Instrumentation::NO_SPAN, :frozen?) + assert_same(Dexpace::Instrumentation::NO_SPAN, Dexpace::Instrumentation::NO_SPAN) + end + + # The object is public by identity; its class is a private_constant, so phase 5 can widen it + # without an NFR-4 diff and nothing can build a second one. + test "NO_SPAN's class is a private_constant, reachable by no qualified name" do + refute_includes(Dexpace::Instrumentation.constants(false), :NoSpan) + assert_raises(::NameError) { Dexpace::Instrumentation::NoSpan } + end + + # Phase 4 fixes the slot and not the protocol: the object answers nothing beyond Object's own + # surface, and the RBS interface _Span is empty on purpose. + test "NO_SPAN responds to nothing beyond Object's own surface" do + assert_empty(Dexpace::Instrumentation::NO_SPAN.class.public_instance_methods(false)) + end +end diff --git a/gems/dexpace-core/test/dexpace/instrumentation/no_tracer_test.rb b/gems/dexpace-core/test/dexpace/instrumentation/no_tracer_test.rb new file mode 100644 index 0000000..855cc37 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/instrumentation/no_tracer_test.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace" + +# CTX-20: the no-op tracer factory's method MUST be safe to invoke concurrently, and OBS-25's +# "MUST NOT allocate per call" is what the identity assertions below are for. CTX-14's "a +# per-operation tracer factory" slot and CTX-15's "no-op tracer factory". +class DexpaceInstrumentationNoTracerTest < DexpaceTestCase + # P4-8 and the plan's open question 1: the point of the mirrored signature is CALL + # COMPATIBILITY, and the call shapes alone do not assert it. On Ruby 3.x a method that accepts + # no keyword parameters receives `tracer(name: "n")` as a positional Hash, so every keyword call + # below passes just as well against `def tracer(a = nil, b = nil)` -- the whole keyword half of + # the gem's signature can be deleted with the call-shape assertions still green. The parameter + # list is the discriminating assertion; the call shapes are the regression test for what + # callers write. Read from opentelemetry-api 1.11.0's + # lib/opentelemetry/trace/tracer_provider.rb, fetched from rubygems.org and re-read on + # 2026-09-16, still the latest release. + test "the factory method mirrors opentelemetry-api 1.11.0's parameter list exactly" do + assert_equal( + [%i[opt deprecated_name], %i[opt deprecated_version], + %i[key name], %i[key version], %i[key attributes],], + Dexpace::Instrumentation::NO_TRACER_FACTORY.method(:tracer).parameters, + ) + end + + test "the factory method accepts opentelemetry-api 1.11.0's own positional and keyword shape" do + factory = Dexpace::Instrumentation::NO_TRACER_FACTORY + + assert_same(Dexpace::Instrumentation::NO_TRACER, factory.tracer) + assert_same(Dexpace::Instrumentation::NO_TRACER, factory.tracer("name")) + assert_same(Dexpace::Instrumentation::NO_TRACER, factory.tracer("name", "1.0")) + assert_same( + Dexpace::Instrumentation::NO_TRACER, + factory.tracer(name: "name", version: "1.0", attributes: {}), + ) + assert_same(Dexpace::Instrumentation::NO_TRACER, factory.tracer("name", name: "other")) + end + + test "CTX-20: 16 threads calling #tracer concurrently all get the same object" do + factory = Dexpace::Instrumentation::NO_TRACER_FACTORY + results = Array.new(16) + + threads = Array.new(16) { |i| ::Thread.new { results[i] = factory.tracer("t#{i}") } } + threads.each(&:join) + + results.each { |tracer| assert_same(Dexpace::Instrumentation::NO_TRACER, tracer) } + end + + test "both singletons are frozen, and neither class is reachable by a qualified name" do + assert_predicate(Dexpace::Instrumentation::NO_TRACER, :frozen?) + assert_predicate(Dexpace::Instrumentation::NO_TRACER_FACTORY, :frozen?) + refute_includes(Dexpace::Instrumentation.constants(false), :NoTracer) + refute_includes(Dexpace::Instrumentation.constants(false), :NoTracerFactory) + assert_raises(::NameError) { Dexpace::Instrumentation::NoTracerFactory } + end + + # Phase 4 fixes the slot and not the protocol: the tracer answers nothing beyond Object's own + # surface, and the factory answers exactly the one method CTX-20's embedded MUST forces. + test "NO_TRACER responds to nothing beyond Object; the factory defines exactly #tracer" do + assert_empty(Dexpace::Instrumentation::NO_TRACER.class.public_instance_methods(false)) + assert_equal( + [:tracer], + Dexpace::Instrumentation::NO_TRACER_FACTORY.class.public_instance_methods(false), + ) + end +end diff --git a/gems/dexpace-core/test/dexpace/instrumentation/trace_id_flavour_test.rb b/gems/dexpace-core/test/dexpace/instrumentation/trace_id_flavour_test.rb new file mode 100644 index 0000000..6551dfa --- /dev/null +++ b/gems/dexpace-core/test/dexpace/instrumentation/trace_id_flavour_test.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require "dexpace" + +# OBS-27's trace-id flavours and CTX-14's "a trace-id encoding flavor" slot; P4-7's split of the +# reserved trace-id sentinel from the span-id one. +# +# One class per behaviour group, because Metrics/ClassLength caps a class at 100 lines: the table +# and the two predicates here, each flavour's own rule and the construction pattern below. +class DexpaceInstrumentationTraceIdFlavourTest < DexpaceTestCase + Flavour = Dexpace::Instrumentation::TraceIdFlavour + + FLAVOURS = [ + Flavour::NONE, + Flavour::W3C, + Flavour::DATADOG, + ].freeze + + VALID_SAMPLES = { + Flavour::NONE => [], + Flavour::W3C => ["a" * 32, "0123456789abcdef" * 2], + Flavour::DATADOG => %w[1 42 18446744073709551615], + }.freeze + + INVALID_SAMPLES = { + Flavour::NONE => ["a" * 32, "0", ""], + Flavour::W3C => ["A" * 32, "a" * 31, "a" * 33, "", "0"], + Flavour::DATADOG => ["1a", "", "0" * 32, "184467440737095516150", "99999999999999999999"], + }.freeze + + test ".of round-trips a recognised name" do + FLAVOURS.each { |flavour| assert_same(flavour, Flavour.of(flavour.name)) } + end + + test ".of raises Dexpace::InvalidArgumentError on an unrecognised name, like Protocol.parse" do + error = assert_raises(Dexpace::InvalidArgumentError) { Flavour.of(:bogus) } + + assert_includes(error.message, ":bogus") + assert_raises(Dexpace::InvalidArgumentError) { Flavour.of("w3c") } + end + + test "ALL is the frozen closed set, in the design's order" do + assert_equal(FLAVOURS, Flavour::ALL) + assert_predicate(Flavour::ALL, :frozen?) + assert_equal(%i[none w3c datadog], Flavour::ALL.map(&:name)) + end + + test "NONE's sentinel is OBS-26's 32 hex zeros, not a flavour-specific zero draw" do + assert_equal("0" * 32, Flavour::NONE.invalid_trace_id) + assert_equal("0" * 32, Flavour::W3C.invalid_trace_id) + end + + test "DATADOG's own zero draw is '0', distinct from OBS-26's sentinel (P4-7)" do + assert_equal("0", Flavour::DATADOG.invalid_trace_id) + end + + test "NONE renders only the sentinel: a disabled-tracing bundle carries no trace id of its own" do + assert(Flavour::NONE.renders?("0" * 32)) + refute(Flavour::NONE.valid_trace_id?("0" * 32)) + refute(Flavour::NONE.renders?("a" * 32)) + refute(Flavour::NONE.renders?("0")) + end + + # testing/f36a19cd: a parse-constructor invariant gets a round-trip property test. + # #renders?(x) == (#valid_trace_id?(x) || x == invalid_trace_id) for every generated x, and the + # two predicates disagree at exactly one input: the flavour's own sentinel. + test "renders? and valid_trace_id? agree everywhere except at the sentinel" do + sample(count: 64, seed: 20_260_908) do |rng| + flavour = FLAVOURS.sample(random: rng) + candidates = [flavour.invalid_trace_id] + VALID_SAMPLES[flavour] + INVALID_SAMPLES[flavour] + candidate = candidates.sample(random: rng) + + assert_equal( + flavour.valid_trace_id?(candidate) || candidate == flavour.invalid_trace_id, + flavour.renders?(candidate), + "#{flavour.name}: #{candidate.inspect}", + ) + end + end + + test "the sentinel is the one input where the two predicates disagree" do + FLAVOURS.each do |flavour| + assert(flavour.renders?(flavour.invalid_trace_id)) + refute(flavour.valid_trace_id?(flavour.invalid_trace_id)) + end + end + + test "every valid sample is valid and renders; every invalid sample is neither" do + FLAVOURS.each do |flavour| + VALID_SAMPLES[flavour].each do |trace_id| + assert(flavour.valid_trace_id?(trace_id), "#{flavour.name}: #{trace_id.inspect}") + assert(flavour.renders?(trace_id), "#{flavour.name}: #{trace_id.inspect}") + end + INVALID_SAMPLES[flavour].each do |trace_id| + refute(flavour.valid_trace_id?(trace_id), "#{flavour.name}: #{trace_id.inspect}") + refute(flavour.renders?(trace_id), "#{flavour.name}: #{trace_id.inspect}") + end + end + end + + # OBS-26 and OBS-27, per flavour, and the construction pattern. + class PerFlavourTest < DexpaceTestCase + test "DATADOG accepts a 64-bit decimal rendering and nothing wider" do + datadog = Flavour::DATADOG + + assert(datadog.valid_trace_id?("18446744073709551615")) + refute(datadog.valid_trace_id?("184467440737095516150")) + refute(datadog.valid_trace_id?("1a")) + refute(datadog.valid_trace_id?("")) + end + + # OBS-27's Datadog flavour is "a 64-bit unsigned integer rendered as a decimal string", and a + # digit count cannot express that bound: \A[0-9]{1,20}\z accepts both values below, and only + # the second is out of range. The bound is max_value on the flavour, so this is the one test + # that fails if it is dropped -- the digit-count test above passes either way. + test "OBS-27: DATADOG rejects a 20-digit value above 2**64 - 1, at the boundary" do + datadog = Flavour::DATADOG + + assert(datadog.valid_trace_id?(((2**64) - 1).to_s)) + refute(datadog.valid_trace_id?((2**64).to_s)) + refute(datadog.valid_trace_id?("99999999999999999999")) + refute(datadog.renders?("99999999999999999999")) + assert_equal((2**64) - 1, datadog.max_value) + assert_nil(Flavour::W3C.max_value) + assert_nil(Flavour::NONE.max_value) + end + + # OBS-26: "32 lowercase hex chars". Lowercase-only and rejected rather than folded -- a fold + # would silently accept 0A where the wire form is 0a (Dexpace/NoLocaleCaseFold has nothing to + # bite on here because nothing folds). + test "W3C accepts exactly 32 lowercase hex characters" do + assert(Flavour::W3C.valid_trace_id?("a" * 32)) + assert(Flavour::W3C.valid_trace_id?("0123456789abcdef" * 2)) + refute(Flavour::W3C.valid_trace_id?("A" * 32)) + refute(Flavour::W3C.valid_trace_id?("a" * 31)) + refute(Flavour::W3C.valid_trace_id?("a" * 33)) + refute(Flavour::W3C.valid_trace_id?("g" * 32)) + end + + test "every flavour is a frozen Data with its pattern built with a per-pattern timeout" do + FLAVOURS.each do |flavour| + assert_predicate(flavour, :frozen?) + assert_predicate(flavour.invalid_trace_id, :frozen?) + refute_nil(flavour.trace_id_pattern.timeout, "#{flavour.name}: no per-pattern timeout") + assert_in_delta(1.0, flavour.trace_id_pattern.timeout) + end + assert_nil(Regexp.timeout, "no process-global regexp budget is imposed on the host") + end + + test "the construction pattern: private new, validating build, and #with through build" do + refute_respond_to(Flavour, :new) + assert_raises(Dexpace::InvalidArgumentError) do + Flavour.build(name: nil, trace_id_pattern: /x/, invalid_trace_id: "0") + end + error = assert_raises(Dexpace::InvalidArgumentError) do + Flavour::W3C.with(invalid_trace_id: nil) + end + + assert_equal("invalid_trace_id is required", error.message) + derived = Flavour::W3C.with(name: :other) + + assert_equal(:other, derived.name) + assert_same(Flavour::W3C.trace_id_pattern, derived.trace_id_pattern) + end + end +end diff --git a/gems/dexpace-core/test/dexpace_test.rb b/gems/dexpace-core/test/dexpace_test.rb index b2a9f9b..2806c07 100644 --- a/gems/dexpace-core/test/dexpace_test.rb +++ b/gems/dexpace-core/test/dexpace_test.rb @@ -34,9 +34,10 @@ class DexpaceTest < DexpaceTestCase # Every public constant the surface manifest records, and the check that catches a file added # to lib/ and forgotten in the entry point. Phase 1's domain model, then phase 2's seam layer, - # then phase 3a's byte-streaming layer, then phase 3b's body layer, then phase 4b's recovery - # layer, then phase 4c's pipeline; Dexpace::Hooks, Dexpace::Recovery::Ownership and the two - # pipeline drivers are private_constants and appear in no constants(false) list. + # then phase 3a's byte-streaming layer, then phase 3b's body layer, then phase 4a's execution + # context, phase 4b's recovery layer and phase 4c's pipeline; Dexpace::Hooks, Dexpace::BoundedMap, + # Dexpace::CallKey, Dexpace::Recovery::Ownership and the two pipeline drivers are + # private_constants and appear in no constants(false) list. DOMAIN_MODEL = %i[ Error InvalidArgumentError Model Builder HeaderSyntax HeaderName Headers Status Method Protocol MediaType PercentEncoding Query URL RequestOptions Request Response @@ -50,9 +51,15 @@ class DexpaceTest < DexpaceTestCase Body BytesBody BufferBody StreamBody ChunkedBody FormBody FileBody MultipartBody ResponseBody RequestLoggingBody ResponseLoggingBody TypedResponse ].freeze + CONTEXT_LAYER = %i[ + ContextConflictError Context ContextStore Instrumentation DispatchContext RequestContext + ExchangeContext + ].freeze RECOVERY_LAYER = %i[Suppressible OutcomeError ProtocolError Outcome Recovery].freeze PIPELINE_LAYER = %i[PipelineError Pipeline AsyncPipeline].freeze - LAYERS = [DOMAIN_MODEL, SEAM_LAYER, IO_LAYER, BODY_LAYER, RECOVERY_LAYER, PIPELINE_LAYER].flatten + LAYERS = [ + DOMAIN_MODEL, SEAM_LAYER, IO_LAYER, BODY_LAYER, CONTEXT_LAYER, RECOVERY_LAYER, PIPELINE_LAYER, + ].flatten.freeze test "defines nothing outside the Dexpace namespace" do assert_empty(TOP_LEVEL_ADDED - [:Dexpace], "top-level constants added by the entry file") @@ -76,57 +83,76 @@ class DexpaceTest < DexpaceTestCase end end - # A consumer requires "dexpace" and nothing else: the body layer resolves too (phase 3b), and - # the two constants that are nested rather than flat sit where HTTP-3 and R6 put them. - test "requiring dexpace alone makes the whole body layer resolve" do - assert_equal(Dexpace::Body, Dexpace.const_get(:Body)) - assert_equal(Dexpace::TypedResponse, Dexpace.const_get(:TypedResponse)) - assert_equal(Dexpace::MultipartBody::Part, Dexpace::MultipartBody.const_get(:Part)) - assert_equal(1024 * 1024, Dexpace::Body::MAX_BUFFERED_ERROR_BODY_BYTES) - refute(Dexpace::Body.const_defined?(:Part, false), "Dexpace::Body::Part is not a namespace") - end + # A consumer requires "dexpace" and nothing else, and every layer resolves -- one case per phase, + # in a nested class because the smoke suite outgrew Metrics/ClassLength once the three phase-4 + # lanes landed their pins beside one another (the split every larger suite in this gem uses). + class Layers < DexpaceTestCase + # A consumer requires "dexpace" and nothing else: the body layer resolves too (phase 3b), and + # the two constants that are nested rather than flat sit where HTTP-3 and R6 put them. + test "requiring dexpace alone makes the whole body layer resolve" do + assert_equal(Dexpace::Body, Dexpace.const_get(:Body)) + assert_equal(Dexpace::TypedResponse, Dexpace.const_get(:TypedResponse)) + assert_equal(Dexpace::MultipartBody::Part, Dexpace::MultipartBody.const_get(:Part)) + assert_equal(1024 * 1024, Dexpace::Body::MAX_BUFFERED_ERROR_BODY_BYTES) + refute(Dexpace::Body.const_defined?(:Part, false), "Dexpace::Body::Part is not a namespace") + end - # A consumer requires "dexpace" and nothing else: the recovery layer resolves too (phase 4b), - # the trail module precedes the error root that includes it, and Ownership is private. - test "requiring dexpace alone makes the whole recovery layer resolve" do - assert_operator(Dexpace::Error, :<, Dexpace::Suppressible) - assert_equal(Dexpace::Outcome::Failure, Dexpace::Outcome.const_get(:Failure)) - assert_equal(Dexpace::Recovery::Orchestrator, Dexpace::Recovery.const_get(:Orchestrator)) - refute_includes(Dexpace::Recovery.constants(false), :Ownership, "a private_constant") - assert_raises(::NameError) { Dexpace::Recovery::Ownership } - end + # A consumer requires "dexpace" and nothing else: the recovery layer resolves too (phase 4b), + # the trail module precedes the error root that includes it, and Ownership is private. + test "requiring dexpace alone makes the whole recovery layer resolve" do + assert_operator(Dexpace::Error, :<, Dexpace::Suppressible) + assert_equal(Dexpace::Outcome::Failure, Dexpace::Outcome.const_get(:Failure)) + assert_equal(Dexpace::Recovery::Orchestrator, Dexpace::Recovery.const_get(:Orchestrator)) + refute_includes(Dexpace::Recovery.constants(false), :Ownership, "a private_constant") + assert_raises(::NameError) { Dexpace::Recovery::Ownership } + end - # A consumer requires "dexpace" and nothing else: the pipeline resolves too (phase 4c), the - # runtime's nested vocabulary with it, and the two drivers are private. - test "requiring dexpace alone makes the whole pipeline resolve, its two drivers private" do - assert_equal(16, Dexpace::Pipeline.const_get(:Stages)::ALL.size) - assert_empty(Dexpace::Pipeline.constants(false) & %i[SyncDriver AsyncDriver], "private") - assert_raises(::NameError) { Dexpace::Pipeline::SyncDriver } - end + # A consumer requires "dexpace" and nothing else: the pipeline resolves too (phase 4c), the + # runtime's nested vocabulary with it, and the two drivers are private. + test "requiring dexpace alone makes the whole pipeline resolve, its two drivers private" do + assert_equal(16, Dexpace::Pipeline.const_get(:Stages)::ALL.size) + assert_empty(Dexpace::Pipeline.constants(false) & %i[SyncDriver AsyncDriver], "private") + assert_raises(::NameError) { Dexpace::Pipeline::SyncDriver } + end - # A consumer requires "dexpace" and nothing else: the seam layer resolves too (phase 2). - test "requiring dexpace alone makes the whole seam layer resolve" do - assert_equal(Dexpace::Transport, Dexpace.const_get(:Transport)) - assert_equal(Dexpace::AsyncTransport, Dexpace.const_get(:AsyncTransport)) - assert_equal(Dexpace::Serde, Dexpace.const_get(:Serde)) - assert_equal(Dexpace::Operation, Dexpace.const_get(:Operation)) - assert_equal(Dexpace::Registry, Dexpace.const_get(:Registry)) - assert_equal(Dexpace::Cancellation, Dexpace.const_get(:Cancellation)) - assert_equal(Dexpace::Async::Future, Dexpace::Async.const_get(:Future)) - assert_equal(Dexpace::Closeable, Dexpace.const_get(:Closeable)) - refute_includes(Dexpace.constants(false), :Hooks, "Dexpace::Hooks is a private_constant") - assert_raises(::NameError) { Dexpace::Hooks } - end + # A consumer requires "dexpace" and nothing else: the execution context resolves too (phase + # 4a), the instrumentation subsystem keeps its namespace (design §8.1), and the two private + # constants are as unreachable as Dexpace::Hooks. + test "requiring dexpace alone makes the whole execution context resolve" do + assert_equal(Dexpace::ContextStore, Dexpace.const_get(:ContextStore)) + assert_equal(Dexpace::DispatchContext, Dexpace.const_get(:DispatchContext)) + assert_equal(Dexpace::Instrumentation::Bundle, Dexpace::Instrumentation.const_get(:Bundle)) + assert_equal(1024, Dexpace::ContextStore::MAX_TRACKED_CONTEXTS) + refute_includes(Dexpace.constants(false), :BoundedMap, "Dexpace::BoundedMap is private") + refute_includes(Dexpace.constants(false), :CallKey, "Dexpace::CallKey is private") + assert_raises(::NameError) { Dexpace::BoundedMap } + assert_raises(::NameError) { Dexpace::CallKey } + end - # A consumer requires "dexpace" and nothing else: the byte-streaming layer resolves too - # (phase 3a). - test "requiring dexpace alone makes the whole streaming layer resolve" do - assert_equal(Dexpace::IO::Buffer, Dexpace::IO.const_get(:Buffer)) - assert_equal(Dexpace::IO::BufferedSource, Dexpace::IO.const_get(:BufferedSource)) - assert_equal(Dexpace::IO::BufferedSink, Dexpace::IO.const_get(:BufferedSink)) - assert_equal(Dexpace::IO::TeeSink, Dexpace::IO.const_get(:TeeSink)) - assert_equal(Dexpace::StreamError, Dexpace.const_get(:StreamError)) - assert_equal(Dexpace::EndOfStreamError, Dexpace.const_get(:EndOfStreamError)) + # A consumer requires "dexpace" and nothing else: the seam layer resolves too (phase 2). + test "requiring dexpace alone makes the whole seam layer resolve" do + assert_equal(Dexpace::Transport, Dexpace.const_get(:Transport)) + assert_equal(Dexpace::AsyncTransport, Dexpace.const_get(:AsyncTransport)) + assert_equal(Dexpace::Serde, Dexpace.const_get(:Serde)) + assert_equal(Dexpace::Operation, Dexpace.const_get(:Operation)) + assert_equal(Dexpace::Registry, Dexpace.const_get(:Registry)) + assert_equal(Dexpace::Cancellation, Dexpace.const_get(:Cancellation)) + assert_equal(Dexpace::Async::Future, Dexpace::Async.const_get(:Future)) + assert_equal(Dexpace::Closeable, Dexpace.const_get(:Closeable)) + refute_includes(Dexpace.constants(false), :Hooks, "Dexpace::Hooks is a private_constant") + assert_raises(::NameError) { Dexpace::Hooks } + end + + # A consumer requires "dexpace" and nothing else: the byte-streaming layer resolves too + # (phase 3a). + test "requiring dexpace alone makes the whole streaming layer resolve" do + assert_equal(Dexpace::IO::Buffer, Dexpace::IO.const_get(:Buffer)) + assert_equal(Dexpace::IO::BufferedSource, Dexpace::IO.const_get(:BufferedSource)) + assert_equal(Dexpace::IO::BufferedSink, Dexpace::IO.const_get(:BufferedSink)) + assert_equal(Dexpace::IO::TeeSink, Dexpace::IO.const_get(:TeeSink)) + assert_equal(Dexpace::StreamError, Dexpace.const_get(:StreamError)) + assert_equal(Dexpace::EndOfStreamError, Dexpace.const_get(:EndOfStreamError)) + end end # The shadowing names this SDK never defines: each would make a bare `rescue ArgumentError`, diff --git a/gems/dexpace-core/test/support/fake_context.rb b/gems/dexpace-core/test/support/fake_context.rb new file mode 100644 index 0000000..a43b3c4 --- /dev/null +++ b/gems/dexpace-core/test/support/fake_context.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../lib/dexpace/context" + +# The one double CTX's store suite needs: every store rule (CTX-7 through CTX-13, CTX-18, CTX-19) +# is about a keyed occupant and nothing else, so driving them through a real chain would couple +# the store's suite to a Bundle, a Request and a Response per case. A real in-memory +# implementation of Dexpace::Context, not a recorder of calls -- a fake by testing/7ecef8e8's +# definition, named Fake* per testing/630ba094. Gets #close free from the module, which is what +# makes the CTX-9/CTX-10 cases readable. It has no value equality (plain Object#==), which is why +# the CTX-9 trap needs a real Data context and lives beside it in context_store_test.rb. +# +# It lives in dexpace-core's test tree and is not public API, for the reason fake_transport.rb +# gives: a published fake is NFR-4-locked surface, and the move to dexpace-conformance was +# declined by phase 8a. +class FakeContext + include Dexpace::Context + + # @return [String] the store key this occupant registers under + # @return [#set, #release] the store #close releases through + attr_reader :call_key, :store + + def initialize(call_key:, store:) + @call_key = call_key + @store = store + end +end diff --git a/test/fixtures/surface/dexpace-core.txt b/test/fixtures/surface/dexpace-core.txt index d0ad1d9..43cdf5d 100644 --- a/test/fixtures/surface/dexpace-core.txt +++ b/test/fixtures/surface/dexpace-core.txt @@ -125,8 +125,34 @@ Dexpace::Closeable#closed? Dexpace::Closeable#initialize_closeable Dexpace::Closeable#owned? Dexpace::ClosedError +Dexpace::Context +Dexpace::Context#close +Dexpace::ContextConflictError +Dexpace::ContextConflictError#call_key +Dexpace::ContextStore +Dexpace::ContextStore#[] +Dexpace::ContextStore#put +Dexpace::ContextStore#release +Dexpace::ContextStore#set +Dexpace::ContextStore#size +Dexpace::ContextStore.default +Dexpace::ContextStore::MAX_TRACKED_CONTEXTS : Integer +Dexpace::DispatchContext +Dexpace::DispatchContext#bundle +Dexpace::DispatchContext#call_key +Dexpace::DispatchContext#promote_to_request +Dexpace::DispatchContext#store +Dexpace::DispatchContext.build Dexpace::EndOfStreamError Dexpace::Error +Dexpace::ExchangeContext +Dexpace::ExchangeContext#bundle +Dexpace::ExchangeContext#call_key +Dexpace::ExchangeContext#operation_name +Dexpace::ExchangeContext#request +Dexpace::ExchangeContext#response +Dexpace::ExchangeContext#store +Dexpace::ExchangeContext.build Dexpace::FileBody Dexpace::FileBody#== Dexpace::FileBody#content_length @@ -241,6 +267,37 @@ Dexpace::IO::TypedWrites#write_all Dexpace::IO::TypedWrites#write_from Dexpace::IO::TypedWrites#write_string Dexpace::IO::TypedWrites#write_utf8 +Dexpace::Instrumentation +Dexpace::Instrumentation::Bundle +Dexpace::Instrumentation::Bundle#flavour +Dexpace::Instrumentation::Bundle#remote +Dexpace::Instrumentation::Bundle#remote? +Dexpace::Instrumentation::Bundle#span +Dexpace::Instrumentation::Bundle#span_id +Dexpace::Instrumentation::Bundle#trace_flags +Dexpace::Instrumentation::Bundle#trace_id +Dexpace::Instrumentation::Bundle#trace_state +Dexpace::Instrumentation::Bundle#tracer_factory +Dexpace::Instrumentation::Bundle#valid? +Dexpace::Instrumentation::Bundle.build +Dexpace::Instrumentation::Bundle::INVALID_SPAN_ID : String +Dexpace::Instrumentation::Bundle::NONE : Dexpace::Instrumentation::Bundle +Dexpace::Instrumentation::NO_SPAN : Dexpace::Instrumentation::NoSpan +Dexpace::Instrumentation::NO_TRACER : Dexpace::Instrumentation::NoTracer +Dexpace::Instrumentation::NO_TRACER_FACTORY : Dexpace::Instrumentation::NoTracerFactory +Dexpace::Instrumentation::TraceIdFlavour +Dexpace::Instrumentation::TraceIdFlavour#invalid_trace_id +Dexpace::Instrumentation::TraceIdFlavour#max_value +Dexpace::Instrumentation::TraceIdFlavour#name +Dexpace::Instrumentation::TraceIdFlavour#renders? +Dexpace::Instrumentation::TraceIdFlavour#trace_id_pattern +Dexpace::Instrumentation::TraceIdFlavour#valid_trace_id? +Dexpace::Instrumentation::TraceIdFlavour.build +Dexpace::Instrumentation::TraceIdFlavour.of +Dexpace::Instrumentation::TraceIdFlavour::ALL : Array +Dexpace::Instrumentation::TraceIdFlavour::DATADOG : Dexpace::Instrumentation::TraceIdFlavour +Dexpace::Instrumentation::TraceIdFlavour::NONE : Dexpace::Instrumentation::TraceIdFlavour +Dexpace::Instrumentation::TraceIdFlavour::W3C : Dexpace::Instrumentation::TraceIdFlavour Dexpace::InvalidArgumentError Dexpace::MediaType Dexpace::MediaType#charset @@ -520,6 +577,14 @@ Dexpace::Request::Builder#header Dexpace::Request::Builder#headers= Dexpace::Request::Builder#method= Dexpace::Request::Builder#url= +Dexpace::RequestContext +Dexpace::RequestContext#bundle +Dexpace::RequestContext#call_key +Dexpace::RequestContext#operation_name +Dexpace::RequestContext#promote_to_exchange +Dexpace::RequestContext#request +Dexpace::RequestContext#store +Dexpace::RequestContext.build Dexpace::RequestLoggingBody Dexpace::RequestLoggingBody#== Dexpace::RequestLoggingBody#content_length diff --git a/test/gates/rubocop_config_test.rb b/test/gates/rubocop_config_test.rb index 0e1cd60..637cb38 100644 --- a/test/gates/rubocop_config_test.rb +++ b/test/gates/rubocop_config_test.rb @@ -35,6 +35,20 @@ class RubocopConfigTest < GateCase assert_equal("nested", CONFIG.dig("Style/ClassAndModuleChildren", "EnforcedStyle")) end + # Phase 4a (P4-10): CTX-19's cop is scoped to EVERY gem's lib/, not core alone, because + # gates:require_allowlist covers core's lib/ only and an adapter gem can hold a context too. + # The same scope as the two lib/-only cops before it; a narrowing to one gem is the mutation + # this test exists to catch. + test "Dexpace/NoWeakReferences is required, enabled, and reaches every gem's lib/" do + assert_includes(CONFIG.fetch("require"), "./.rubocop/cops/dexpace/no_weak_references.rb") + assert(CONFIG.dig("Dexpace/NoWeakReferences", "Enabled")) + assert_equal(["gems/*/lib/**/*.rb"], CONFIG.dig("Dexpace/NoWeakReferences", "Include")) + assert_equal( + CONFIG.dig("Dexpace/QualifiedCoreConstant", "Include"), + CONFIG.dig("Dexpace/NoWeakReferences", "Include"), + ) + end + test "the gate itself runs at --fail-level=convention with no autocorrection" do body = File.read(File.join(ROOT, "tasks/quality.rake")) # The `rubocop:fix` convenience in the same file is allowed to autocorrect; the gate is not.