From ce16c7a04661e34172d3e4b20d79f15b8df13483 Mon Sep 17 00:00:00 2001 From: Nitesh Purohit Date: Sat, 2 May 2026 11:51:05 -0400 Subject: [PATCH 01/13] feat: implement shared backend interface and selection model - Added a new Backend module to encapsulate backend selection logic. - Introduced classes for Capabilities, Descriptor, and InMemory backend. - Implemented a Selection class for normalizing backend identifiers. - Defined RBS types for backend identifiers and classifications. - Updated documentation to reflect new backend selection contracts. - Added tests for backend capabilities, descriptor, and selection logic. closes: #105 --- .../core-karya-rbs.instructions.md | 12 +++ .github/instructions/review.instructions.md | 10 ++ core/karya/lib/karya.rb | 1 + core/karya/lib/karya/backend.rb | 23 +++++ core/karya/lib/karya/backend/base.rb | 43 +++++++++ core/karya/lib/karya/backend/capabilities.rb | 76 +++++++++++++++ core/karya/lib/karya/backend/descriptor.rb | 59 ++++++++++++ core/karya/lib/karya/backend/in_memory.rb | 62 ++++++++++++ core/karya/lib/karya/backend/selection.rb | 95 +++++++++++++++++++ core/karya/sig/karya.rbs | 19 ++++ core/karya/sig/karya/backend.rbs | 15 +++ core/karya/sig/karya/backend/base.rbs | 13 +++ core/karya/sig/karya/backend/capabilities.rbs | 38 ++++++++ core/karya/sig/karya/backend/descriptor.rbs | 25 +++++ core/karya/sig/karya/backend/in_memory.rbs | 24 +++++ core/karya/sig/karya/backend/selection.rbs | 27 ++++++ core/karya/spec/karya/backend/base_spec.rb | 47 +++++++++ .../spec/karya/backend/capabilities_spec.rb | 91 ++++++++++++++++++ .../spec/karya/backend/descriptor_spec.rb | 45 +++++++++ .../spec/karya/backend/in_memory_spec.rb | 33 +++++++ .../spec/karya/backend/selection_spec.rb | 75 +++++++++++++++ 21 files changed, 833 insertions(+) create mode 100644 core/karya/lib/karya/backend.rb create mode 100644 core/karya/lib/karya/backend/base.rb create mode 100644 core/karya/lib/karya/backend/capabilities.rb create mode 100644 core/karya/lib/karya/backend/descriptor.rb create mode 100644 core/karya/lib/karya/backend/in_memory.rb create mode 100644 core/karya/lib/karya/backend/selection.rb create mode 100644 core/karya/sig/karya/backend.rbs create mode 100644 core/karya/sig/karya/backend/base.rbs create mode 100644 core/karya/sig/karya/backend/capabilities.rbs create mode 100644 core/karya/sig/karya/backend/descriptor.rbs create mode 100644 core/karya/sig/karya/backend/in_memory.rbs create mode 100644 core/karya/sig/karya/backend/selection.rbs create mode 100644 core/karya/spec/karya/backend/base_spec.rb create mode 100644 core/karya/spec/karya/backend/capabilities_spec.rb create mode 100644 core/karya/spec/karya/backend/descriptor_spec.rb create mode 100644 core/karya/spec/karya/backend/in_memory_spec.rb create mode 100644 core/karya/spec/karya/backend/selection_spec.rb diff --git a/.github/instructions/core-karya-rbs.instructions.md b/.github/instructions/core-karya-rbs.instructions.md index f27b8d5a..8a8c520e 100644 --- a/.github/instructions/core-karya-rbs.instructions.md +++ b/.github/instructions/core-karya-rbs.instructions.md @@ -37,6 +37,18 @@ RBS in this repository is a correctness contract. `private`, mirror that visibility change in the same RBS patch. - Match argument names, keyword names, optionality, return types, and nested module/class structure. +- Match the full accepted input surface before normalization. If Ruby accepts + aliases, alternative casing, delimiter variants, or `nil` before rejecting or + normalizing, model that accepted input in the RBS instead of only the + normalized canonical value. +- Do not collapse shared contracts to one concrete implementation's keyword + surface. Shared interfaces should model only the common contract they truly + guarantee, not adapter-local boot options copied from the first + implementation. +- Do not use broad keyword maps to dodge exactness. If Ruby requires specific + keys, rejects unknown keys, or distinguishes one optional keyword from + another, encode that explicitly instead of using a generic catch-all keyword + shape. - Remove stale entries for deleted methods, constants, and modules. - Do not use `untyped`, `any`, or other generic escape hatches where a concrete type is knowable. diff --git a/.github/instructions/review.instructions.md b/.github/instructions/review.instructions.md index b098558b..38f66188 100644 --- a/.github/instructions/review.instructions.md +++ b/.github/instructions/review.instructions.md @@ -207,6 +207,16 @@ For any Ruby change that has a mirrored file under `sig/`: - **Required:** Treat spec-layout drift as a review concern when extracted owner-local files leave all direct behavior buried only in a monolithic owner spec. +- **Required:** Mirror the accepted input surface, not just the normalized + output surface. If Ruby accepts aliases, mixed casing, delimiter variants, or + broader nilability before normalization, the RBS input type must reflect + that same accepted set. +- **Required:** Flag signatures that overfit one implementation while claiming + to model a shared contract. If a shared base/module type is narrowed to one + concrete adapter's keyword set or return posture, treat that as contract + drift even when the current implementation still passes tests. +- **Required:** Flag generic keyword or catch-all type shapes that hide runtime + rules about required keys, rejected keys, or known option names. ## Architecture Guidelines Review diff --git a/core/karya/lib/karya.rb b/core/karya/lib/karya.rb index 76eb2cf5..0f42c668 100644 --- a/core/karya/lib/karya.rb +++ b/core/karya/lib/karya.rb @@ -20,6 +20,7 @@ require_relative 'karya/outbound_events' require_relative 'karya/reservation' require_relative 'karya/queue_store' +require_relative 'karya/backend' require_relative 'karya/workflow' require_relative 'karya/constant_resolver' require_relative 'karya/worker' diff --git a/core/karya/lib/karya/backend.rb b/core/karya/lib/karya/backend.rb new file mode 100644 index 00000000..12857b23 --- /dev/null +++ b/core/karya/lib/karya/backend.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +# Copyright Codevedas Inc. 2025-present +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +module Karya + # Raised when backend selection input cannot be normalized into a supported identifier. + class InvalidBackendSelectionError < Error; end + + # Raised when a caller refers to a backend outside the supported backend set. + class UnsupportedBackendError < Error; end + + # Namespace for backend selection, capability, and lifecycle contracts. + module Backend + autoload :Base, 'karya/backend/base' + autoload :Capabilities, 'karya/backend/capabilities' + autoload :Descriptor, 'karya/backend/descriptor' + autoload :InMemory, 'karya/backend/in_memory' + autoload :Selection, 'karya/backend/selection' + end +end diff --git a/core/karya/lib/karya/backend/base.rb b/core/karya/lib/karya/backend/base.rb new file mode 100644 index 00000000..3bdb8f39 --- /dev/null +++ b/core/karya/lib/karya/backend/base.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# Copyright Codevedas Inc. 2025-present +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +module Karya + module Backend + # Shared backend contract above the queue-store persistence API. + module Base + def identifier + descriptor.identifier + end + + def classification + descriptor.classification + end + + def capabilities + descriptor.capabilities + end + + def descriptor + raise NotImplementedError, "#{self.class} must implement ##{__method__}" + end + + def build_queue_store + raise NotImplementedError, "#{self.class} must implement ##{__method__}" + end + + def before_start(queue_store:) + _queue_store = queue_store + nil + end + + def after_stop(queue_store:) + _queue_store = queue_store + nil + end + end + end +end diff --git a/core/karya/lib/karya/backend/capabilities.rb b/core/karya/lib/karya/backend/capabilities.rb new file mode 100644 index 00000000..baded6ef --- /dev/null +++ b/core/karya/lib/karya/backend/capabilities.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +# Copyright Codevedas Inc. 2025-present +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +module Karya + module Backend + # Immutable backend capability flags and documented parity exceptions. + class Capabilities + BOOLEAN_ATTRIBUTES = %i[ + job_persistence + workflow_state + schedule_state + audit_history + shared_processes + multi_node + ].freeze + ATTRIBUTE_NAMES = (BOOLEAN_ATTRIBUTES + [:parity_exceptions]).freeze + + attr_reader(*BOOLEAN_ATTRIBUTES, :parity_exceptions) + + def initialize(**attributes) + @attribute_names = attributes.keys.freeze + validate_attribute_names + + @job_persistence = required_boolean(:job_persistence, attributes) + @workflow_state = required_boolean(:workflow_state, attributes) + @schedule_state = required_boolean(:schedule_state, attributes) + @audit_history = required_boolean(:audit_history, attributes) + @shared_processes = required_boolean(:shared_processes, attributes) + @multi_node = required_boolean(:multi_node, attributes) + @parity_exceptions = normalize_parity_exceptions(attributes.fetch(:parity_exceptions, [])) + ensure + @attribute_names = nil + end + + BOOLEAN_ATTRIBUTES.each do |attribute_name| + alias_method "#{attribute_name}?", attribute_name + end + + private + + def required_boolean(name, attributes) + normalize_boolean(name, attributes.fetch(name) { raise InvalidBackendSelectionError, "#{name} must be provided" }) + end + + def validate_attribute_names + unknown_attributes = attribute_names - ATTRIBUTE_NAMES + return if unknown_attributes.empty? + + raise InvalidBackendSelectionError, "unknown capability attributes: #{unknown_attributes.join(', ')}" + end + + def normalize_boolean(name, value) + return value if [true, false].include?(value) + + raise InvalidBackendSelectionError, "#{name} must be boolean" + end + + def normalize_parity_exceptions(values) + raise InvalidBackendSelectionError, 'parity_exceptions must be an Array' unless values.is_a?(Array) + + values.map do |value| + normalized_value = value.to_s.strip + raise InvalidBackendSelectionError, 'parity_exceptions entries must be present' if normalized_value.empty? + + normalized_value.freeze + end.freeze + end + + attr_reader :attribute_names + end + end +end diff --git a/core/karya/lib/karya/backend/descriptor.rb b/core/karya/lib/karya/backend/descriptor.rb new file mode 100644 index 00000000..c75df948 --- /dev/null +++ b/core/karya/lib/karya/backend/descriptor.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +# Copyright Codevedas Inc. 2025-present +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +require_relative '../primitives/identifier' + +module Karya + module Backend + # Immutable backend identity and deployment posture description. + class Descriptor + CLASSIFICATIONS = %i[quick_setup_and_run production_like_local production_grade].freeze + + attr_reader :capabilities, :classification, :identifier + + def initialize(identifier:, classification:, capabilities:) + @identifier = Selection.normalize_identifier(identifier) + @classification = normalize_classification(classification) + @capabilities = normalize_capabilities(capabilities) + end + + def quick_setup_and_run? + classification == :quick_setup_and_run + end + + def production_like_local? + classification == :production_like_local + end + + def production_grade? + classification == :production_grade + end + + private + + def normalize_capabilities(value) + return value if value.is_a?(Capabilities) + + raise InvalidBackendSelectionError, 'capabilities must be a Karya::Backend::Capabilities' + end + + def normalize_classification(value) + normalized_value = value.to_sym + return normalized_value if CLASSIFICATIONS.include?(normalized_value) + + raise_invalid_classification_error + rescue NoMethodError + raise_invalid_classification_error + end + + def raise_invalid_classification_error + valid_classifications = CLASSIFICATIONS.map(&:inspect).join(', ') + raise InvalidBackendSelectionError, "classification must be one of #{valid_classifications}" + end + end + end +end diff --git a/core/karya/lib/karya/backend/in_memory.rb b/core/karya/lib/karya/backend/in_memory.rb new file mode 100644 index 00000000..f9ca8d87 --- /dev/null +++ b/core/karya/lib/karya/backend/in_memory.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +# Copyright Codevedas Inc. 2025-present +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +require_relative '../queue_store/in_memory' + +module Karya + module Backend + # Quick-start backend wrapper around the single-process reference queue store. + class InMemory + include Base + + def initialize(queue_store_class: QueueStore::InMemory) + @queue_store_class = queue_store_class + end + + CAPABILITIES = Capabilities.new( + job_persistence: false, + workflow_state: false, + schedule_state: false, + audit_history: false, + shared_processes: false, + multi_node: false, + parity_exceptions: [ + 'Jobs, workflow state, schedules, and audit history are process-local and lost on restart', + 'The backend is for quick setup, tests, and ephemeral local runs rather than production-grade deployments' + ] + ) + + DESCRIPTOR = Descriptor.new( + identifier: :in_memory, + classification: :quick_setup_and_run, + capabilities: CAPABILITIES + ) + + def descriptor + DESCRIPTOR + end + + def build_queue_store(**) + queue_store_class.new(**) + end + + def before_start(queue_store:) + _queue_store = queue_store + nil + end + + def after_stop(queue_store:) + _queue_store = queue_store + nil + end + + private + + attr_reader :queue_store_class + end + end +end diff --git a/core/karya/lib/karya/backend/selection.rb b/core/karya/lib/karya/backend/selection.rb new file mode 100644 index 00000000..d0d3d015 --- /dev/null +++ b/core/karya/lib/karya/backend/selection.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +# Copyright Codevedas Inc. 2025-present +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +require_relative '../primitives/identifier' + +module Karya + module Backend + # Normalized backend selection without runtime boot wiring. + class Selection + SUPPORTED_IDENTIFIERS = %w[in_memory sqlite redis postgres mysql].freeze + + IDENTIFIER_ALIASES = { + 'InMemory' => 'in_memory', + 'inmemory' => 'in_memory', + 'in_memory' => 'in_memory', + 'SQLite' => 'sqlite', + 'sqlite' => 'sqlite', + 'Redis' => 'redis', + 'redis' => 'redis', + 'Postgres' => 'postgres', + 'PostgreSQL' => 'postgres', + 'postgres' => 'postgres', + 'postgresql' => 'postgres', + 'MySQL' => 'mysql', + 'my_sql' => 'mysql', + 'mysql' => 'mysql' + }.freeze + + CLASSIFICATIONS = { + 'in_memory' => :quick_setup_and_run, + 'sqlite' => :production_like_local, + 'redis' => :production_grade, + 'postgres' => :production_grade, + 'mysql' => :production_grade + }.freeze + + attr_reader :identifier + + def self.normalize_identifier(value) + normalized_input = Primitives::Identifier.new(:backend, value, error_class: InvalidBackendSelectionError).normalize + normalized_alias = IDENTIFIER_ALIASES[normalized_input] + return normalized_alias.freeze if normalized_alias + + raise UnsupportedBackendError, + "unsupported backend #{normalized_input.inspect}; supported backends: #{SUPPORTED_IDENTIFIERS.join(', ')}" + end + + def self.supported_identifier?(value) + SUPPORTED_IDENTIFIERS.include?(normalize_identifier(value)) + rescue InvalidBackendSelectionError, UnsupportedBackendError + false + end + + def self.classification_for(value) + CLASSIFICATIONS.fetch(normalize_identifier(value)) + end + + def self.quick_setup_and_run?(value) + classification_for(value) == :quick_setup_and_run + end + + def self.production_like_local?(value) + classification_for(value) == :production_like_local + end + + def self.production_grade?(value) + classification_for(value) == :production_grade + end + + def initialize(value) + @identifier = self.class.normalize_identifier(value) + end + + def classification + self.class.classification_for(identifier) + end + + def quick_setup_and_run? + classification == :quick_setup_and_run + end + + def production_like_local? + classification == :production_like_local + end + + def production_grade? + classification == :production_grade + end + end + end +end diff --git a/core/karya/sig/karya.rbs b/core/karya/sig/karya.rbs index 75b369b0..3489a15d 100644 --- a/core/karya/sig/karya.rbs +++ b/core/karya/sig/karya.rbs @@ -440,6 +440,25 @@ module Karya type symbol_options = ::Hash[Symbol, option_value] type mixed_options = ::Hash[Symbol | String, option_value] type job_arguments = ::Hash[String, job_argument] + type backend_identifier = :in_memory | :sqlite | :redis | :postgres | :mysql + type backend_identifier_input = + backend_identifier | + "inmemory" | + "in_memory" | + "sqlite" | + "redis" | + "postgres" | + "postgresql" | + "mysql" | + "my_sql" | + "InMemory" | + "SQLite" | + "Redis" | + "Postgres" | + "PostgreSQL" | + "MySQL" + type optional_backend_identifier_input = backend_identifier_input | nil + type backend_classification = :quick_setup_and_run | :production_like_local | :production_grade type error_class = singleton(StandardError) type logger = Internal::_Logger type instrumenter = ^(String, context_payload) -> nil diff --git a/core/karya/sig/karya/backend.rbs b/core/karya/sig/karya/backend.rbs new file mode 100644 index 00000000..1624eb20 --- /dev/null +++ b/core/karya/sig/karya/backend.rbs @@ -0,0 +1,15 @@ +# Copyright Codevedas Inc. 2025-present +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +module Karya + class InvalidBackendSelectionError < Error + end + + class UnsupportedBackendError < Error + end + + module Backend + end +end diff --git a/core/karya/sig/karya/backend/base.rbs b/core/karya/sig/karya/backend/base.rbs new file mode 100644 index 00000000..124596a1 --- /dev/null +++ b/core/karya/sig/karya/backend/base.rbs @@ -0,0 +1,13 @@ +module Karya + module Backend + module Base + def identifier: () -> String + def classification: () -> backend_classification + def capabilities: () -> Capabilities + def descriptor: () -> Descriptor + def build_queue_store: () -> QueueStore::Base + def before_start: (queue_store: QueueStore::Base) -> nil + def after_stop: (queue_store: QueueStore::Base) -> nil + end + end +end diff --git a/core/karya/sig/karya/backend/capabilities.rbs b/core/karya/sig/karya/backend/capabilities.rbs new file mode 100644 index 00000000..3c90400a --- /dev/null +++ b/core/karya/sig/karya/backend/capabilities.rbs @@ -0,0 +1,38 @@ +module Karya + module Backend + class Capabilities + @job_persistence: bool + @workflow_state: bool + @schedule_state: bool + @audit_history: bool + @shared_processes: bool + @multi_node: bool + @parity_exceptions: Array[String] + + def initialize: ( + job_persistence: bool, + workflow_state: bool, + schedule_state: bool, + audit_history: bool, + shared_processes: bool, + multi_node: bool, + ?parity_exceptions: Array[String] + ) -> void + + attr_reader job_persistence: bool + attr_reader workflow_state: bool + attr_reader schedule_state: bool + attr_reader audit_history: bool + attr_reader shared_processes: bool + attr_reader multi_node: bool + attr_reader parity_exceptions: Array[String] + + def job_persistence?: () -> bool + def workflow_state?: () -> bool + def schedule_state?: () -> bool + def audit_history?: () -> bool + def shared_processes?: () -> bool + def multi_node?: () -> bool + end + end +end diff --git a/core/karya/sig/karya/backend/descriptor.rbs b/core/karya/sig/karya/backend/descriptor.rbs new file mode 100644 index 00000000..e1142670 --- /dev/null +++ b/core/karya/sig/karya/backend/descriptor.rbs @@ -0,0 +1,25 @@ +module Karya + module Backend + class Descriptor + CLASSIFICATIONS: Array[backend_classification] + + @identifier: String + @classification: backend_classification + @capabilities: Capabilities + + def initialize: ( + identifier: backend_identifier_input, + classification: backend_classification, + capabilities: Capabilities + ) -> void + + attr_reader identifier: String + attr_reader classification: backend_classification + attr_reader capabilities: Capabilities + + def quick_setup_and_run?: () -> bool + def production_like_local?: () -> bool + def production_grade?: () -> bool + end + end +end diff --git a/core/karya/sig/karya/backend/in_memory.rbs b/core/karya/sig/karya/backend/in_memory.rbs new file mode 100644 index 00000000..0e8dfbb6 --- /dev/null +++ b/core/karya/sig/karya/backend/in_memory.rbs @@ -0,0 +1,24 @@ +module Karya + module Backend + class InMemory + include Base + + CAPABILITIES: Capabilities + DESCRIPTOR: Descriptor + + def initialize: (?queue_store_class: singleton(QueueStore::InMemory)) -> void + def descriptor: () -> Descriptor + def build_queue_store: ( + ?token_generator: Karya::callable_value, + ?expired_tombstone_limit: Integer, + ?completed_batch_retention_limit: Integer, + ?max_batch_size: Integer, + ?policy_set: Backpressure::PolicySet, + ?circuit_breaker_policy_set: CircuitBreaker::PolicySet, + ?fairness_policy: Fairness::Policy + ) -> QueueStore::InMemory + def before_start: (queue_store: QueueStore::Base) -> nil + def after_stop: (queue_store: QueueStore::Base) -> nil + end + end +end diff --git a/core/karya/sig/karya/backend/selection.rbs b/core/karya/sig/karya/backend/selection.rbs new file mode 100644 index 00000000..94761f9b --- /dev/null +++ b/core/karya/sig/karya/backend/selection.rbs @@ -0,0 +1,27 @@ +module Karya + module Backend + class Selection + SUPPORTED_IDENTIFIERS: Array[String] + IDENTIFIER_ALIASES: Hash[String, String] + CLASSIFICATIONS: Hash[String, backend_classification] + + @identifier: String + + def self.normalize_identifier: (backend_identifier_input value) -> String + def self.supported_identifier?: (optional_backend_identifier_input value) -> bool + def self.classification_for: (backend_identifier_input value) -> backend_classification + def self.quick_setup_and_run?: (backend_identifier_input value) -> bool + def self.production_like_local?: (backend_identifier_input value) -> bool + def self.production_grade?: (backend_identifier_input value) -> bool + + def initialize: (backend_identifier_input value) -> void + + attr_reader identifier: String + + def classification: () -> backend_classification + def quick_setup_and_run?: () -> bool + def production_like_local?: () -> bool + def production_grade?: () -> bool + end + end +end diff --git a/core/karya/spec/karya/backend/base_spec.rb b/core/karya/spec/karya/backend/base_spec.rb new file mode 100644 index 00000000..8cc0d2a0 --- /dev/null +++ b/core/karya/spec/karya/backend/base_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +RSpec.describe Karya::Backend::Base do + subject(:backend) { implementation.new } + + let(:implementation) do + Class.new do + include Karya::Backend::Base + end + end + + it 'requires descriptor to be implemented' do + expect { backend.descriptor }.to raise_error(NotImplementedError, /implement #descriptor/) + end + + it 'requires build_queue_store to be implemented' do + expect { backend.build_queue_store }.to raise_error(NotImplementedError, /implement #build_queue_store/) + end + + it 'provides no-op lifecycle hooks by default' do + queue_store = instance_double(Karya::QueueStore::Base) + + expect(backend.before_start(queue_store:)).to be_nil + expect(backend.after_stop(queue_store:)).to be_nil + end + + it 'delegates identifier, classification, and capabilities through the descriptor' do + capabilities = instance_double(Karya::Backend::Capabilities) + descriptor = instance_double( + Karya::Backend::Descriptor, + identifier: 'in_memory', + classification: :quick_setup_and_run, + capabilities: + ) + backend_class = Class.new do + include Karya::Backend::Base + + define_method(:descriptor) { descriptor } + end + + delegating_backend = backend_class.new + + expect(delegating_backend.identifier).to eq('in_memory') + expect(delegating_backend.classification).to eq(:quick_setup_and_run) + expect(delegating_backend.capabilities).to be(capabilities) + end +end diff --git a/core/karya/spec/karya/backend/capabilities_spec.rb b/core/karya/spec/karya/backend/capabilities_spec.rb new file mode 100644 index 00000000..8a34aeb2 --- /dev/null +++ b/core/karya/spec/karya/backend/capabilities_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +RSpec.describe Karya::Backend::Capabilities do + it 'stores backend capability flags and parity exceptions immutably' do + capabilities = described_class.new( + job_persistence: true, + workflow_state: true, + schedule_state: false, + audit_history: true, + shared_processes: true, + multi_node: false, + parity_exceptions: ['Local deployments stay single-node'] + ) + + expect(capabilities.job_persistence?).to be(true) + expect(capabilities.workflow_state?).to be(true) + expect(capabilities.schedule_state?).to be(false) + expect(capabilities.audit_history?).to be(true) + expect(capabilities.shared_processes?).to be(true) + expect(capabilities.multi_node?).to be(false) + expect(capabilities.parity_exceptions).to eq(['Local deployments stay single-node']) + expect(capabilities.parity_exceptions).to be_frozen + end + + it 'rejects non-boolean capability flags' do + expect do + described_class.new( + job_persistence: 'yes', + workflow_state: true, + schedule_state: true, + audit_history: true, + shared_processes: true, + multi_node: true + ) + end.to raise_error(Karya::InvalidBackendSelectionError, /job_persistence must be boolean/) + end + + it 'rejects missing required capability flags' do + expect do + described_class.new( + workflow_state: true, + schedule_state: true, + audit_history: true, + shared_processes: true, + multi_node: true + ) + end.to raise_error(Karya::InvalidBackendSelectionError, /job_persistence must be provided/) + end + + it 'rejects blank parity exceptions' do + expect do + described_class.new( + job_persistence: true, + workflow_state: true, + schedule_state: true, + audit_history: true, + shared_processes: true, + multi_node: true, + parity_exceptions: [' '] + ) + end.to raise_error(Karya::InvalidBackendSelectionError, /parity_exceptions entries must be present/) + end + + it 'rejects non-array parity exceptions' do + expect do + described_class.new( + job_persistence: true, + workflow_state: true, + schedule_state: true, + audit_history: true, + shared_processes: true, + multi_node: true, + parity_exceptions: 'local only' + ) + end.to raise_error(Karya::InvalidBackendSelectionError, /parity_exceptions must be an Array/) + end + + it 'rejects unknown capability attributes' do + expect do + described_class.new( + job_persistence: true, + workflow_state: true, + schedule_state: true, + audit_history: true, + shared_processes: true, + multi_node: true, + durable: true + ) + end.to raise_error(Karya::InvalidBackendSelectionError, /unknown capability attributes: durable/) + end +end diff --git a/core/karya/spec/karya/backend/descriptor_spec.rb b/core/karya/spec/karya/backend/descriptor_spec.rb new file mode 100644 index 00000000..37e5e6ff --- /dev/null +++ b/core/karya/spec/karya/backend/descriptor_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +RSpec.describe Karya::Backend::Descriptor do + let(:capabilities) do + Karya::Backend::Capabilities.new( + job_persistence: true, + workflow_state: true, + schedule_state: true, + audit_history: true, + shared_processes: true, + multi_node: true + ) + end + + it 'normalizes the backend identifier and stores the classification' do + descriptor = described_class.new( + identifier: ' InMemory ', + classification: :quick_setup_and_run, + capabilities: + ) + + expect(descriptor.identifier).to eq('in_memory') + expect(descriptor.quick_setup_and_run?).to be(true) + expect(descriptor.production_like_local?).to be(false) + expect(descriptor.production_grade?).to be(false) + end + + it 'rejects unsupported classifications' do + expect do + described_class.new(identifier: :redis, classification: :local, capabilities:) + end.to raise_error(Karya::InvalidBackendSelectionError, /classification must be one of/) + end + + it 'rejects non-symbolizable classifications' do + expect do + described_class.new(identifier: :redis, classification: Object.new, capabilities:) + end.to raise_error(Karya::InvalidBackendSelectionError, /classification must be one of/) + end + + it 'requires a backend capabilities object' do + expect do + described_class.new(identifier: :redis, classification: :production_grade, capabilities: Object.new) + end.to raise_error(Karya::InvalidBackendSelectionError, /capabilities must be a Karya::Backend::Capabilities/) + end +end diff --git a/core/karya/spec/karya/backend/in_memory_spec.rb b/core/karya/spec/karya/backend/in_memory_spec.rb new file mode 100644 index 00000000..f15e7b22 --- /dev/null +++ b/core/karya/spec/karya/backend/in_memory_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +RSpec.describe Karya::Backend::InMemory do + subject(:backend) { described_class.new } + + it 'exposes the quick setup descriptor and non-durable capability posture' do + descriptor = backend.descriptor + + expect(descriptor.identifier).to eq('in_memory') + expect(descriptor.classification).to eq(:quick_setup_and_run) + expect(descriptor.quick_setup_and_run?).to be(true) + expect(descriptor.capabilities.job_persistence?).to be(false) + expect(descriptor.capabilities.workflow_state?).to be(false) + expect(descriptor.capabilities.schedule_state?).to be(false) + expect(descriptor.capabilities.audit_history?).to be(false) + expect(descriptor.capabilities.shared_processes?).to be(false) + expect(descriptor.capabilities.multi_node?).to be(false) + expect(descriptor.capabilities.parity_exceptions).not_to be_empty + end + + it 'builds the queue store provider owned by the backend definition' do + queue_store = backend.build_queue_store + + expect(queue_store).to be_a(Karya::QueueStore::InMemory) + end + + it 'declares no-op lifecycle hooks around queue-store usage' do + queue_store = backend.build_queue_store + + expect(backend.before_start(queue_store:)).to be_nil + expect(backend.after_stop(queue_store:)).to be_nil + end +end diff --git a/core/karya/spec/karya/backend/selection_spec.rb b/core/karya/spec/karya/backend/selection_spec.rb new file mode 100644 index 00000000..62646bbe --- /dev/null +++ b/core/karya/spec/karya/backend/selection_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +RSpec.describe Karya::Backend::Selection do + it 'normalizes supported backend identifiers' do + expect(described_class.normalize_identifier(:in_memory)).to eq('in_memory') + expect(described_class.normalize_identifier('inmemory')).to eq('in_memory') + expect(described_class.normalize_identifier('InMemory')).to eq('in_memory') + expect(described_class.normalize_identifier('PostgreSQL')).to eq('postgres') + expect(described_class.normalize_identifier('MySQL')).to eq('mysql') + end + + it 'rejects blank backend input' do + expect do + described_class.normalize_identifier(' ') + end.to raise_error(Karya::InvalidBackendSelectionError, /backend must be present/) + end + + it 'rejects unsupported backends' do + expect do + described_class.normalize_identifier('mongodb') + end.to raise_error(Karya::UnsupportedBackendError, /unsupported backend "mongodb"/) + end + + it 'rejects undocumented delimiter variants' do + expect do + described_class.normalize_identifier('in-memory') + end.to raise_error(Karya::UnsupportedBackendError, /unsupported backend "in-memory"/) + end + + it 'classifies inmemory as quick setup and run' do + selection = described_class.new('InMemory') + + expect(selection.identifier).to eq('in_memory') + expect(selection.classification).to eq(:quick_setup_and_run) + expect(selection.quick_setup_and_run?).to be(true) + expect(selection.production_like_local?).to be(false) + expect(selection.production_grade?).to be(false) + end + + it 'exposes the class-level quick setup predicate' do + expect(described_class.quick_setup_and_run?('InMemory')).to be(true) + end + + it 'classifies sqlite as production-like but local' do + selection = described_class.new('SQLite') + + expect(selection.identifier).to eq('sqlite') + expect(selection.classification).to eq(:production_like_local) + expect(selection.quick_setup_and_run?).to be(false) + expect(selection.production_like_local?).to be(true) + expect(selection.production_grade?).to be(false) + end + + it 'exposes the class-level production-like-local predicate' do + expect(described_class.production_like_local?('SQLite')).to be(true) + end + + it 'classifies redis, postgres, and mysql as production-grade' do + %w[Redis Postgres MySQL].each do |identifier| + selection = described_class.new(identifier) + + expect(selection.classification).to eq(:production_grade) + expect(selection.production_grade?).to be(true) + end + end + + it 'exposes the class-level production-grade predicate' do + expect(described_class.production_grade?('Postgres')).to be(true) + end + + it 'reports whether an identifier is supported' do + expect(described_class.supported_identifier?('postgres')).to be(true) + expect(described_class.supported_identifier?('mongodb')).to be(false) + end +end From 3fda5e6aba3a947b6c9eff6f50d60d006178e8a5 Mon Sep 17 00:00:00 2001 From: Nitesh Purohit Date: Sat, 2 May 2026 12:07:10 -0400 Subject: [PATCH 02/13] feat: refine backend selection model and identifiers --- core/karya/lib/karya/backend/selection.rb | 15 +------ core/karya/sig/karya.rbs | 25 +++-------- core/karya/sig/karya/backend/descriptor.rbs | 2 +- core/karya/sig/karya/backend/in_memory.rbs | 16 ++++++- core/karya/sig/karya/backend/selection.rbs | 2 +- .../spec/karya/backend/descriptor_spec.rb | 16 +++++-- .../spec/karya/backend/in_memory_spec.rb | 10 +++++ .../spec/karya/backend/selection_spec.rb | 43 ++++++++----------- 8 files changed, 67 insertions(+), 62 deletions(-) diff --git a/core/karya/lib/karya/backend/selection.rb b/core/karya/lib/karya/backend/selection.rb index d0d3d015..e649a26f 100644 --- a/core/karya/lib/karya/backend/selection.rb +++ b/core/karya/lib/karya/backend/selection.rb @@ -11,23 +11,12 @@ module Karya module Backend # Normalized backend selection without runtime boot wiring. class Selection - SUPPORTED_IDENTIFIERS = %w[in_memory sqlite redis postgres mysql].freeze + SUPPORTED_IDENTIFIERS = %w[in_memory].freeze IDENTIFIER_ALIASES = { 'InMemory' => 'in_memory', 'inmemory' => 'in_memory', - 'in_memory' => 'in_memory', - 'SQLite' => 'sqlite', - 'sqlite' => 'sqlite', - 'Redis' => 'redis', - 'redis' => 'redis', - 'Postgres' => 'postgres', - 'PostgreSQL' => 'postgres', - 'postgres' => 'postgres', - 'postgresql' => 'postgres', - 'MySQL' => 'mysql', - 'my_sql' => 'mysql', - 'mysql' => 'mysql' + 'in_memory' => 'in_memory' }.freeze CLASSIFICATIONS = { diff --git a/core/karya/sig/karya.rbs b/core/karya/sig/karya.rbs index 3489a15d..c1c33792 100644 --- a/core/karya/sig/karya.rbs +++ b/core/karya/sig/karya.rbs @@ -440,25 +440,14 @@ module Karya type symbol_options = ::Hash[Symbol, option_value] type mixed_options = ::Hash[Symbol | String, option_value] type job_arguments = ::Hash[String, job_argument] - type backend_identifier = :in_memory | :sqlite | :redis | :postgres | :mysql - type backend_identifier_input = - backend_identifier | - "inmemory" | - "in_memory" | - "sqlite" | - "redis" | - "postgres" | - "postgresql" | - "mysql" | - "my_sql" | - "InMemory" | - "SQLite" | - "Redis" | - "Postgres" | - "PostgreSQL" | - "MySQL" - type optional_backend_identifier_input = backend_identifier_input | nil + type backend_identifier = :in_memory + type backend_identifier_input = state_name? type backend_classification = :quick_setup_and_run | :production_like_local | :production_grade + type backend_classification_input = + backend_classification | + "quick_setup_and_run" | + "production_like_local" | + "production_grade" type error_class = singleton(StandardError) type logger = Internal::_Logger type instrumenter = ^(String, context_payload) -> nil diff --git a/core/karya/sig/karya/backend/descriptor.rbs b/core/karya/sig/karya/backend/descriptor.rbs index e1142670..77bc265f 100644 --- a/core/karya/sig/karya/backend/descriptor.rbs +++ b/core/karya/sig/karya/backend/descriptor.rbs @@ -9,7 +9,7 @@ module Karya def initialize: ( identifier: backend_identifier_input, - classification: backend_classification, + classification: backend_classification_input, capabilities: Capabilities ) -> void diff --git a/core/karya/sig/karya/backend/in_memory.rbs b/core/karya/sig/karya/backend/in_memory.rbs index 0e8dfbb6..3fd787bd 100644 --- a/core/karya/sig/karya/backend/in_memory.rbs +++ b/core/karya/sig/karya/backend/in_memory.rbs @@ -1,12 +1,24 @@ module Karya module Backend + interface _QueueStoreFactory + def new: ( + ?token_generator: Karya::callable_value, + ?expired_tombstone_limit: Integer, + ?completed_batch_retention_limit: Integer, + ?max_batch_size: Integer, + ?policy_set: Backpressure::PolicySet, + ?circuit_breaker_policy_set: CircuitBreaker::PolicySet, + ?fairness_policy: Fairness::Policy + ) -> QueueStore::Base + end + class InMemory include Base CAPABILITIES: Capabilities DESCRIPTOR: Descriptor - def initialize: (?queue_store_class: singleton(QueueStore::InMemory)) -> void + def initialize: (?queue_store_class: _QueueStoreFactory) -> void def descriptor: () -> Descriptor def build_queue_store: ( ?token_generator: Karya::callable_value, @@ -16,7 +28,7 @@ module Karya ?policy_set: Backpressure::PolicySet, ?circuit_breaker_policy_set: CircuitBreaker::PolicySet, ?fairness_policy: Fairness::Policy - ) -> QueueStore::InMemory + ) -> QueueStore::Base def before_start: (queue_store: QueueStore::Base) -> nil def after_stop: (queue_store: QueueStore::Base) -> nil end diff --git a/core/karya/sig/karya/backend/selection.rbs b/core/karya/sig/karya/backend/selection.rbs index 94761f9b..d7cae69c 100644 --- a/core/karya/sig/karya/backend/selection.rbs +++ b/core/karya/sig/karya/backend/selection.rbs @@ -8,7 +8,7 @@ module Karya @identifier: String def self.normalize_identifier: (backend_identifier_input value) -> String - def self.supported_identifier?: (optional_backend_identifier_input value) -> bool + def self.supported_identifier?: (backend_identifier_input value) -> bool def self.classification_for: (backend_identifier_input value) -> backend_classification def self.quick_setup_and_run?: (backend_identifier_input value) -> bool def self.production_like_local?: (backend_identifier_input value) -> bool diff --git a/core/karya/spec/karya/backend/descriptor_spec.rb b/core/karya/spec/karya/backend/descriptor_spec.rb index 37e5e6ff..d33fbadf 100644 --- a/core/karya/spec/karya/backend/descriptor_spec.rb +++ b/core/karya/spec/karya/backend/descriptor_spec.rb @@ -25,21 +25,31 @@ expect(descriptor.production_grade?).to be(false) end + it 'accepts string classifications that Ruby normalizes to symbols' do + descriptor = described_class.new( + identifier: 'inmemory', + classification: 'quick_setup_and_run', + capabilities: + ) + + expect(descriptor.classification).to eq(:quick_setup_and_run) + end + it 'rejects unsupported classifications' do expect do - described_class.new(identifier: :redis, classification: :local, capabilities:) + described_class.new(identifier: :in_memory, classification: :local, capabilities:) end.to raise_error(Karya::InvalidBackendSelectionError, /classification must be one of/) end it 'rejects non-symbolizable classifications' do expect do - described_class.new(identifier: :redis, classification: Object.new, capabilities:) + described_class.new(identifier: :in_memory, classification: Object.new, capabilities:) end.to raise_error(Karya::InvalidBackendSelectionError, /classification must be one of/) end it 'requires a backend capabilities object' do expect do - described_class.new(identifier: :redis, classification: :production_grade, capabilities: Object.new) + described_class.new(identifier: :in_memory, classification: :production_grade, capabilities: Object.new) end.to raise_error(Karya::InvalidBackendSelectionError, /capabilities must be a Karya::Backend::Capabilities/) end end diff --git a/core/karya/spec/karya/backend/in_memory_spec.rb b/core/karya/spec/karya/backend/in_memory_spec.rb index f15e7b22..0ebe2930 100644 --- a/core/karya/spec/karya/backend/in_memory_spec.rb +++ b/core/karya/spec/karya/backend/in_memory_spec.rb @@ -24,6 +24,16 @@ expect(queue_store).to be_a(Karya::QueueStore::InMemory) end + it 'allows an injected queue store factory that returns a queue store base' do + queue_store = Karya::QueueStore::InMemory.new + queue_store_factory = Class.new do + define_singleton_method(:new) { |**| queue_store } + end + backend = described_class.new(queue_store_class: queue_store_factory) + + expect(backend.build_queue_store).to be(queue_store) + end + it 'declares no-op lifecycle hooks around queue-store usage' do queue_store = backend.build_queue_store diff --git a/core/karya/spec/karya/backend/selection_spec.rb b/core/karya/spec/karya/backend/selection_spec.rb index 62646bbe..3df88b43 100644 --- a/core/karya/spec/karya/backend/selection_spec.rb +++ b/core/karya/spec/karya/backend/selection_spec.rb @@ -5,8 +5,6 @@ expect(described_class.normalize_identifier(:in_memory)).to eq('in_memory') expect(described_class.normalize_identifier('inmemory')).to eq('in_memory') expect(described_class.normalize_identifier('InMemory')).to eq('in_memory') - expect(described_class.normalize_identifier('PostgreSQL')).to eq('postgres') - expect(described_class.normalize_identifier('MySQL')).to eq('mysql') end it 'rejects blank backend input' do @@ -27,6 +25,14 @@ end.to raise_error(Karya::UnsupportedBackendError, /unsupported backend "in-memory"/) end + it 'rejects backends without a defined backend implementation' do + %w[sqlite redis postgres mysql].each do |identifier| + expect do + described_class.normalize_identifier(identifier) + end.to raise_error(Karya::UnsupportedBackendError, /unsupported backend/) + end + end + it 'classifies inmemory as quick setup and run' do selection = described_class.new('InMemory') @@ -41,35 +47,24 @@ expect(described_class.quick_setup_and_run?('InMemory')).to be(true) end - it 'classifies sqlite as production-like but local' do - selection = described_class.new('SQLite') - - expect(selection.identifier).to eq('sqlite') - expect(selection.classification).to eq(:production_like_local) - expect(selection.quick_setup_and_run?).to be(false) - expect(selection.production_like_local?).to be(true) - expect(selection.production_grade?).to be(false) + it 'exposes the class-level production-like-local predicate for planned classifications' do + expect(described_class.production_like_local?('in_memory')).to be(false) end - it 'exposes the class-level production-like-local predicate' do - expect(described_class.production_like_local?('SQLite')).to be(true) - end - - it 'classifies redis, postgres, and mysql as production-grade' do - %w[Redis Postgres MySQL].each do |identifier| - selection = described_class.new(identifier) - - expect(selection.classification).to eq(:production_grade) - expect(selection.production_grade?).to be(true) - end + it 'exposes the class-level production-grade predicate for planned classifications' do + expect(described_class.production_grade?('in_memory')).to be(false) end - it 'exposes the class-level production-grade predicate' do - expect(described_class.production_grade?('Postgres')).to be(true) + it 'keeps planned deployment classifications internal until those backends exist' do + expect(described_class::CLASSIFICATIONS.fetch('sqlite')).to eq(:production_like_local) + expect(described_class::CLASSIFICATIONS.fetch('redis')).to eq(:production_grade) + expect(described_class::CLASSIFICATIONS.fetch('postgres')).to eq(:production_grade) + expect(described_class::CLASSIFICATIONS.fetch('mysql')).to eq(:production_grade) end it 'reports whether an identifier is supported' do - expect(described_class.supported_identifier?('postgres')).to be(true) + expect(described_class.supported_identifier?('inmemory')).to be(true) + expect(described_class.supported_identifier?('postgres')).to be(false) expect(described_class.supported_identifier?('mongodb')).to be(false) end end From f305c5a31700cfc7c9dda96622cf116b9b17e037 Mon Sep 17 00:00:00 2001 From: Nitesh Purohit Date: Sat, 2 May 2026 12:23:46 -0400 Subject: [PATCH 03/13] feat: enhance backend identifier normalization --- core/karya/lib/karya/backend/selection.rb | 11 ++++++++++- core/karya/spec/karya/backend/selection_spec.rb | 9 +++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/core/karya/lib/karya/backend/selection.rb b/core/karya/lib/karya/backend/selection.rb index e649a26f..000802cb 100644 --- a/core/karya/lib/karya/backend/selection.rb +++ b/core/karya/lib/karya/backend/selection.rb @@ -30,7 +30,7 @@ class Selection attr_reader :identifier def self.normalize_identifier(value) - normalized_input = Primitives::Identifier.new(:backend, value, error_class: InvalidBackendSelectionError).normalize + normalized_input = normalize_identifier_input(value) normalized_alias = IDENTIFIER_ALIASES[normalized_input] return normalized_alias.freeze if normalized_alias @@ -79,6 +79,15 @@ def production_like_local? def production_grade? classification == :production_grade end + + def self.normalize_identifier_input(value) + if [NilClass, String, Symbol].any? { |klass| value.is_a?(klass) } + return Primitives::Identifier.new(:backend, value, error_class: InvalidBackendSelectionError).normalize + end + + raise InvalidBackendSelectionError, 'backend must be a String or Symbol' + end + private_class_method :normalize_identifier_input end end end diff --git a/core/karya/spec/karya/backend/selection_spec.rb b/core/karya/spec/karya/backend/selection_spec.rb index 3df88b43..a7e22986 100644 --- a/core/karya/spec/karya/backend/selection_spec.rb +++ b/core/karya/spec/karya/backend/selection_spec.rb @@ -13,6 +13,14 @@ end.to raise_error(Karya::InvalidBackendSelectionError, /backend must be present/) end + it 'rejects non string-or-symbol backend input before normalization' do + [42, true, Time.now].each do |value| + expect do + described_class.normalize_identifier(value) + end.to raise_error(Karya::InvalidBackendSelectionError, /backend must be a String or Symbol/) + end + end + it 'rejects unsupported backends' do expect do described_class.normalize_identifier('mongodb') @@ -66,5 +74,6 @@ expect(described_class.supported_identifier?('inmemory')).to be(true) expect(described_class.supported_identifier?('postgres')).to be(false) expect(described_class.supported_identifier?('mongodb')).to be(false) + expect(described_class.supported_identifier?(42)).to be(false) end end From e4295fcdd31e10b563e5006119bd839c6ea73184 Mon Sep 17 00:00:00 2001 From: Nitesh Purohit Date: Sat, 2 May 2026 12:37:35 -0400 Subject: [PATCH 04/13] feat: enhance backend capabilities and descriptor validation --- core/karya/lib/karya/backend/capabilities.rb | 16 ++++++++-------- core/karya/lib/karya/backend/descriptor.rb | 10 ++++++++++ core/karya/sig/karya.rbs | 2 +- core/karya/sig/karya/backend/descriptor.rbs | 4 ++-- core/karya/sig/karya/backend/selection.rbs | 6 +++--- .../spec/karya/backend/capabilities_spec.rb | 1 + core/karya/spec/karya/backend/descriptor_spec.rb | 10 ++++++++++ 7 files changed, 35 insertions(+), 14 deletions(-) diff --git a/core/karya/lib/karya/backend/capabilities.rb b/core/karya/lib/karya/backend/capabilities.rb index baded6ef..0a702670 100644 --- a/core/karya/lib/karya/backend/capabilities.rb +++ b/core/karya/lib/karya/backend/capabilities.rb @@ -22,8 +22,7 @@ class Capabilities attr_reader(*BOOLEAN_ATTRIBUTES, :parity_exceptions) def initialize(**attributes) - @attribute_names = attributes.keys.freeze - validate_attribute_names + validate_attribute_names(attributes) @job_persistence = required_boolean(:job_persistence, attributes) @workflow_state = required_boolean(:workflow_state, attributes) @@ -32,8 +31,7 @@ def initialize(**attributes) @shared_processes = required_boolean(:shared_processes, attributes) @multi_node = required_boolean(:multi_node, attributes) @parity_exceptions = normalize_parity_exceptions(attributes.fetch(:parity_exceptions, [])) - ensure - @attribute_names = nil + freeze end BOOLEAN_ATTRIBUTES.each do |attribute_name| @@ -46,11 +44,11 @@ def required_boolean(name, attributes) normalize_boolean(name, attributes.fetch(name) { raise InvalidBackendSelectionError, "#{name} must be provided" }) end - def validate_attribute_names - unknown_attributes = attribute_names - ATTRIBUTE_NAMES + def validate_attribute_names(attributes) + unknown_attributes = attributes.keys - ATTRIBUTE_NAMES return if unknown_attributes.empty? - raise InvalidBackendSelectionError, "unknown capability attributes: #{unknown_attributes.join(', ')}" + raise_unknown_attribute_names_error(unknown_attributes) end def normalize_boolean(name, value) @@ -70,7 +68,9 @@ def normalize_parity_exceptions(values) end.freeze end - attr_reader :attribute_names + def raise_unknown_attribute_names_error(unknown_attributes) + raise InvalidBackendSelectionError, "unknown capability attributes: #{unknown_attributes.join(', ')}" + end end end end diff --git a/core/karya/lib/karya/backend/descriptor.rb b/core/karya/lib/karya/backend/descriptor.rb index c75df948..6aeabd02 100644 --- a/core/karya/lib/karya/backend/descriptor.rb +++ b/core/karya/lib/karya/backend/descriptor.rb @@ -19,6 +19,8 @@ def initialize(identifier:, classification:, capabilities:) @identifier = Selection.normalize_identifier(identifier) @classification = normalize_classification(classification) @capabilities = normalize_capabilities(capabilities) + validate_classification_consistency + freeze end def quick_setup_and_run? @@ -54,6 +56,14 @@ def raise_invalid_classification_error valid_classifications = CLASSIFICATIONS.map(&:inspect).join(', ') raise InvalidBackendSelectionError, "classification must be one of #{valid_classifications}" end + + def validate_classification_consistency + expected_classification = Selection.classification_for(identifier) + return if classification == expected_classification + + raise InvalidBackendSelectionError, + "classification #{classification.inspect} does not match backend #{identifier.inspect}; expected #{expected_classification.inspect}" + end end end end diff --git a/core/karya/sig/karya.rbs b/core/karya/sig/karya.rbs index c1c33792..9c8305b6 100644 --- a/core/karya/sig/karya.rbs +++ b/core/karya/sig/karya.rbs @@ -440,7 +440,7 @@ module Karya type symbol_options = ::Hash[Symbol, option_value] type mixed_options = ::Hash[Symbol | String, option_value] type job_arguments = ::Hash[String, job_argument] - type backend_identifier = :in_memory + type backend_identifier = "in_memory" type backend_identifier_input = state_name? type backend_classification = :quick_setup_and_run | :production_like_local | :production_grade type backend_classification_input = diff --git a/core/karya/sig/karya/backend/descriptor.rbs b/core/karya/sig/karya/backend/descriptor.rbs index 77bc265f..0124609a 100644 --- a/core/karya/sig/karya/backend/descriptor.rbs +++ b/core/karya/sig/karya/backend/descriptor.rbs @@ -3,7 +3,7 @@ module Karya class Descriptor CLASSIFICATIONS: Array[backend_classification] - @identifier: String + @identifier: backend_identifier @classification: backend_classification @capabilities: Capabilities @@ -13,7 +13,7 @@ module Karya capabilities: Capabilities ) -> void - attr_reader identifier: String + attr_reader identifier: backend_identifier attr_reader classification: backend_classification attr_reader capabilities: Capabilities diff --git a/core/karya/sig/karya/backend/selection.rbs b/core/karya/sig/karya/backend/selection.rbs index d7cae69c..96fd68cd 100644 --- a/core/karya/sig/karya/backend/selection.rbs +++ b/core/karya/sig/karya/backend/selection.rbs @@ -5,9 +5,9 @@ module Karya IDENTIFIER_ALIASES: Hash[String, String] CLASSIFICATIONS: Hash[String, backend_classification] - @identifier: String + @identifier: backend_identifier - def self.normalize_identifier: (backend_identifier_input value) -> String + def self.normalize_identifier: (backend_identifier_input value) -> backend_identifier def self.supported_identifier?: (backend_identifier_input value) -> bool def self.classification_for: (backend_identifier_input value) -> backend_classification def self.quick_setup_and_run?: (backend_identifier_input value) -> bool @@ -16,7 +16,7 @@ module Karya def initialize: (backend_identifier_input value) -> void - attr_reader identifier: String + attr_reader identifier: backend_identifier def classification: () -> backend_classification def quick_setup_and_run?: () -> bool diff --git a/core/karya/spec/karya/backend/capabilities_spec.rb b/core/karya/spec/karya/backend/capabilities_spec.rb index 8a34aeb2..dac94c64 100644 --- a/core/karya/spec/karya/backend/capabilities_spec.rb +++ b/core/karya/spec/karya/backend/capabilities_spec.rb @@ -19,6 +19,7 @@ expect(capabilities.shared_processes?).to be(true) expect(capabilities.multi_node?).to be(false) expect(capabilities.parity_exceptions).to eq(['Local deployments stay single-node']) + expect(capabilities).to be_frozen expect(capabilities.parity_exceptions).to be_frozen end diff --git a/core/karya/spec/karya/backend/descriptor_spec.rb b/core/karya/spec/karya/backend/descriptor_spec.rb index d33fbadf..2a894437 100644 --- a/core/karya/spec/karya/backend/descriptor_spec.rb +++ b/core/karya/spec/karya/backend/descriptor_spec.rb @@ -23,6 +23,7 @@ expect(descriptor.quick_setup_and_run?).to be(true) expect(descriptor.production_like_local?).to be(false) expect(descriptor.production_grade?).to be(false) + expect(descriptor).to be_frozen end it 'accepts string classifications that Ruby normalizes to symbols' do @@ -52,4 +53,13 @@ described_class.new(identifier: :in_memory, classification: :production_grade, capabilities: Object.new) end.to raise_error(Karya::InvalidBackendSelectionError, /capabilities must be a Karya::Backend::Capabilities/) end + + it 'rejects classifications that contradict the shared backend selection model' do + expect do + described_class.new(identifier: :in_memory, classification: :production_grade, capabilities:) + end.to raise_error( + Karya::InvalidBackendSelectionError, + /classification :production_grade does not match backend "in_memory"; expected :quick_setup_and_run/ + ) + end end From e357611b89d34af1444f1aea06c02d2893fb4e5f Mon Sep 17 00:00:00 2001 From: Nitesh Purohit Date: Sat, 2 May 2026 13:21:58 -0400 Subject: [PATCH 05/13] feat: enhance backend capabilities and descriptor validation --- core/karya/lib/karya/backend/capabilities.rb | 4 +++- core/karya/lib/karya/backend/descriptor.rb | 21 ++++++++++++++++- core/karya/lib/karya/backend/in_memory.rb | 23 +++++++++++++++++-- core/karya/sig/karya/backend/base.rbs | 2 +- .../spec/karya/backend/capabilities_spec.rb | 14 +++++++++++ .../spec/karya/backend/descriptor_spec.rb | 8 ++++++- .../spec/karya/backend/in_memory_spec.rb | 11 +++++++++ 7 files changed, 77 insertions(+), 6 deletions(-) diff --git a/core/karya/lib/karya/backend/capabilities.rb b/core/karya/lib/karya/backend/capabilities.rb index 0a702670..42b9edf0 100644 --- a/core/karya/lib/karya/backend/capabilities.rb +++ b/core/karya/lib/karya/backend/capabilities.rb @@ -61,7 +61,9 @@ def normalize_parity_exceptions(values) raise InvalidBackendSelectionError, 'parity_exceptions must be an Array' unless values.is_a?(Array) values.map do |value| - normalized_value = value.to_s.strip + raise InvalidBackendSelectionError, 'parity_exceptions entries must be String values' unless value.is_a?(String) + + normalized_value = value.strip raise InvalidBackendSelectionError, 'parity_exceptions entries must be present' if normalized_value.empty? normalized_value.freeze diff --git a/core/karya/lib/karya/backend/descriptor.rb b/core/karya/lib/karya/backend/descriptor.rb index 6aeabd02..21587e25 100644 --- a/core/karya/lib/karya/backend/descriptor.rb +++ b/core/karya/lib/karya/backend/descriptor.rb @@ -44,11 +44,26 @@ def normalize_capabilities(value) end def normalize_classification(value) + case value + when String + normalize_string_classification(value) + when Symbol + normalize_symbol_classification(value) + else + raise_invalid_classification_type_error + end + end + + def normalize_string_classification(value) normalized_value = value.to_sym return normalized_value if CLASSIFICATIONS.include?(normalized_value) raise_invalid_classification_error - rescue NoMethodError + end + + def normalize_symbol_classification(value) + return value if CLASSIFICATIONS.include?(value) + raise_invalid_classification_error end @@ -57,6 +72,10 @@ def raise_invalid_classification_error raise InvalidBackendSelectionError, "classification must be one of #{valid_classifications}" end + def raise_invalid_classification_type_error + raise InvalidBackendSelectionError, 'classification must be a String or Symbol' + end + def validate_classification_consistency expected_classification = Selection.classification_for(identifier) return if classification == expected_classification diff --git a/core/karya/lib/karya/backend/in_memory.rb b/core/karya/lib/karya/backend/in_memory.rb index f9ca8d87..34ec06bc 100644 --- a/core/karya/lib/karya/backend/in_memory.rb +++ b/core/karya/lib/karya/backend/in_memory.rb @@ -40,8 +40,27 @@ def descriptor DESCRIPTOR end - def build_queue_store(**) - queue_store_class.new(**) + def build_queue_store( + token_generator: nil, + expired_tombstone_limit: nil, + completed_batch_retention_limit: nil, + max_batch_size: nil, + policy_set: nil, + circuit_breaker_policy_set: nil, + fairness_policy: nil + ) + queue_store = queue_store_class.new(**{ + token_generator:, + expired_tombstone_limit:, + completed_batch_retention_limit:, + max_batch_size:, + policy_set:, + circuit_breaker_policy_set:, + fairness_policy: + }.compact) + return queue_store if queue_store.is_a?(QueueStore::Base) + + raise InvalidBackendSelectionError, 'queue_store_class must build a Karya::QueueStore::Base' end def before_start(queue_store:) diff --git a/core/karya/sig/karya/backend/base.rbs b/core/karya/sig/karya/backend/base.rbs index 124596a1..f93bc097 100644 --- a/core/karya/sig/karya/backend/base.rbs +++ b/core/karya/sig/karya/backend/base.rbs @@ -1,7 +1,7 @@ module Karya module Backend module Base - def identifier: () -> String + def identifier: () -> backend_identifier def classification: () -> backend_classification def capabilities: () -> Capabilities def descriptor: () -> Descriptor diff --git a/core/karya/spec/karya/backend/capabilities_spec.rb b/core/karya/spec/karya/backend/capabilities_spec.rb index dac94c64..1fbe4b21 100644 --- a/core/karya/spec/karya/backend/capabilities_spec.rb +++ b/core/karya/spec/karya/backend/capabilities_spec.rb @@ -62,6 +62,20 @@ end.to raise_error(Karya::InvalidBackendSelectionError, /parity_exceptions entries must be present/) end + it 'rejects non-string parity exceptions' do + expect do + described_class.new( + job_persistence: true, + workflow_state: true, + schedule_state: true, + audit_history: true, + shared_processes: true, + multi_node: true, + parity_exceptions: [:local_only] + ) + end.to raise_error(Karya::InvalidBackendSelectionError, /parity_exceptions entries must be String values/) + end + it 'rejects non-array parity exceptions' do expect do described_class.new( diff --git a/core/karya/spec/karya/backend/descriptor_spec.rb b/core/karya/spec/karya/backend/descriptor_spec.rb index 2a894437..4d1a64a7 100644 --- a/core/karya/spec/karya/backend/descriptor_spec.rb +++ b/core/karya/spec/karya/backend/descriptor_spec.rb @@ -42,10 +42,16 @@ end.to raise_error(Karya::InvalidBackendSelectionError, /classification must be one of/) end + it 'rejects unsupported string classifications' do + expect do + described_class.new(identifier: :in_memory, classification: 'local', capabilities:) + end.to raise_error(Karya::InvalidBackendSelectionError, /classification must be one of/) + end + it 'rejects non-symbolizable classifications' do expect do described_class.new(identifier: :in_memory, classification: Object.new, capabilities:) - end.to raise_error(Karya::InvalidBackendSelectionError, /classification must be one of/) + end.to raise_error(Karya::InvalidBackendSelectionError, /classification must be a String or Symbol/) end it 'requires a backend capabilities object' do diff --git a/core/karya/spec/karya/backend/in_memory_spec.rb b/core/karya/spec/karya/backend/in_memory_spec.rb index 0ebe2930..3c77faa7 100644 --- a/core/karya/spec/karya/backend/in_memory_spec.rb +++ b/core/karya/spec/karya/backend/in_memory_spec.rb @@ -34,6 +34,17 @@ expect(backend.build_queue_store).to be(queue_store) end + it 'rejects an injected queue store factory that returns a non queue store' do + queue_store_factory = Class.new do + define_singleton_method(:new) { |**| Object.new } + end + backend = described_class.new(queue_store_class: queue_store_factory) + + expect do + backend.build_queue_store + end.to raise_error(Karya::InvalidBackendSelectionError, /queue_store_class must build a Karya::QueueStore::Base/) + end + it 'declares no-op lifecycle hooks around queue-store usage' do queue_store = backend.build_queue_store From 8dd46f4feb7f5915e49b54f20d7cd149e8069229 Mon Sep 17 00:00:00 2001 From: Nitesh Purohit Date: Sat, 2 May 2026 13:46:08 -0400 Subject: [PATCH 06/13] feat: enhance backend identifier handling and classifications --- core/karya/lib/karya/backend/descriptor.rb | 7 +++- core/karya/lib/karya/backend/selection.rb | 16 ++++++--- core/karya/sig/karya.rbs | 8 ++--- core/karya/sig/karya/backend/selection.rbs | 4 +-- .../spec/karya/backend/selection_spec.rb | 34 +++++++++---------- 5 files changed, 37 insertions(+), 32 deletions(-) diff --git a/core/karya/lib/karya/backend/descriptor.rb b/core/karya/lib/karya/backend/descriptor.rb index 21587e25..7d576ef8 100644 --- a/core/karya/lib/karya/backend/descriptor.rb +++ b/core/karya/lib/karya/backend/descriptor.rb @@ -12,6 +12,11 @@ module Backend # Immutable backend identity and deployment posture description. class Descriptor CLASSIFICATIONS = %i[quick_setup_and_run production_like_local production_grade].freeze + STRING_CLASSIFICATIONS = { + 'quick_setup_and_run' => :quick_setup_and_run, + 'production_like_local' => :production_like_local, + 'production_grade' => :production_grade + }.freeze attr_reader :capabilities, :classification, :identifier @@ -55,7 +60,7 @@ def normalize_classification(value) end def normalize_string_classification(value) - normalized_value = value.to_sym + normalized_value = STRING_CLASSIFICATIONS[value] return normalized_value if CLASSIFICATIONS.include?(normalized_value) raise_invalid_classification_error diff --git a/core/karya/lib/karya/backend/selection.rb b/core/karya/lib/karya/backend/selection.rb index 000802cb..3a379966 100644 --- a/core/karya/lib/karya/backend/selection.rb +++ b/core/karya/lib/karya/backend/selection.rb @@ -11,12 +11,18 @@ module Karya module Backend # Normalized backend selection without runtime boot wiring. class Selection - SUPPORTED_IDENTIFIERS = %w[in_memory].freeze + KNOWN_IDENTIFIERS = %w[in_memory sqlite redis postgres mysql].freeze IDENTIFIER_ALIASES = { 'InMemory' => 'in_memory', 'inmemory' => 'in_memory', - 'in_memory' => 'in_memory' + 'in_memory' => 'in_memory', + 'sqlite' => 'sqlite', + 'redis' => 'redis', + 'postgres' => 'postgres', + 'postgresql' => 'postgres', + 'mysql' => 'mysql', + 'my_sql' => 'mysql' }.freeze CLASSIFICATIONS = { @@ -35,11 +41,11 @@ def self.normalize_identifier(value) return normalized_alias.freeze if normalized_alias raise UnsupportedBackendError, - "unsupported backend #{normalized_input.inspect}; supported backends: #{SUPPORTED_IDENTIFIERS.join(', ')}" + "unsupported backend #{normalized_input.inspect}; known backends: #{KNOWN_IDENTIFIERS.join(', ')}" end - def self.supported_identifier?(value) - SUPPORTED_IDENTIFIERS.include?(normalize_identifier(value)) + def self.known_identifier?(value) + KNOWN_IDENTIFIERS.include?(normalize_identifier(value)) rescue InvalidBackendSelectionError, UnsupportedBackendError false end diff --git a/core/karya/sig/karya.rbs b/core/karya/sig/karya.rbs index 9c8305b6..b02e3051 100644 --- a/core/karya/sig/karya.rbs +++ b/core/karya/sig/karya.rbs @@ -440,14 +440,10 @@ module Karya type symbol_options = ::Hash[Symbol, option_value] type mixed_options = ::Hash[Symbol | String, option_value] type job_arguments = ::Hash[String, job_argument] - type backend_identifier = "in_memory" + type backend_identifier = "in_memory" | "sqlite" | "redis" | "postgres" | "mysql" type backend_identifier_input = state_name? type backend_classification = :quick_setup_and_run | :production_like_local | :production_grade - type backend_classification_input = - backend_classification | - "quick_setup_and_run" | - "production_like_local" | - "production_grade" + type backend_classification_input = state_name type error_class = singleton(StandardError) type logger = Internal::_Logger type instrumenter = ^(String, context_payload) -> nil diff --git a/core/karya/sig/karya/backend/selection.rbs b/core/karya/sig/karya/backend/selection.rbs index 96fd68cd..0ef3f5cf 100644 --- a/core/karya/sig/karya/backend/selection.rbs +++ b/core/karya/sig/karya/backend/selection.rbs @@ -1,14 +1,14 @@ module Karya module Backend class Selection - SUPPORTED_IDENTIFIERS: Array[String] + KNOWN_IDENTIFIERS: Array[String] IDENTIFIER_ALIASES: Hash[String, String] CLASSIFICATIONS: Hash[String, backend_classification] @identifier: backend_identifier def self.normalize_identifier: (backend_identifier_input value) -> backend_identifier - def self.supported_identifier?: (backend_identifier_input value) -> bool + def self.known_identifier?: (backend_identifier_input value) -> bool def self.classification_for: (backend_identifier_input value) -> backend_classification def self.quick_setup_and_run?: (backend_identifier_input value) -> bool def self.production_like_local?: (backend_identifier_input value) -> bool diff --git a/core/karya/spec/karya/backend/selection_spec.rb b/core/karya/spec/karya/backend/selection_spec.rb index a7e22986..f46123af 100644 --- a/core/karya/spec/karya/backend/selection_spec.rb +++ b/core/karya/spec/karya/backend/selection_spec.rb @@ -5,6 +5,12 @@ expect(described_class.normalize_identifier(:in_memory)).to eq('in_memory') expect(described_class.normalize_identifier('inmemory')).to eq('in_memory') expect(described_class.normalize_identifier('InMemory')).to eq('in_memory') + expect(described_class.normalize_identifier(:sqlite)).to eq('sqlite') + expect(described_class.normalize_identifier(:redis)).to eq('redis') + expect(described_class.normalize_identifier(:postgres)).to eq('postgres') + expect(described_class.normalize_identifier(:postgresql)).to eq('postgres') + expect(described_class.normalize_identifier(:mysql)).to eq('mysql') + expect(described_class.normalize_identifier(:my_sql)).to eq('mysql') end it 'rejects blank backend input' do @@ -33,14 +39,6 @@ end.to raise_error(Karya::UnsupportedBackendError, /unsupported backend "in-memory"/) end - it 'rejects backends without a defined backend implementation' do - %w[sqlite redis postgres mysql].each do |identifier| - expect do - described_class.normalize_identifier(identifier) - end.to raise_error(Karya::UnsupportedBackendError, /unsupported backend/) - end - end - it 'classifies inmemory as quick setup and run' do selection = described_class.new('InMemory') @@ -55,25 +53,25 @@ expect(described_class.quick_setup_and_run?('InMemory')).to be(true) end - it 'exposes the class-level production-like-local predicate for planned classifications' do - expect(described_class.production_like_local?('in_memory')).to be(false) + it 'exposes the class-level production-like-local predicate' do + expect(described_class.production_like_local?('sqlite')).to be(true) end - it 'exposes the class-level production-grade predicate for planned classifications' do - expect(described_class.production_grade?('in_memory')).to be(false) + it 'exposes the class-level production-grade predicate' do + expect(described_class.production_grade?('postgres')).to be(true) end - it 'keeps planned deployment classifications internal until those backends exist' do + it 'defines deployment classifications for the shared backend contract' do expect(described_class::CLASSIFICATIONS.fetch('sqlite')).to eq(:production_like_local) expect(described_class::CLASSIFICATIONS.fetch('redis')).to eq(:production_grade) expect(described_class::CLASSIFICATIONS.fetch('postgres')).to eq(:production_grade) expect(described_class::CLASSIFICATIONS.fetch('mysql')).to eq(:production_grade) end - it 'reports whether an identifier is supported' do - expect(described_class.supported_identifier?('inmemory')).to be(true) - expect(described_class.supported_identifier?('postgres')).to be(false) - expect(described_class.supported_identifier?('mongodb')).to be(false) - expect(described_class.supported_identifier?(42)).to be(false) + it 'reports whether an identifier is known' do + expect(described_class.known_identifier?('inmemory')).to be(true) + expect(described_class.known_identifier?('postgres')).to be(true) + expect(described_class.known_identifier?('mongodb')).to be(false) + expect(described_class.known_identifier?(42)).to be(false) end end From 9c646d1bf846e881a3c03c3e601cb5787f708bc3 Mon Sep 17 00:00:00 2001 From: Nitesh Purohit Date: Sat, 2 May 2026 14:01:28 -0400 Subject: [PATCH 07/13] feat: implement shared backend interface and selection model --- core/karya/sig/karya/backend/in_memory.rbs | 14 +++++----- core/karya/sig/karya/backend/selection.rbs | 6 ++--- .../spec/karya/backend/in_memory_spec.rb | 26 +++++++++++++++++++ 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/core/karya/sig/karya/backend/in_memory.rbs b/core/karya/sig/karya/backend/in_memory.rbs index 3fd787bd..58133338 100644 --- a/core/karya/sig/karya/backend/in_memory.rbs +++ b/core/karya/sig/karya/backend/in_memory.rbs @@ -21,13 +21,13 @@ module Karya def initialize: (?queue_store_class: _QueueStoreFactory) -> void def descriptor: () -> Descriptor def build_queue_store: ( - ?token_generator: Karya::callable_value, - ?expired_tombstone_limit: Integer, - ?completed_batch_retention_limit: Integer, - ?max_batch_size: Integer, - ?policy_set: Backpressure::PolicySet, - ?circuit_breaker_policy_set: CircuitBreaker::PolicySet, - ?fairness_policy: Fairness::Policy + ?token_generator: Karya::callable_value?, + ?expired_tombstone_limit: Integer?, + ?completed_batch_retention_limit: Integer?, + ?max_batch_size: Integer?, + ?policy_set: Backpressure::PolicySet?, + ?circuit_breaker_policy_set: CircuitBreaker::PolicySet?, + ?fairness_policy: Fairness::Policy? ) -> QueueStore::Base def before_start: (queue_store: QueueStore::Base) -> nil def after_stop: (queue_store: QueueStore::Base) -> nil diff --git a/core/karya/sig/karya/backend/selection.rbs b/core/karya/sig/karya/backend/selection.rbs index 0ef3f5cf..74114a67 100644 --- a/core/karya/sig/karya/backend/selection.rbs +++ b/core/karya/sig/karya/backend/selection.rbs @@ -1,9 +1,9 @@ module Karya module Backend class Selection - KNOWN_IDENTIFIERS: Array[String] - IDENTIFIER_ALIASES: Hash[String, String] - CLASSIFICATIONS: Hash[String, backend_classification] + KNOWN_IDENTIFIERS: Array[backend_identifier] + IDENTIFIER_ALIASES: Hash[String, backend_identifier] + CLASSIFICATIONS: Hash[backend_identifier, backend_classification] @identifier: backend_identifier diff --git a/core/karya/spec/karya/backend/in_memory_spec.rb b/core/karya/spec/karya/backend/in_memory_spec.rb index 3c77faa7..900344fc 100644 --- a/core/karya/spec/karya/backend/in_memory_spec.rb +++ b/core/karya/spec/karya/backend/in_memory_spec.rb @@ -34,6 +34,32 @@ expect(backend.build_queue_store).to be(queue_store) end + it 'forwards provided queue-store builder keywords and omits nil values' do + queue_store = Karya::QueueStore::InMemory.new + captured_options = nil + queue_store_factory = Class.new do + define_singleton_method(:new) do |**options| + captured_options = options + queue_store + end + end + backend = described_class.new(queue_store_class: queue_store_factory) + token_generator = -> { 'token-1' } + + backend.build_queue_store( + token_generator:, + expired_tombstone_limit: 12, + completed_batch_retention_limit: nil, + max_batch_size: 50 + ) + + expect(captured_options).to eq( + token_generator:, + expired_tombstone_limit: 12, + max_batch_size: 50 + ) + end + it 'rejects an injected queue store factory that returns a non queue store' do queue_store_factory = Class.new do define_singleton_method(:new) { |**| Object.new } From f0968447b5daf2a772f8610d922ae7add1fd9088 Mon Sep 17 00:00:00 2001 From: Nitesh Purohit Date: Sat, 2 May 2026 14:17:29 -0400 Subject: [PATCH 08/13] feat: enhance shutdown signal handling and testing --- core/karya/lib/karya/worker_supervisor.rb | 13 +++++- .../runtime_support/shutdown_state_spec.rb | 8 ++++ .../spec/karya/worker_supervisor_spec.rb | 46 +++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/core/karya/lib/karya/worker_supervisor.rb b/core/karya/lib/karya/worker_supervisor.rb index 1f1f7a16..d0935481 100644 --- a/core/karya/lib/karya/worker_supervisor.rb +++ b/core/karya/lib/karya/worker_supervisor.rb @@ -255,10 +255,21 @@ def collect_signal_restorers(restorers, shutdown_controller) def register_signal_restorers(restorers, shutdown_controller) SIGNALS.each do |signal| - restorers << runtime.subscribe_signal(signal, -> { shutdown_controller.advance }) + restorers << runtime.subscribe_signal(signal, -> { handle_shutdown_signal(shutdown_controller) }) end end + def handle_shutdown_signal(shutdown_controller) + phase = shutdown_controller.advance + if phase == Internal::RuntimeSupport::ShutdownState::DRAINING + runtime_state_store.mark_supervisor_phase(RuntimeStateStore::DRAINING_PHASE) + elsif phase == Internal::RuntimeSupport::ShutdownState::FORCE_STOP + runtime_state_store.mark_supervisor_phase(RuntimeStateStore::FORCE_STOPPING_PHASE) + end + ensure + WakeupSignal.interrupt(WAKEUP_SIGNAL) + end + def cleanup_tracked_children(child_pids) shutdown_tracked_children(child_pids, GRACEFUL_SIGNAL, blocking: false) return if child_pids.empty? diff --git a/core/karya/spec/karya/internal/runtime_support/shutdown_state_spec.rb b/core/karya/spec/karya/internal/runtime_support/shutdown_state_spec.rb index 85e5b454..f3975b16 100644 --- a/core/karya/spec/karya/internal/runtime_support/shutdown_state_spec.rb +++ b/core/karya/spec/karya/internal/runtime_support/shutdown_state_spec.rb @@ -19,4 +19,12 @@ expect(state.force_stop).to be(true) expect(state.force_stop).to be(false) end + + it 'returns the resulting shutdown phase when advancing' do + state = described_class.new + + expect(state.advance).to eq(described_class::DRAINING) + expect(state.advance).to eq(described_class::FORCE_STOP) + expect(state.advance).to be_nil + end end diff --git a/core/karya/spec/karya/worker_supervisor_spec.rb b/core/karya/spec/karya/worker_supervisor_spec.rb index 6ff21e64..22a71910 100644 --- a/core/karya/spec/karya/worker_supervisor_spec.rb +++ b/core/karya/spec/karya/worker_supervisor_spec.rb @@ -250,6 +250,52 @@ def execute_child_block? expect(supervisor.run).to eq(1) expect(killed_processes).to eq([['TERM', 100], ['KILL', 100]]) + expect(supervisor.runtime_snapshot.phase).to eq('stopped') + end + + it 'records runtime phases when OS signals escalate shutdown' do + wait_call_count = 0 + runtime_state_store = instance_double( + described_class.const_get(:RuntimeStateStore, false), + write_running: nil, + write_stopped: nil, + control_socket_path: '/tmp/runtime.sock', + instance_token: 'runtime-token', + snapshot: instance_double(described_class.const_get(:RuntimeSnapshot, false), phase: 'running'), + register_child: nil, + mark_supervisor_phase: nil, + mark_child_phase: nil, + mark_child_stopped: nil + ) + allow(runtime).to receive(:wait_for_child) do + wait_call_count += 1 + if wait_call_count <= 3 + subscriptions.fetch('TERM').call + nil + else + wait_results.shift + end + end + wait_results << [100, failure_status] + supervisor_with_runtime_state = described_class.new( + queue_store: queue_store, + worker_id: 'worker-supervisor', + queues: ['billing'], + handlers: { 'billing_sync' => -> {} }, + lease_duration: 30, + processes: 1, + threads: 1, + poll_interval: 0, + max_iterations: 1, + runtime: runtime, + runtime_state_store: runtime_state_store, + child_worker_class: child_worker_class + ) + + expect(supervisor_with_runtime_state.run).to eq(1) + expect(runtime_state_store).to have_received(:mark_supervisor_phase).with('draining').ordered + expect(runtime_state_store).to have_received(:mark_supervisor_phase).with('force_stopping').ordered + expect(runtime_state_store).to have_received(:mark_supervisor_phase).twice end it 'supports begin_drain through the public control API while running' do From c85f1615568195157d11a0af8cb9ad0cda935d06 Mon Sep 17 00:00:00 2001 From: Nitesh Purohit Date: Sat, 2 May 2026 14:35:23 -0400 Subject: [PATCH 09/13] feat: refactor backend interface and capabilities --- core/karya/lib/karya/backend.rb | 3 +- core/karya/lib/karya/backend/base.rb | 8 -- core/karya/lib/karya/backend/capabilities.rb | 78 ------------- core/karya/lib/karya/backend/descriptor.rb | 77 +------------ core/karya/lib/karya/backend/in_memory.rb | 21 +--- core/karya/lib/karya/backend/selection.rb | 40 ------- core/karya/sig/karya.rbs | 2 - core/karya/sig/karya/backend/base.rbs | 2 - core/karya/sig/karya/backend/capabilities.rbs | 38 ------- core/karya/sig/karya/backend/descriptor.rbs | 16 +-- core/karya/sig/karya/backend/in_memory.rbs | 1 - core/karya/sig/karya/backend/selection.rbs | 10 -- core/karya/spec/karya/backend/base_spec.rb | 12 +- .../spec/karya/backend/capabilities_spec.rb | 106 ------------------ .../spec/karya/backend/descriptor_spec.rb | 65 +---------- .../spec/karya/backend/in_memory_spec.rb | 12 +- .../spec/karya/backend/selection_spec.rb | 25 +---- docs/index.md | 8 +- docs/pages/architecture.md | 6 +- docs/pages/backends.md | 36 +++--- docs/pages/getting-started.md | 4 +- 21 files changed, 43 insertions(+), 527 deletions(-) delete mode 100644 core/karya/lib/karya/backend/capabilities.rb delete mode 100644 core/karya/sig/karya/backend/capabilities.rbs delete mode 100644 core/karya/spec/karya/backend/capabilities_spec.rb diff --git a/core/karya/lib/karya/backend.rb b/core/karya/lib/karya/backend.rb index 12857b23..274aa0d6 100644 --- a/core/karya/lib/karya/backend.rb +++ b/core/karya/lib/karya/backend.rb @@ -12,10 +12,9 @@ class InvalidBackendSelectionError < Error; end # Raised when a caller refers to a backend outside the supported backend set. class UnsupportedBackendError < Error; end - # Namespace for backend selection, capability, and lifecycle contracts. + # Namespace for backend selection and lifecycle contracts. module Backend autoload :Base, 'karya/backend/base' - autoload :Capabilities, 'karya/backend/capabilities' autoload :Descriptor, 'karya/backend/descriptor' autoload :InMemory, 'karya/backend/in_memory' autoload :Selection, 'karya/backend/selection' diff --git a/core/karya/lib/karya/backend/base.rb b/core/karya/lib/karya/backend/base.rb index 3bdb8f39..262dca18 100644 --- a/core/karya/lib/karya/backend/base.rb +++ b/core/karya/lib/karya/backend/base.rb @@ -13,14 +13,6 @@ def identifier descriptor.identifier end - def classification - descriptor.classification - end - - def capabilities - descriptor.capabilities - end - def descriptor raise NotImplementedError, "#{self.class} must implement ##{__method__}" end diff --git a/core/karya/lib/karya/backend/capabilities.rb b/core/karya/lib/karya/backend/capabilities.rb deleted file mode 100644 index 42b9edf0..00000000 --- a/core/karya/lib/karya/backend/capabilities.rb +++ /dev/null @@ -1,78 +0,0 @@ -# frozen_string_literal: true - -# Copyright Codevedas Inc. 2025-present -# -# This source code is licensed under the MIT license found in the -# LICENSE file in the root directory of this source tree. - -module Karya - module Backend - # Immutable backend capability flags and documented parity exceptions. - class Capabilities - BOOLEAN_ATTRIBUTES = %i[ - job_persistence - workflow_state - schedule_state - audit_history - shared_processes - multi_node - ].freeze - ATTRIBUTE_NAMES = (BOOLEAN_ATTRIBUTES + [:parity_exceptions]).freeze - - attr_reader(*BOOLEAN_ATTRIBUTES, :parity_exceptions) - - def initialize(**attributes) - validate_attribute_names(attributes) - - @job_persistence = required_boolean(:job_persistence, attributes) - @workflow_state = required_boolean(:workflow_state, attributes) - @schedule_state = required_boolean(:schedule_state, attributes) - @audit_history = required_boolean(:audit_history, attributes) - @shared_processes = required_boolean(:shared_processes, attributes) - @multi_node = required_boolean(:multi_node, attributes) - @parity_exceptions = normalize_parity_exceptions(attributes.fetch(:parity_exceptions, [])) - freeze - end - - BOOLEAN_ATTRIBUTES.each do |attribute_name| - alias_method "#{attribute_name}?", attribute_name - end - - private - - def required_boolean(name, attributes) - normalize_boolean(name, attributes.fetch(name) { raise InvalidBackendSelectionError, "#{name} must be provided" }) - end - - def validate_attribute_names(attributes) - unknown_attributes = attributes.keys - ATTRIBUTE_NAMES - return if unknown_attributes.empty? - - raise_unknown_attribute_names_error(unknown_attributes) - end - - def normalize_boolean(name, value) - return value if [true, false].include?(value) - - raise InvalidBackendSelectionError, "#{name} must be boolean" - end - - def normalize_parity_exceptions(values) - raise InvalidBackendSelectionError, 'parity_exceptions must be an Array' unless values.is_a?(Array) - - values.map do |value| - raise InvalidBackendSelectionError, 'parity_exceptions entries must be String values' unless value.is_a?(String) - - normalized_value = value.strip - raise InvalidBackendSelectionError, 'parity_exceptions entries must be present' if normalized_value.empty? - - normalized_value.freeze - end.freeze - end - - def raise_unknown_attribute_names_error(unknown_attributes) - raise InvalidBackendSelectionError, "unknown capability attributes: #{unknown_attributes.join(', ')}" - end - end - end -end diff --git a/core/karya/lib/karya/backend/descriptor.rb b/core/karya/lib/karya/backend/descriptor.rb index 7d576ef8..c48c0807 100644 --- a/core/karya/lib/karya/backend/descriptor.rb +++ b/core/karya/lib/karya/backend/descriptor.rb @@ -9,85 +9,14 @@ module Karya module Backend - # Immutable backend identity and deployment posture description. + # Immutable backend identity description. class Descriptor - CLASSIFICATIONS = %i[quick_setup_and_run production_like_local production_grade].freeze - STRING_CLASSIFICATIONS = { - 'quick_setup_and_run' => :quick_setup_and_run, - 'production_like_local' => :production_like_local, - 'production_grade' => :production_grade - }.freeze + attr_reader :identifier - attr_reader :capabilities, :classification, :identifier - - def initialize(identifier:, classification:, capabilities:) + def initialize(identifier:) @identifier = Selection.normalize_identifier(identifier) - @classification = normalize_classification(classification) - @capabilities = normalize_capabilities(capabilities) - validate_classification_consistency freeze end - - def quick_setup_and_run? - classification == :quick_setup_and_run - end - - def production_like_local? - classification == :production_like_local - end - - def production_grade? - classification == :production_grade - end - - private - - def normalize_capabilities(value) - return value if value.is_a?(Capabilities) - - raise InvalidBackendSelectionError, 'capabilities must be a Karya::Backend::Capabilities' - end - - def normalize_classification(value) - case value - when String - normalize_string_classification(value) - when Symbol - normalize_symbol_classification(value) - else - raise_invalid_classification_type_error - end - end - - def normalize_string_classification(value) - normalized_value = STRING_CLASSIFICATIONS[value] - return normalized_value if CLASSIFICATIONS.include?(normalized_value) - - raise_invalid_classification_error - end - - def normalize_symbol_classification(value) - return value if CLASSIFICATIONS.include?(value) - - raise_invalid_classification_error - end - - def raise_invalid_classification_error - valid_classifications = CLASSIFICATIONS.map(&:inspect).join(', ') - raise InvalidBackendSelectionError, "classification must be one of #{valid_classifications}" - end - - def raise_invalid_classification_type_error - raise InvalidBackendSelectionError, 'classification must be a String or Symbol' - end - - def validate_classification_consistency - expected_classification = Selection.classification_for(identifier) - return if classification == expected_classification - - raise InvalidBackendSelectionError, - "classification #{classification.inspect} does not match backend #{identifier.inspect}; expected #{expected_classification.inspect}" - end end end end diff --git a/core/karya/lib/karya/backend/in_memory.rb b/core/karya/lib/karya/backend/in_memory.rb index 34ec06bc..0404ff32 100644 --- a/core/karya/lib/karya/backend/in_memory.rb +++ b/core/karya/lib/karya/backend/in_memory.rb @@ -13,29 +13,12 @@ module Backend class InMemory include Base + DESCRIPTOR = Descriptor.new(identifier: :in_memory) + def initialize(queue_store_class: QueueStore::InMemory) @queue_store_class = queue_store_class end - CAPABILITIES = Capabilities.new( - job_persistence: false, - workflow_state: false, - schedule_state: false, - audit_history: false, - shared_processes: false, - multi_node: false, - parity_exceptions: [ - 'Jobs, workflow state, schedules, and audit history are process-local and lost on restart', - 'The backend is for quick setup, tests, and ephemeral local runs rather than production-grade deployments' - ] - ) - - DESCRIPTOR = Descriptor.new( - identifier: :in_memory, - classification: :quick_setup_and_run, - capabilities: CAPABILITIES - ) - def descriptor DESCRIPTOR end diff --git a/core/karya/lib/karya/backend/selection.rb b/core/karya/lib/karya/backend/selection.rb index 3a379966..e160056c 100644 --- a/core/karya/lib/karya/backend/selection.rb +++ b/core/karya/lib/karya/backend/selection.rb @@ -25,14 +25,6 @@ class Selection 'my_sql' => 'mysql' }.freeze - CLASSIFICATIONS = { - 'in_memory' => :quick_setup_and_run, - 'sqlite' => :production_like_local, - 'redis' => :production_grade, - 'postgres' => :production_grade, - 'mysql' => :production_grade - }.freeze - attr_reader :identifier def self.normalize_identifier(value) @@ -50,42 +42,10 @@ def self.known_identifier?(value) false end - def self.classification_for(value) - CLASSIFICATIONS.fetch(normalize_identifier(value)) - end - - def self.quick_setup_and_run?(value) - classification_for(value) == :quick_setup_and_run - end - - def self.production_like_local?(value) - classification_for(value) == :production_like_local - end - - def self.production_grade?(value) - classification_for(value) == :production_grade - end - def initialize(value) @identifier = self.class.normalize_identifier(value) end - def classification - self.class.classification_for(identifier) - end - - def quick_setup_and_run? - classification == :quick_setup_and_run - end - - def production_like_local? - classification == :production_like_local - end - - def production_grade? - classification == :production_grade - end - def self.normalize_identifier_input(value) if [NilClass, String, Symbol].any? { |klass| value.is_a?(klass) } return Primitives::Identifier.new(:backend, value, error_class: InvalidBackendSelectionError).normalize diff --git a/core/karya/sig/karya.rbs b/core/karya/sig/karya.rbs index b02e3051..226887a0 100644 --- a/core/karya/sig/karya.rbs +++ b/core/karya/sig/karya.rbs @@ -442,8 +442,6 @@ module Karya type job_arguments = ::Hash[String, job_argument] type backend_identifier = "in_memory" | "sqlite" | "redis" | "postgres" | "mysql" type backend_identifier_input = state_name? - type backend_classification = :quick_setup_and_run | :production_like_local | :production_grade - type backend_classification_input = state_name type error_class = singleton(StandardError) type logger = Internal::_Logger type instrumenter = ^(String, context_payload) -> nil diff --git a/core/karya/sig/karya/backend/base.rbs b/core/karya/sig/karya/backend/base.rbs index f93bc097..62e86857 100644 --- a/core/karya/sig/karya/backend/base.rbs +++ b/core/karya/sig/karya/backend/base.rbs @@ -2,8 +2,6 @@ module Karya module Backend module Base def identifier: () -> backend_identifier - def classification: () -> backend_classification - def capabilities: () -> Capabilities def descriptor: () -> Descriptor def build_queue_store: () -> QueueStore::Base def before_start: (queue_store: QueueStore::Base) -> nil diff --git a/core/karya/sig/karya/backend/capabilities.rbs b/core/karya/sig/karya/backend/capabilities.rbs deleted file mode 100644 index 3c90400a..00000000 --- a/core/karya/sig/karya/backend/capabilities.rbs +++ /dev/null @@ -1,38 +0,0 @@ -module Karya - module Backend - class Capabilities - @job_persistence: bool - @workflow_state: bool - @schedule_state: bool - @audit_history: bool - @shared_processes: bool - @multi_node: bool - @parity_exceptions: Array[String] - - def initialize: ( - job_persistence: bool, - workflow_state: bool, - schedule_state: bool, - audit_history: bool, - shared_processes: bool, - multi_node: bool, - ?parity_exceptions: Array[String] - ) -> void - - attr_reader job_persistence: bool - attr_reader workflow_state: bool - attr_reader schedule_state: bool - attr_reader audit_history: bool - attr_reader shared_processes: bool - attr_reader multi_node: bool - attr_reader parity_exceptions: Array[String] - - def job_persistence?: () -> bool - def workflow_state?: () -> bool - def schedule_state?: () -> bool - def audit_history?: () -> bool - def shared_processes?: () -> bool - def multi_node?: () -> bool - end - end -end diff --git a/core/karya/sig/karya/backend/descriptor.rbs b/core/karya/sig/karya/backend/descriptor.rbs index 0124609a..bcabc7d7 100644 --- a/core/karya/sig/karya/backend/descriptor.rbs +++ b/core/karya/sig/karya/backend/descriptor.rbs @@ -1,25 +1,11 @@ module Karya module Backend class Descriptor - CLASSIFICATIONS: Array[backend_classification] - @identifier: backend_identifier - @classification: backend_classification - @capabilities: Capabilities - def initialize: ( - identifier: backend_identifier_input, - classification: backend_classification_input, - capabilities: Capabilities - ) -> void + def initialize: (identifier: backend_identifier_input) -> void attr_reader identifier: backend_identifier - attr_reader classification: backend_classification - attr_reader capabilities: Capabilities - - def quick_setup_and_run?: () -> bool - def production_like_local?: () -> bool - def production_grade?: () -> bool end end end diff --git a/core/karya/sig/karya/backend/in_memory.rbs b/core/karya/sig/karya/backend/in_memory.rbs index 58133338..ada23cd3 100644 --- a/core/karya/sig/karya/backend/in_memory.rbs +++ b/core/karya/sig/karya/backend/in_memory.rbs @@ -15,7 +15,6 @@ module Karya class InMemory include Base - CAPABILITIES: Capabilities DESCRIPTOR: Descriptor def initialize: (?queue_store_class: _QueueStoreFactory) -> void diff --git a/core/karya/sig/karya/backend/selection.rbs b/core/karya/sig/karya/backend/selection.rbs index 74114a67..7473593d 100644 --- a/core/karya/sig/karya/backend/selection.rbs +++ b/core/karya/sig/karya/backend/selection.rbs @@ -3,25 +3,15 @@ module Karya class Selection KNOWN_IDENTIFIERS: Array[backend_identifier] IDENTIFIER_ALIASES: Hash[String, backend_identifier] - CLASSIFICATIONS: Hash[backend_identifier, backend_classification] @identifier: backend_identifier def self.normalize_identifier: (backend_identifier_input value) -> backend_identifier def self.known_identifier?: (backend_identifier_input value) -> bool - def self.classification_for: (backend_identifier_input value) -> backend_classification - def self.quick_setup_and_run?: (backend_identifier_input value) -> bool - def self.production_like_local?: (backend_identifier_input value) -> bool - def self.production_grade?: (backend_identifier_input value) -> bool def initialize: (backend_identifier_input value) -> void attr_reader identifier: backend_identifier - - def classification: () -> backend_classification - def quick_setup_and_run?: () -> bool - def production_like_local?: () -> bool - def production_grade?: () -> bool end end end diff --git a/core/karya/spec/karya/backend/base_spec.rb b/core/karya/spec/karya/backend/base_spec.rb index 8cc0d2a0..9ec168d1 100644 --- a/core/karya/spec/karya/backend/base_spec.rb +++ b/core/karya/spec/karya/backend/base_spec.rb @@ -24,14 +24,8 @@ expect(backend.after_stop(queue_store:)).to be_nil end - it 'delegates identifier, classification, and capabilities through the descriptor' do - capabilities = instance_double(Karya::Backend::Capabilities) - descriptor = instance_double( - Karya::Backend::Descriptor, - identifier: 'in_memory', - classification: :quick_setup_and_run, - capabilities: - ) + it 'delegates identifier through the descriptor' do + descriptor = instance_double(Karya::Backend::Descriptor, identifier: 'in_memory') backend_class = Class.new do include Karya::Backend::Base @@ -41,7 +35,5 @@ delegating_backend = backend_class.new expect(delegating_backend.identifier).to eq('in_memory') - expect(delegating_backend.classification).to eq(:quick_setup_and_run) - expect(delegating_backend.capabilities).to be(capabilities) end end diff --git a/core/karya/spec/karya/backend/capabilities_spec.rb b/core/karya/spec/karya/backend/capabilities_spec.rb deleted file mode 100644 index 1fbe4b21..00000000 --- a/core/karya/spec/karya/backend/capabilities_spec.rb +++ /dev/null @@ -1,106 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Karya::Backend::Capabilities do - it 'stores backend capability flags and parity exceptions immutably' do - capabilities = described_class.new( - job_persistence: true, - workflow_state: true, - schedule_state: false, - audit_history: true, - shared_processes: true, - multi_node: false, - parity_exceptions: ['Local deployments stay single-node'] - ) - - expect(capabilities.job_persistence?).to be(true) - expect(capabilities.workflow_state?).to be(true) - expect(capabilities.schedule_state?).to be(false) - expect(capabilities.audit_history?).to be(true) - expect(capabilities.shared_processes?).to be(true) - expect(capabilities.multi_node?).to be(false) - expect(capabilities.parity_exceptions).to eq(['Local deployments stay single-node']) - expect(capabilities).to be_frozen - expect(capabilities.parity_exceptions).to be_frozen - end - - it 'rejects non-boolean capability flags' do - expect do - described_class.new( - job_persistence: 'yes', - workflow_state: true, - schedule_state: true, - audit_history: true, - shared_processes: true, - multi_node: true - ) - end.to raise_error(Karya::InvalidBackendSelectionError, /job_persistence must be boolean/) - end - - it 'rejects missing required capability flags' do - expect do - described_class.new( - workflow_state: true, - schedule_state: true, - audit_history: true, - shared_processes: true, - multi_node: true - ) - end.to raise_error(Karya::InvalidBackendSelectionError, /job_persistence must be provided/) - end - - it 'rejects blank parity exceptions' do - expect do - described_class.new( - job_persistence: true, - workflow_state: true, - schedule_state: true, - audit_history: true, - shared_processes: true, - multi_node: true, - parity_exceptions: [' '] - ) - end.to raise_error(Karya::InvalidBackendSelectionError, /parity_exceptions entries must be present/) - end - - it 'rejects non-string parity exceptions' do - expect do - described_class.new( - job_persistence: true, - workflow_state: true, - schedule_state: true, - audit_history: true, - shared_processes: true, - multi_node: true, - parity_exceptions: [:local_only] - ) - end.to raise_error(Karya::InvalidBackendSelectionError, /parity_exceptions entries must be String values/) - end - - it 'rejects non-array parity exceptions' do - expect do - described_class.new( - job_persistence: true, - workflow_state: true, - schedule_state: true, - audit_history: true, - shared_processes: true, - multi_node: true, - parity_exceptions: 'local only' - ) - end.to raise_error(Karya::InvalidBackendSelectionError, /parity_exceptions must be an Array/) - end - - it 'rejects unknown capability attributes' do - expect do - described_class.new( - job_persistence: true, - workflow_state: true, - schedule_state: true, - audit_history: true, - shared_processes: true, - multi_node: true, - durable: true - ) - end.to raise_error(Karya::InvalidBackendSelectionError, /unknown capability attributes: durable/) - end -end diff --git a/core/karya/spec/karya/backend/descriptor_spec.rb b/core/karya/spec/karya/backend/descriptor_spec.rb index 4d1a64a7..8c79f3ba 100644 --- a/core/karya/spec/karya/backend/descriptor_spec.rb +++ b/core/karya/spec/karya/backend/descriptor_spec.rb @@ -1,71 +1,10 @@ # frozen_string_literal: true RSpec.describe Karya::Backend::Descriptor do - let(:capabilities) do - Karya::Backend::Capabilities.new( - job_persistence: true, - workflow_state: true, - schedule_state: true, - audit_history: true, - shared_processes: true, - multi_node: true - ) - end - - it 'normalizes the backend identifier and stores the classification' do - descriptor = described_class.new( - identifier: ' InMemory ', - classification: :quick_setup_and_run, - capabilities: - ) + it 'normalizes the backend identifier' do + descriptor = described_class.new(identifier: ' InMemory ') expect(descriptor.identifier).to eq('in_memory') - expect(descriptor.quick_setup_and_run?).to be(true) - expect(descriptor.production_like_local?).to be(false) - expect(descriptor.production_grade?).to be(false) expect(descriptor).to be_frozen end - - it 'accepts string classifications that Ruby normalizes to symbols' do - descriptor = described_class.new( - identifier: 'inmemory', - classification: 'quick_setup_and_run', - capabilities: - ) - - expect(descriptor.classification).to eq(:quick_setup_and_run) - end - - it 'rejects unsupported classifications' do - expect do - described_class.new(identifier: :in_memory, classification: :local, capabilities:) - end.to raise_error(Karya::InvalidBackendSelectionError, /classification must be one of/) - end - - it 'rejects unsupported string classifications' do - expect do - described_class.new(identifier: :in_memory, classification: 'local', capabilities:) - end.to raise_error(Karya::InvalidBackendSelectionError, /classification must be one of/) - end - - it 'rejects non-symbolizable classifications' do - expect do - described_class.new(identifier: :in_memory, classification: Object.new, capabilities:) - end.to raise_error(Karya::InvalidBackendSelectionError, /classification must be a String or Symbol/) - end - - it 'requires a backend capabilities object' do - expect do - described_class.new(identifier: :in_memory, classification: :production_grade, capabilities: Object.new) - end.to raise_error(Karya::InvalidBackendSelectionError, /capabilities must be a Karya::Backend::Capabilities/) - end - - it 'rejects classifications that contradict the shared backend selection model' do - expect do - described_class.new(identifier: :in_memory, classification: :production_grade, capabilities:) - end.to raise_error( - Karya::InvalidBackendSelectionError, - /classification :production_grade does not match backend "in_memory"; expected :quick_setup_and_run/ - ) - end end diff --git a/core/karya/spec/karya/backend/in_memory_spec.rb b/core/karya/spec/karya/backend/in_memory_spec.rb index 900344fc..320b3603 100644 --- a/core/karya/spec/karya/backend/in_memory_spec.rb +++ b/core/karya/spec/karya/backend/in_memory_spec.rb @@ -3,19 +3,11 @@ RSpec.describe Karya::Backend::InMemory do subject(:backend) { described_class.new } - it 'exposes the quick setup descriptor and non-durable capability posture' do + it 'exposes the in-memory backend descriptor' do descriptor = backend.descriptor expect(descriptor.identifier).to eq('in_memory') - expect(descriptor.classification).to eq(:quick_setup_and_run) - expect(descriptor.quick_setup_and_run?).to be(true) - expect(descriptor.capabilities.job_persistence?).to be(false) - expect(descriptor.capabilities.workflow_state?).to be(false) - expect(descriptor.capabilities.schedule_state?).to be(false) - expect(descriptor.capabilities.audit_history?).to be(false) - expect(descriptor.capabilities.shared_processes?).to be(false) - expect(descriptor.capabilities.multi_node?).to be(false) - expect(descriptor.capabilities.parity_exceptions).not_to be_empty + expect(descriptor).to be_frozen end it 'builds the queue store provider owned by the backend definition' do diff --git a/core/karya/spec/karya/backend/selection_spec.rb b/core/karya/spec/karya/backend/selection_spec.rb index f46123af..d5495352 100644 --- a/core/karya/spec/karya/backend/selection_spec.rb +++ b/core/karya/spec/karya/backend/selection_spec.rb @@ -39,33 +39,10 @@ end.to raise_error(Karya::UnsupportedBackendError, /unsupported backend "in-memory"/) end - it 'classifies inmemory as quick setup and run' do + it 'creates a selection for a known backend identifier' do selection = described_class.new('InMemory') expect(selection.identifier).to eq('in_memory') - expect(selection.classification).to eq(:quick_setup_and_run) - expect(selection.quick_setup_and_run?).to be(true) - expect(selection.production_like_local?).to be(false) - expect(selection.production_grade?).to be(false) - end - - it 'exposes the class-level quick setup predicate' do - expect(described_class.quick_setup_and_run?('InMemory')).to be(true) - end - - it 'exposes the class-level production-like-local predicate' do - expect(described_class.production_like_local?('sqlite')).to be(true) - end - - it 'exposes the class-level production-grade predicate' do - expect(described_class.production_grade?('postgres')).to be(true) - end - - it 'defines deployment classifications for the shared backend contract' do - expect(described_class::CLASSIFICATIONS.fetch('sqlite')).to eq(:production_like_local) - expect(described_class::CLASSIFICATIONS.fetch('redis')).to eq(:production_grade) - expect(described_class::CLASSIFICATIONS.fetch('postgres')).to eq(:production_grade) - expect(described_class::CLASSIFICATIONS.fetch('mysql')).to eq(:production_grade) end it 'reports whether an identifier is known' do diff --git a/docs/index.md b/docs/index.md index 387c276c..1a4b6ebe 100644 --- a/docs/index.md +++ b/docs/index.md @@ -21,7 +21,7 @@ workflows, framework hosts, operator tooling, and governed production rollout. ## What The Platform Includes - a canonical runtime and CLI -- backend adapters with an explicit capability matrix +- backend adapters with a shared backend selection contract - first-class integrations for plain Ruby, Rails, Sinatra, Roda, and Hanami - ActiveJob compatibility and migration guidance - an optional dashboard addon with shared UI delivery, internal APIs, and Kaal @@ -57,7 +57,7 @@ workflows, framework hosts, operator tooling, and governed production rollout. ## Explore The Docs -- [Architecture](/architecture/): package map, capability model, and how +- [Architecture](/architecture/): package map, product layers, and how the platform fits together - [Getting Started](/getting-started/): setup path for repository work and initial platform evaluation @@ -69,8 +69,8 @@ workflows, framework hosts, operator tooling, and governed production rollout. governed recovery, and backpressure - [Workflows](/workflows/): orchestration, replay, signals, child workflows, and versioning -- [Backends](/backends/): selection guidance, tiers, and capability - matrix +- [Backends](/backends/): selection guidance, backend identifiers, and + adapter fit - [Frameworks](/frameworks/): host integrations, ActiveJob, and parity notes - [Dashboard Hosting](/dashboard-hosting/): packaged asset contract and host diff --git a/docs/pages/architecture.md b/docs/pages/architecture.md index 8e30b99f..6352f51a 100644 --- a/docs/pages/architecture.md +++ b/docs/pages/architecture.md @@ -25,7 +25,7 @@ and operator surfaces each have a clear role and are meant to compose cleanly. | `gems/karya-roda` | Roda host integration | | `gems/karya-sinatra` | Sinatra host integration | -## Capability Model +## Product Layers Karya’s product model is built from these layers: @@ -34,8 +34,8 @@ Karya’s product model is built from these layers: dead-letter isolation, and recovery automation. 3. Orchestration: workflows, batch execution, signals, child workflows, approval checkpoints, replay, and version evolution. -4. Persistence: backend adapters, capability reporting, parity guarantees, and - documented exceptions. +4. Persistence: backend adapters, shared backend selection, and durable runtime + state. 5. Framework integration: host-native mounting, auth/session alignment, adapter pairing, and operator API exposure. 6. Operator experience: dashboard, APIs, CLI, search, drilldowns, activity, diff --git a/docs/pages/backends.md b/docs/pages/backends.md index 314c3a64..ab27be44 100644 --- a/docs/pages/backends.md +++ b/docs/pages/backends.md @@ -6,8 +6,8 @@ permalink: /backends/ # Backends -Karya documents backend support through an explicit capability matrix instead of -implying parity from package names alone. +Karya documents backend selection through a shared backend identifier contract +instead of implying selection semantics from package names alone. Backend choice shapes durability, operator workflows, scheduling behavior, failure recovery, and the overall fit between Karya and the rest of the stack. @@ -22,7 +22,7 @@ For most teams, Postgres is the easiest recommendation to defend: it fits the broader Karya product model, works well with framework integrations, and gives operators one durable system of record for execution and orchestration state. -## Support Matrix +## Backend Identifiers | Backend | Position | Typical Fit | | ---------- | ----------------------------- | ------------------------------------------------------ | @@ -39,7 +39,7 @@ Choose Postgres when: - you want the default production path - you expect workflows, schedules, audit history, and operator workflows to matter from the beginning -- you want the broadest fit across hosts and future backlog capability +- you want the broadest fit across hosts and operator workflows Choose Redis when: @@ -65,15 +65,19 @@ Choose `InMemory` when: - you need quick examples or tests - durability and multi-process production behavior are not part of the goal -## Capability Expectations +## Selection Contract -The documented backend contract covers parity for: +The shared backend contract defines these normalized backend identifiers: -- job and queue persistence -- workflow and batch state -- schedules and recurring-job state -- audit-relevant and operator-visible history -- capability reporting and intentional parity exceptions +- `postgres` +- `redis` +- `mysql` +- `sqlite` +- `in_memory` + +Selection input is normalized onto those identifiers. For example, `InMemory` +and `inmemory` resolve to `in_memory`, and `postgresql` resolves to +`postgres`. ## What Backends Influence @@ -81,14 +85,14 @@ Backend choice affects more than persistence: - how operators reason about queue depth, recovery, and history - how workflows and schedules remain durable across process or host failures -- what parity guarantees can be treated as universal versus backend-specific +- what adapter path and operational posture fit the deployment best - what troubleshooting guidance applies in production -## Unsupported Or Tiered Cases +## Selection Notes -When a backend has different scale, durability, or concurrency tradeoffs, the -docs call that out explicitly. `InMemory` is documented as a non-primary backend -for local/dev/test usage rather than a peer production recommendation. +Backend identifiers are part of the shared product contract. Adapter wiring, +runtime boot behavior, and backend-local implementation details are separate +concerns, but they all build on the same backend names. ## Common Scenarios diff --git a/docs/pages/getting-started.md b/docs/pages/getting-started.md index 9b60a130..4fa9c745 100644 --- a/docs/pages/getting-started.md +++ b/docs/pages/getting-started.md @@ -28,8 +28,8 @@ run the shared verification flow. 1. Read the [Architecture](/architecture/) page for the package map and platform boundaries. -2. Choose a backend using [Backends](/backends/). Postgres is the default - production recommendation. +2. Choose a backend identifier using [Backends](/backends/). Postgres is the + default production recommendation. 3. Choose a host integration from [Frameworks](/frameworks/). 4. Review [Dashboard Hosting](/dashboard-hosting/) if you want a framework host to include the optional dashboard addon. From 2c57b282ed0da15a4d031d2a1ae616337a25393f Mon Sep 17 00:00:00 2001 From: Nitesh Purohit Date: Sat, 2 May 2026 14:53:35 -0400 Subject: [PATCH 10/13] feat: enhance backend interface and signal handling --- core/karya/lib/karya/backend/in_memory.rb | 17 ++--- core/karya/lib/karya/worker_supervisor.rb | 17 +++-- .../spec/karya/backend/in_memory_spec.rb | 3 +- .../spec/karya/worker_supervisor_spec.rb | 65 +++++++++++++++++++ 4 files changed, 88 insertions(+), 14 deletions(-) diff --git a/core/karya/lib/karya/backend/in_memory.rb b/core/karya/lib/karya/backend/in_memory.rb index 0404ff32..afcb6708 100644 --- a/core/karya/lib/karya/backend/in_memory.rb +++ b/core/karya/lib/karya/backend/in_memory.rb @@ -14,6 +14,7 @@ class InMemory include Base DESCRIPTOR = Descriptor.new(identifier: :in_memory) + UNSET = Object.new.freeze def initialize(queue_store_class: QueueStore::InMemory) @queue_store_class = queue_store_class @@ -24,13 +25,13 @@ def descriptor end def build_queue_store( - token_generator: nil, - expired_tombstone_limit: nil, - completed_batch_retention_limit: nil, - max_batch_size: nil, - policy_set: nil, - circuit_breaker_policy_set: nil, - fairness_policy: nil + token_generator: UNSET, + expired_tombstone_limit: UNSET, + completed_batch_retention_limit: UNSET, + max_batch_size: UNSET, + policy_set: UNSET, + circuit_breaker_policy_set: UNSET, + fairness_policy: UNSET ) queue_store = queue_store_class.new(**{ token_generator:, @@ -40,7 +41,7 @@ def build_queue_store( policy_set:, circuit_breaker_policy_set:, fairness_policy: - }.compact) + }.reject { |_name, value| value.equal?(UNSET) }) return queue_store if queue_store.is_a?(QueueStore::Base) raise InvalidBackendSelectionError, 'queue_store_class must build a Karya::QueueStore::Base' diff --git a/core/karya/lib/karya/worker_supervisor.rb b/core/karya/lib/karya/worker_supervisor.rb index d0935481..c5c9845a 100644 --- a/core/karya/lib/karya/worker_supervisor.rb +++ b/core/karya/lib/karya/worker_supervisor.rb @@ -261,11 +261,18 @@ def register_signal_restorers(restorers, shutdown_controller) def handle_shutdown_signal(shutdown_controller) phase = shutdown_controller.advance - if phase == Internal::RuntimeSupport::ShutdownState::DRAINING - runtime_state_store.mark_supervisor_phase(RuntimeStateStore::DRAINING_PHASE) - elsif phase == Internal::RuntimeSupport::ShutdownState::FORCE_STOP - runtime_state_store.mark_supervisor_phase(RuntimeStateStore::FORCE_STOPPING_PHASE) - end + runtime_phase = + if phase == Internal::RuntimeSupport::ShutdownState::DRAINING + RuntimeStateStore::DRAINING_PHASE + elsif phase == Internal::RuntimeSupport::ShutdownState::FORCE_STOP + RuntimeStateStore::FORCE_STOPPING_PHASE + end + return unless runtime_phase + return unless control_monitor.synchronize { @running_claimed } + + runtime_state_store.mark_supervisor_phase(runtime_phase) + rescue InvalidRuntimeStateFileError + nil ensure WakeupSignal.interrupt(WAKEUP_SIGNAL) end diff --git a/core/karya/spec/karya/backend/in_memory_spec.rb b/core/karya/spec/karya/backend/in_memory_spec.rb index 320b3603..f5c204c6 100644 --- a/core/karya/spec/karya/backend/in_memory_spec.rb +++ b/core/karya/spec/karya/backend/in_memory_spec.rb @@ -26,7 +26,7 @@ expect(backend.build_queue_store).to be(queue_store) end - it 'forwards provided queue-store builder keywords and omits nil values' do + it 'forwards provided queue-store builder keywords and preserves explicit nil values' do queue_store = Karya::QueueStore::InMemory.new captured_options = nil queue_store_factory = Class.new do @@ -48,6 +48,7 @@ expect(captured_options).to eq( token_generator:, expired_tombstone_limit: 12, + completed_batch_retention_limit: nil, max_batch_size: 50 ) end diff --git a/core/karya/spec/karya/worker_supervisor_spec.rb b/core/karya/spec/karya/worker_supervisor_spec.rb index 22a71910..9905d16d 100644 --- a/core/karya/spec/karya/worker_supervisor_spec.rb +++ b/core/karya/spec/karya/worker_supervisor_spec.rb @@ -298,6 +298,71 @@ def execute_child_block? expect(runtime_state_store).to have_received(:mark_supervisor_phase).twice end + it 'does not write runtime phases from signal handlers before the run is claimed' do + runtime_state_store = instance_double( + described_class.const_get(:RuntimeStateStore, false), + mark_supervisor_phase: nil + ) + allow(described_class.const_get(:WakeupSignal, false)).to receive(:interrupt) + supervisor_with_runtime_state = described_class.new( + queue_store: queue_store, + worker_id: 'worker-supervisor', + queues: ['billing'], + handlers: { 'billing_sync' => -> {} }, + lease_duration: 30, + processes: 1, + threads: 1, + poll_interval: 0, + max_iterations: 1, + runtime: runtime, + runtime_state_store: runtime_state_store, + child_worker_class: child_worker_class + ) + + expect do + supervisor_with_runtime_state.send( + :handle_shutdown_signal, + Karya::Internal::RuntimeSupport::ShutdownState.new + ) + end.not_to raise_error + + expect(runtime_state_store).not_to have_received(:mark_supervisor_phase) + end + + it 'does not abort signal handling when runtime phase persistence fails' do + runtime_state_store = instance_double( + described_class.const_get(:RuntimeStateStore, false), + mark_supervisor_phase: nil + ) + allow(described_class.const_get(:WakeupSignal, false)).to receive(:interrupt) + allow(runtime_state_store).to receive(:mark_supervisor_phase) + .and_raise(described_class.const_get(:InvalidRuntimeStateFileError, false), 'boom') + supervisor_with_runtime_state = described_class.new( + queue_store: queue_store, + worker_id: 'worker-supervisor', + queues: ['billing'], + handlers: { 'billing_sync' => -> {} }, + lease_duration: 30, + processes: 1, + threads: 1, + poll_interval: 0, + max_iterations: 1, + runtime: runtime, + runtime_state_store: runtime_state_store, + child_worker_class: child_worker_class + ) + supervisor_with_runtime_state.instance_variable_set(:@running_claimed, true) + + expect do + supervisor_with_runtime_state.send( + :handle_shutdown_signal, + Karya::Internal::RuntimeSupport::ShutdownState.new + ) + end.not_to raise_error + + expect(runtime_state_store).to have_received(:mark_supervisor_phase).with('draining') + end + it 'supports begin_drain through the public control API while running' do wait_call_count = 0 allow(runtime).to receive(:wait_for_child) do From 4e70de7fb09abb3fb35926beaa28fcc5ce58e25f Mon Sep 17 00:00:00 2001 From: Nitesh Purohit Date: Sat, 2 May 2026 15:24:47 -0400 Subject: [PATCH 11/13] feat: enhance backend interface and error handling --- core/karya/lib/karya/backend/in_memory.rb | 1 + core/karya/lib/karya/worker_supervisor.rb | 24 +--- .../lib/karya/worker_supervisor/runtime.rb | 28 +++-- core/karya/sig/karya/backend/in_memory.rbs | 14 +-- .../runtime_support/shutdown_state.rbs | 2 +- core/karya/sig/karya/worker_supervisor.rbs | 2 + .../sig/karya/worker_supervisor/runtime.rbs | 2 + .../karya/worker_supervisor/runtime_spec.rb | 16 ++- .../spec/karya/worker_supervisor_spec.rb | 110 ------------------ 9 files changed, 51 insertions(+), 148 deletions(-) diff --git a/core/karya/lib/karya/backend/in_memory.rb b/core/karya/lib/karya/backend/in_memory.rb index afcb6708..3700d713 100644 --- a/core/karya/lib/karya/backend/in_memory.rb +++ b/core/karya/lib/karya/backend/in_memory.rb @@ -15,6 +15,7 @@ class InMemory DESCRIPTOR = Descriptor.new(identifier: :in_memory) UNSET = Object.new.freeze + private_constant :UNSET def initialize(queue_store_class: QueueStore::InMemory) @queue_store_class = queue_store_class diff --git a/core/karya/lib/karya/worker_supervisor.rb b/core/karya/lib/karya/worker_supervisor.rb index c5c9845a..c0bcbda4 100644 --- a/core/karya/lib/karya/worker_supervisor.rb +++ b/core/karya/lib/karya/worker_supervisor.rb @@ -255,28 +255,14 @@ def collect_signal_restorers(restorers, shutdown_controller) def register_signal_restorers(restorers, shutdown_controller) SIGNALS.each do |signal| - restorers << runtime.subscribe_signal(signal, -> { handle_shutdown_signal(shutdown_controller) }) + restorers << runtime.subscribe_signal(signal, proc do + shutdown_controller.advance + ensure + WakeupSignal.interrupt(WAKEUP_SIGNAL) + end) end end - def handle_shutdown_signal(shutdown_controller) - phase = shutdown_controller.advance - runtime_phase = - if phase == Internal::RuntimeSupport::ShutdownState::DRAINING - RuntimeStateStore::DRAINING_PHASE - elsif phase == Internal::RuntimeSupport::ShutdownState::FORCE_STOP - RuntimeStateStore::FORCE_STOPPING_PHASE - end - return unless runtime_phase - return unless control_monitor.synchronize { @running_claimed } - - runtime_state_store.mark_supervisor_phase(runtime_phase) - rescue InvalidRuntimeStateFileError - nil - ensure - WakeupSignal.interrupt(WAKEUP_SIGNAL) - end - def cleanup_tracked_children(child_pids) shutdown_tracked_children(child_pids, GRACEFUL_SIGNAL, blocking: false) return if child_pids.empty? diff --git a/core/karya/lib/karya/worker_supervisor/runtime.rb b/core/karya/lib/karya/worker_supervisor/runtime.rb index 9666d667..c87ae60a 100644 --- a/core/karya/lib/karya/worker_supervisor/runtime.rb +++ b/core/karya/lib/karya/worker_supervisor/runtime.rb @@ -14,6 +14,7 @@ class WorkerSupervisor # Supervisor runtime hooks for process management and signal handling. class Runtime OPTION_KEYS = %i[forker instrumenter killer logger outbound_event_dispatcher poll_waiter signal_subscriber waiter].freeze + WAIT_FOR_CHILD_POLL_INTERVAL = 0.05 UNSET = Object.new.freeze attr_reader :instrumenter, :logger, :outbound_event_dispatcher, :signal_subscriber @@ -29,6 +30,13 @@ def self.default_killer ->(signal, pid) { Process.kill(signal, pid) } end + def self.default_signal_subscriber + lambda do |signal, handler| + previous_handler = Signal.trap(signal) { handler.call } + -> { Signal.trap(signal, previous_handler) } + end + end + def self.normalize_callable(name, value) Primitives::Callable.new(name, value, error_class: InvalidWorkerSupervisorConfigurationError).normalize end @@ -66,34 +74,34 @@ def initialize(**attributes) runtime_class = self.class @forker = runtime_class.normalize_forker( :forker, - runtime_class.resolve_option(attributes, :forker, default: method(:default_forker)) + resolve_runtime_option(attributes, :forker, default: method(:default_forker)) ) @instrumenter = runtime_class.normalize_optional_callable( :instrumenter, - runtime_class.resolve_option(attributes, :instrumenter, default: Karya.instrumenter) + resolve_runtime_option(attributes, :instrumenter, default: Karya.instrumenter) ) @killer = runtime_class.normalize_callable( :killer, - runtime_class.resolve_option(attributes, :killer, default: runtime_class.default_killer) + resolve_runtime_option(attributes, :killer, default: runtime_class.default_killer) ) @logger = validate_logger( - runtime_class.resolve_option(attributes, :logger, default: Karya.logger) + resolve_runtime_option(attributes, :logger, default: Karya.logger) ) @outbound_event_dispatcher = runtime_class.normalize_optional_outbound_event_dispatcher( :outbound_event_dispatcher, - runtime_class.resolve_option(attributes, :outbound_event_dispatcher, default: Karya.outbound_event_dispatcher) + resolve_runtime_option(attributes, :outbound_event_dispatcher, default: Karya.outbound_event_dispatcher) ) @poll_waiter = runtime_class.normalize_callable( :poll_waiter, - runtime_class.resolve_option(attributes, :poll_waiter, default: default_poll_waiter) + resolve_runtime_option(attributes, :poll_waiter, default: default_poll_waiter) ) @signal_subscriber = runtime_class.normalize_optional_callable( :signal_subscriber, - runtime_class.resolve_option(attributes, :signal_subscriber, default: nil) + resolve_runtime_option(attributes, :signal_subscriber, default: runtime_class.default_signal_subscriber) ) @waiter = runtime_class.normalize_callable( :waiter, - runtime_class.resolve_option(attributes, :waiter, default: default_waiter) + resolve_runtime_option(attributes, :waiter, default: default_waiter) ) end @@ -179,6 +187,10 @@ def default_waiter private + def resolve_runtime_option(attributes, key, default:) + self.class.resolve_option(attributes, key, default:) + end + def validate_logger(value) %i[debug info warn error].each do |level| value.public_method(level) diff --git a/core/karya/sig/karya/backend/in_memory.rbs b/core/karya/sig/karya/backend/in_memory.rbs index ada23cd3..14534a1f 100644 --- a/core/karya/sig/karya/backend/in_memory.rbs +++ b/core/karya/sig/karya/backend/in_memory.rbs @@ -2,13 +2,13 @@ module Karya module Backend interface _QueueStoreFactory def new: ( - ?token_generator: Karya::callable_value, - ?expired_tombstone_limit: Integer, - ?completed_batch_retention_limit: Integer, - ?max_batch_size: Integer, - ?policy_set: Backpressure::PolicySet, - ?circuit_breaker_policy_set: CircuitBreaker::PolicySet, - ?fairness_policy: Fairness::Policy + ?token_generator: Karya::callable_value?, + ?expired_tombstone_limit: Integer?, + ?completed_batch_retention_limit: Integer?, + ?max_batch_size: Integer?, + ?policy_set: Backpressure::PolicySet?, + ?circuit_breaker_policy_set: CircuitBreaker::PolicySet?, + ?fairness_policy: Fairness::Policy? ) -> QueueStore::Base end diff --git a/core/karya/sig/karya/internal/runtime_support/shutdown_state.rbs b/core/karya/sig/karya/internal/runtime_support/shutdown_state.rbs index ebe7c002..4573681c 100644 --- a/core/karya/sig/karya/internal/runtime_support/shutdown_state.rbs +++ b/core/karya/sig/karya/internal/runtime_support/shutdown_state.rbs @@ -20,7 +20,7 @@ module Karya def initialize: () -> void - def advance: () -> void + def advance: () -> (:draining | :force_stop | nil) def begin_drain: () -> bool diff --git a/core/karya/sig/karya/worker_supervisor.rbs b/core/karya/sig/karya/worker_supervisor.rbs index 457b07f7..ab6fc5a6 100644 --- a/core/karya/sig/karya/worker_supervisor.rbs +++ b/core/karya/sig/karya/worker_supervisor.rbs @@ -84,6 +84,8 @@ module Karya def with_signal_handlers: (WorkerSupervisor::ShutdownController shutdown_controller) { () -> Integer } -> Integer + def collect_signal_restorers: (Array[^() -> nil] restorers, WorkerSupervisor::ShutdownController shutdown_controller) -> Array[^() -> nil] + def register_signal_restorers: (Array[^() -> nil] restorers, WorkerSupervisor::ShutdownController shutdown_controller) -> Array[^() -> nil] def cleanup_tracked_children: (::Hash[Integer, bool] child_pids) -> nil diff --git a/core/karya/sig/karya/worker_supervisor/runtime.rbs b/core/karya/sig/karya/worker_supervisor/runtime.rbs index 5d2eb77b..d9a2e7de 100644 --- a/core/karya/sig/karya/worker_supervisor/runtime.rbs +++ b/core/karya/sig/karya/worker_supervisor/runtime.rbs @@ -38,6 +38,8 @@ module Karya def self.default_killer: () -> ^(String, Integer) -> Integer + def self.default_signal_subscriber: () -> ^(String, Karya::signal_handler) -> Karya::signal_restorer + def self.normalize_callable: (Symbol name, Karya::callable_value value) -> Karya::callable_value def self.normalize_forker: (Symbol name, Karya::forker value) -> Karya::forker diff --git a/core/karya/spec/karya/worker_supervisor/runtime_spec.rb b/core/karya/spec/karya/worker_supervisor/runtime_spec.rb index 5fb653ef..cf4bdf5d 100644 --- a/core/karya/spec/karya/worker_supervisor/runtime_spec.rb +++ b/core/karya/spec/karya/worker_supervisor/runtime_spec.rb @@ -125,13 +125,15 @@ end describe 'unit tests' do - it 'covers runtime defaults and no-op signal subscriptions' do + it 'covers runtime defaults and default signal subscriptions' do + allow(Signal).to receive(:trap).and_return('DEFAULT') runtime_instance = runtime_class.new - noop = runtime_instance.subscribe_signal('TERM', -> {}) + restorer = runtime_instance.subscribe_signal('TERM', -> {}) pid = runtime_instance.fork_child { 1 } waited_pid, waited_status = runtime_instance.wait_for_child - expect(noop).to respond_to(:call) + expect(restorer).to respond_to(:call) + restorer.call expect(pid).to be_a(Integer) expect(waited_pid).to eq(pid) expect(waited_status.success?).to be(true) @@ -358,6 +360,14 @@ expect(subscriptions.keys).to eq(['TERM']) end + it 'returns the noop subscription when signal subscriptions are explicitly disabled' do + runtime_instance = runtime_class.new(signal_subscriber: nil) + subscription = runtime_instance.subscribe_signal('TERM', -> {}) + + expect(subscription).to respond_to(:call) + expect(subscription.call).to be_nil + end + it 'rejects non-callable signal subscriber restorers' do runtime_instance = runtime_class.new(signal_subscriber: ->(_signal, _handler) { 'DEFAULT' }) diff --git a/core/karya/spec/karya/worker_supervisor_spec.rb b/core/karya/spec/karya/worker_supervisor_spec.rb index 9905d16d..6ff227ac 100644 --- a/core/karya/spec/karya/worker_supervisor_spec.rb +++ b/core/karya/spec/karya/worker_supervisor_spec.rb @@ -253,116 +253,6 @@ def execute_child_block? expect(supervisor.runtime_snapshot.phase).to eq('stopped') end - it 'records runtime phases when OS signals escalate shutdown' do - wait_call_count = 0 - runtime_state_store = instance_double( - described_class.const_get(:RuntimeStateStore, false), - write_running: nil, - write_stopped: nil, - control_socket_path: '/tmp/runtime.sock', - instance_token: 'runtime-token', - snapshot: instance_double(described_class.const_get(:RuntimeSnapshot, false), phase: 'running'), - register_child: nil, - mark_supervisor_phase: nil, - mark_child_phase: nil, - mark_child_stopped: nil - ) - allow(runtime).to receive(:wait_for_child) do - wait_call_count += 1 - if wait_call_count <= 3 - subscriptions.fetch('TERM').call - nil - else - wait_results.shift - end - end - wait_results << [100, failure_status] - supervisor_with_runtime_state = described_class.new( - queue_store: queue_store, - worker_id: 'worker-supervisor', - queues: ['billing'], - handlers: { 'billing_sync' => -> {} }, - lease_duration: 30, - processes: 1, - threads: 1, - poll_interval: 0, - max_iterations: 1, - runtime: runtime, - runtime_state_store: runtime_state_store, - child_worker_class: child_worker_class - ) - - expect(supervisor_with_runtime_state.run).to eq(1) - expect(runtime_state_store).to have_received(:mark_supervisor_phase).with('draining').ordered - expect(runtime_state_store).to have_received(:mark_supervisor_phase).with('force_stopping').ordered - expect(runtime_state_store).to have_received(:mark_supervisor_phase).twice - end - - it 'does not write runtime phases from signal handlers before the run is claimed' do - runtime_state_store = instance_double( - described_class.const_get(:RuntimeStateStore, false), - mark_supervisor_phase: nil - ) - allow(described_class.const_get(:WakeupSignal, false)).to receive(:interrupt) - supervisor_with_runtime_state = described_class.new( - queue_store: queue_store, - worker_id: 'worker-supervisor', - queues: ['billing'], - handlers: { 'billing_sync' => -> {} }, - lease_duration: 30, - processes: 1, - threads: 1, - poll_interval: 0, - max_iterations: 1, - runtime: runtime, - runtime_state_store: runtime_state_store, - child_worker_class: child_worker_class - ) - - expect do - supervisor_with_runtime_state.send( - :handle_shutdown_signal, - Karya::Internal::RuntimeSupport::ShutdownState.new - ) - end.not_to raise_error - - expect(runtime_state_store).not_to have_received(:mark_supervisor_phase) - end - - it 'does not abort signal handling when runtime phase persistence fails' do - runtime_state_store = instance_double( - described_class.const_get(:RuntimeStateStore, false), - mark_supervisor_phase: nil - ) - allow(described_class.const_get(:WakeupSignal, false)).to receive(:interrupt) - allow(runtime_state_store).to receive(:mark_supervisor_phase) - .and_raise(described_class.const_get(:InvalidRuntimeStateFileError, false), 'boom') - supervisor_with_runtime_state = described_class.new( - queue_store: queue_store, - worker_id: 'worker-supervisor', - queues: ['billing'], - handlers: { 'billing_sync' => -> {} }, - lease_duration: 30, - processes: 1, - threads: 1, - poll_interval: 0, - max_iterations: 1, - runtime: runtime, - runtime_state_store: runtime_state_store, - child_worker_class: child_worker_class - ) - supervisor_with_runtime_state.instance_variable_set(:@running_claimed, true) - - expect do - supervisor_with_runtime_state.send( - :handle_shutdown_signal, - Karya::Internal::RuntimeSupport::ShutdownState.new - ) - end.not_to raise_error - - expect(runtime_state_store).to have_received(:mark_supervisor_phase).with('draining') - end - it 'supports begin_drain through the public control API while running' do wait_call_count = 0 allow(runtime).to receive(:wait_for_child) do From f2b27078c010c292bbc6ce30dcd7d3e68f6040ea Mon Sep 17 00:00:00 2001 From: Nitesh Purohit Date: Sat, 2 May 2026 16:22:14 -0400 Subject: [PATCH 12/13] feat: implement shared backend interface and model --- core/karya/lib/karya/backend.rb | 11 +- core/karya/lib/karya/backend/base.rb | 4 - core/karya/lib/karya/backend/descriptor.rb | 22 --- core/karya/lib/karya/backend/in_memory.rb | 10 +- core/karya/lib/karya/backend/selection.rb | 59 --------- core/karya/lib/karya/worker_supervisor.rb | 1 + .../lib/karya/worker_supervisor/runtime.rb | 1 - core/karya/sig/karya.rbs | 2 - core/karya/sig/karya/backend.rbs | 5 +- core/karya/sig/karya/backend/base.rbs | 3 +- core/karya/sig/karya/backend/descriptor.rbs | 11 -- core/karya/sig/karya/backend/in_memory.rbs | 4 +- core/karya/sig/karya/backend/selection.rbs | 17 --- core/karya/spec/karya/backend/base_spec.rb | 17 +-- .../spec/karya/backend/descriptor_spec.rb | 10 -- .../spec/karya/backend/in_memory_spec.rb | 23 +++- .../spec/karya/backend/selection_spec.rb | 54 -------- docs/index.md | 10 +- docs/pages/adoption/goodjob.md | 5 +- docs/pages/architecture.md | 2 +- docs/pages/backends.md | 125 +++++++++--------- docs/pages/getting-started.md | 6 +- docs/pages/troubleshooting.md | 4 +- 23 files changed, 104 insertions(+), 302 deletions(-) delete mode 100644 core/karya/lib/karya/backend/descriptor.rb delete mode 100644 core/karya/lib/karya/backend/selection.rb delete mode 100644 core/karya/sig/karya/backend/descriptor.rbs delete mode 100644 core/karya/sig/karya/backend/selection.rbs delete mode 100644 core/karya/spec/karya/backend/descriptor_spec.rb delete mode 100644 core/karya/spec/karya/backend/selection_spec.rb diff --git a/core/karya/lib/karya/backend.rb b/core/karya/lib/karya/backend.rb index 274aa0d6..dbb79ba2 100644 --- a/core/karya/lib/karya/backend.rb +++ b/core/karya/lib/karya/backend.rb @@ -6,17 +6,12 @@ # LICENSE file in the root directory of this source tree. module Karya - # Raised when backend selection input cannot be normalized into a supported identifier. - class InvalidBackendSelectionError < Error; end + # Raised when backend configuration is invalid. + class InvalidBackendConfigurationError < Error; end - # Raised when a caller refers to a backend outside the supported backend set. - class UnsupportedBackendError < Error; end - - # Namespace for backend selection and lifecycle contracts. + # Namespace for backend interface and lifecycle contracts. module Backend autoload :Base, 'karya/backend/base' - autoload :Descriptor, 'karya/backend/descriptor' autoload :InMemory, 'karya/backend/in_memory' - autoload :Selection, 'karya/backend/selection' end end diff --git a/core/karya/lib/karya/backend/base.rb b/core/karya/lib/karya/backend/base.rb index 262dca18..06aa1cae 100644 --- a/core/karya/lib/karya/backend/base.rb +++ b/core/karya/lib/karya/backend/base.rb @@ -10,10 +10,6 @@ module Backend # Shared backend contract above the queue-store persistence API. module Base def identifier - descriptor.identifier - end - - def descriptor raise NotImplementedError, "#{self.class} must implement ##{__method__}" end diff --git a/core/karya/lib/karya/backend/descriptor.rb b/core/karya/lib/karya/backend/descriptor.rb deleted file mode 100644 index c48c0807..00000000 --- a/core/karya/lib/karya/backend/descriptor.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -# Copyright Codevedas Inc. 2025-present -# -# This source code is licensed under the MIT license found in the -# LICENSE file in the root directory of this source tree. - -require_relative '../primitives/identifier' - -module Karya - module Backend - # Immutable backend identity description. - class Descriptor - attr_reader :identifier - - def initialize(identifier:) - @identifier = Selection.normalize_identifier(identifier) - freeze - end - end - end -end diff --git a/core/karya/lib/karya/backend/in_memory.rb b/core/karya/lib/karya/backend/in_memory.rb index 3700d713..3ec1ad26 100644 --- a/core/karya/lib/karya/backend/in_memory.rb +++ b/core/karya/lib/karya/backend/in_memory.rb @@ -5,6 +5,8 @@ # This source code is licensed under the MIT license found in the # LICENSE file in the root directory of this source tree. +require_relative '../base' +require_relative '../backend' require_relative '../queue_store/in_memory' module Karya @@ -13,17 +15,15 @@ module Backend class InMemory include Base - DESCRIPTOR = Descriptor.new(identifier: :in_memory) UNSET = Object.new.freeze private_constant :UNSET def initialize(queue_store_class: QueueStore::InMemory) + @identifier = 'in_memory' @queue_store_class = queue_store_class end - def descriptor - DESCRIPTOR - end + attr_reader :identifier def build_queue_store( token_generator: UNSET, @@ -45,7 +45,7 @@ def build_queue_store( }.reject { |_name, value| value.equal?(UNSET) }) return queue_store if queue_store.is_a?(QueueStore::Base) - raise InvalidBackendSelectionError, 'queue_store_class must build a Karya::QueueStore::Base' + raise InvalidBackendConfigurationError, 'queue_store_class must build a Karya::QueueStore::Base' end def before_start(queue_store:) diff --git a/core/karya/lib/karya/backend/selection.rb b/core/karya/lib/karya/backend/selection.rb deleted file mode 100644 index e160056c..00000000 --- a/core/karya/lib/karya/backend/selection.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true - -# Copyright Codevedas Inc. 2025-present -# -# This source code is licensed under the MIT license found in the -# LICENSE file in the root directory of this source tree. - -require_relative '../primitives/identifier' - -module Karya - module Backend - # Normalized backend selection without runtime boot wiring. - class Selection - KNOWN_IDENTIFIERS = %w[in_memory sqlite redis postgres mysql].freeze - - IDENTIFIER_ALIASES = { - 'InMemory' => 'in_memory', - 'inmemory' => 'in_memory', - 'in_memory' => 'in_memory', - 'sqlite' => 'sqlite', - 'redis' => 'redis', - 'postgres' => 'postgres', - 'postgresql' => 'postgres', - 'mysql' => 'mysql', - 'my_sql' => 'mysql' - }.freeze - - attr_reader :identifier - - def self.normalize_identifier(value) - normalized_input = normalize_identifier_input(value) - normalized_alias = IDENTIFIER_ALIASES[normalized_input] - return normalized_alias.freeze if normalized_alias - - raise UnsupportedBackendError, - "unsupported backend #{normalized_input.inspect}; known backends: #{KNOWN_IDENTIFIERS.join(', ')}" - end - - def self.known_identifier?(value) - KNOWN_IDENTIFIERS.include?(normalize_identifier(value)) - rescue InvalidBackendSelectionError, UnsupportedBackendError - false - end - - def initialize(value) - @identifier = self.class.normalize_identifier(value) - end - - def self.normalize_identifier_input(value) - if [NilClass, String, Symbol].any? { |klass| value.is_a?(klass) } - return Primitives::Identifier.new(:backend, value, error_class: InvalidBackendSelectionError).normalize - end - - raise InvalidBackendSelectionError, 'backend must be a String or Symbol' - end - private_class_method :normalize_identifier_input - end - end -end diff --git a/core/karya/lib/karya/worker_supervisor.rb b/core/karya/lib/karya/worker_supervisor.rb index c0bcbda4..7a6740a4 100644 --- a/core/karya/lib/karya/worker_supervisor.rb +++ b/core/karya/lib/karya/worker_supervisor.rb @@ -261,6 +261,7 @@ def register_signal_restorers(restorers, shutdown_controller) WakeupSignal.interrupt(WAKEUP_SIGNAL) end) end + restorers end def cleanup_tracked_children(child_pids) diff --git a/core/karya/lib/karya/worker_supervisor/runtime.rb b/core/karya/lib/karya/worker_supervisor/runtime.rb index c87ae60a..899750f1 100644 --- a/core/karya/lib/karya/worker_supervisor/runtime.rb +++ b/core/karya/lib/karya/worker_supervisor/runtime.rb @@ -14,7 +14,6 @@ class WorkerSupervisor # Supervisor runtime hooks for process management and signal handling. class Runtime OPTION_KEYS = %i[forker instrumenter killer logger outbound_event_dispatcher poll_waiter signal_subscriber waiter].freeze - WAIT_FOR_CHILD_POLL_INTERVAL = 0.05 UNSET = Object.new.freeze attr_reader :instrumenter, :logger, :outbound_event_dispatcher, :signal_subscriber diff --git a/core/karya/sig/karya.rbs b/core/karya/sig/karya.rbs index 226887a0..75b369b0 100644 --- a/core/karya/sig/karya.rbs +++ b/core/karya/sig/karya.rbs @@ -440,8 +440,6 @@ module Karya type symbol_options = ::Hash[Symbol, option_value] type mixed_options = ::Hash[Symbol | String, option_value] type job_arguments = ::Hash[String, job_argument] - type backend_identifier = "in_memory" | "sqlite" | "redis" | "postgres" | "mysql" - type backend_identifier_input = state_name? type error_class = singleton(StandardError) type logger = Internal::_Logger type instrumenter = ^(String, context_payload) -> nil diff --git a/core/karya/sig/karya/backend.rbs b/core/karya/sig/karya/backend.rbs index 1624eb20..fde37a3b 100644 --- a/core/karya/sig/karya/backend.rbs +++ b/core/karya/sig/karya/backend.rbs @@ -4,10 +4,7 @@ # LICENSE file in the root directory of this source tree. module Karya - class InvalidBackendSelectionError < Error - end - - class UnsupportedBackendError < Error + class InvalidBackendConfigurationError < Error end module Backend diff --git a/core/karya/sig/karya/backend/base.rbs b/core/karya/sig/karya/backend/base.rbs index 62e86857..130e8c26 100644 --- a/core/karya/sig/karya/backend/base.rbs +++ b/core/karya/sig/karya/backend/base.rbs @@ -1,8 +1,7 @@ module Karya module Backend module Base - def identifier: () -> backend_identifier - def descriptor: () -> Descriptor + def identifier: () -> String def build_queue_store: () -> QueueStore::Base def before_start: (queue_store: QueueStore::Base) -> nil def after_stop: (queue_store: QueueStore::Base) -> nil diff --git a/core/karya/sig/karya/backend/descriptor.rbs b/core/karya/sig/karya/backend/descriptor.rbs deleted file mode 100644 index bcabc7d7..00000000 --- a/core/karya/sig/karya/backend/descriptor.rbs +++ /dev/null @@ -1,11 +0,0 @@ -module Karya - module Backend - class Descriptor - @identifier: backend_identifier - - def initialize: (identifier: backend_identifier_input) -> void - - attr_reader identifier: backend_identifier - end - end -end diff --git a/core/karya/sig/karya/backend/in_memory.rbs b/core/karya/sig/karya/backend/in_memory.rbs index 14534a1f..6435bcf5 100644 --- a/core/karya/sig/karya/backend/in_memory.rbs +++ b/core/karya/sig/karya/backend/in_memory.rbs @@ -15,10 +15,8 @@ module Karya class InMemory include Base - DESCRIPTOR: Descriptor - def initialize: (?queue_store_class: _QueueStoreFactory) -> void - def descriptor: () -> Descriptor + def identifier: () -> "in_memory" def build_queue_store: ( ?token_generator: Karya::callable_value?, ?expired_tombstone_limit: Integer?, diff --git a/core/karya/sig/karya/backend/selection.rbs b/core/karya/sig/karya/backend/selection.rbs deleted file mode 100644 index 7473593d..00000000 --- a/core/karya/sig/karya/backend/selection.rbs +++ /dev/null @@ -1,17 +0,0 @@ -module Karya - module Backend - class Selection - KNOWN_IDENTIFIERS: Array[backend_identifier] - IDENTIFIER_ALIASES: Hash[String, backend_identifier] - - @identifier: backend_identifier - - def self.normalize_identifier: (backend_identifier_input value) -> backend_identifier - def self.known_identifier?: (backend_identifier_input value) -> bool - - def initialize: (backend_identifier_input value) -> void - - attr_reader identifier: backend_identifier - end - end -end diff --git a/core/karya/spec/karya/backend/base_spec.rb b/core/karya/spec/karya/backend/base_spec.rb index 9ec168d1..6abd8804 100644 --- a/core/karya/spec/karya/backend/base_spec.rb +++ b/core/karya/spec/karya/backend/base_spec.rb @@ -9,8 +9,8 @@ end end - it 'requires descriptor to be implemented' do - expect { backend.descriptor }.to raise_error(NotImplementedError, /implement #descriptor/) + it 'requires identifier to be implemented' do + expect { backend.identifier }.to raise_error(NotImplementedError, /implement #identifier/) end it 'requires build_queue_store to be implemented' do @@ -23,17 +23,4 @@ expect(backend.before_start(queue_store:)).to be_nil expect(backend.after_stop(queue_store:)).to be_nil end - - it 'delegates identifier through the descriptor' do - descriptor = instance_double(Karya::Backend::Descriptor, identifier: 'in_memory') - backend_class = Class.new do - include Karya::Backend::Base - - define_method(:descriptor) { descriptor } - end - - delegating_backend = backend_class.new - - expect(delegating_backend.identifier).to eq('in_memory') - end end diff --git a/core/karya/spec/karya/backend/descriptor_spec.rb b/core/karya/spec/karya/backend/descriptor_spec.rb deleted file mode 100644 index 8c79f3ba..00000000 --- a/core/karya/spec/karya/backend/descriptor_spec.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Karya::Backend::Descriptor do - it 'normalizes the backend identifier' do - descriptor = described_class.new(identifier: ' InMemory ') - - expect(descriptor.identifier).to eq('in_memory') - expect(descriptor).to be_frozen - end -end diff --git a/core/karya/spec/karya/backend/in_memory_spec.rb b/core/karya/spec/karya/backend/in_memory_spec.rb index f5c204c6..2e184a58 100644 --- a/core/karya/spec/karya/backend/in_memory_spec.rb +++ b/core/karya/spec/karya/backend/in_memory_spec.rb @@ -1,13 +1,26 @@ # frozen_string_literal: true +require 'open3' +require 'rbconfig' + RSpec.describe Karya::Backend::InMemory do subject(:backend) { described_class.new } - it 'exposes the in-memory backend descriptor' do - descriptor = backend.descriptor + it 'loads as a standalone backend file' do + lib_path = File.expand_path('../../lib', __dir__) + script = <<~RUBY + require 'karya/backend/in_memory' + puts Karya::Backend::InMemory.new.identifier + RUBY + + stdout, stderr, status = Open3.capture3(RbConfig.ruby, '-I', lib_path, '-e', script) + + expect(status.success?).to be(true), stderr + expect(stdout).to eq("in_memory\n") + end - expect(descriptor.identifier).to eq('in_memory') - expect(descriptor).to be_frozen + it 'exposes the normalized backend identifier' do + expect(backend.identifier).to eq('in_memory') end it 'builds the queue store provider owned by the backend definition' do @@ -61,7 +74,7 @@ expect do backend.build_queue_store - end.to raise_error(Karya::InvalidBackendSelectionError, /queue_store_class must build a Karya::QueueStore::Base/) + end.to raise_error(Karya::InvalidBackendConfigurationError, /queue_store_class must build a Karya::QueueStore::Base/) end it 'declares no-op lifecycle hooks around queue-store usage' do diff --git a/core/karya/spec/karya/backend/selection_spec.rb b/core/karya/spec/karya/backend/selection_spec.rb deleted file mode 100644 index d5495352..00000000 --- a/core/karya/spec/karya/backend/selection_spec.rb +++ /dev/null @@ -1,54 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Karya::Backend::Selection do - it 'normalizes supported backend identifiers' do - expect(described_class.normalize_identifier(:in_memory)).to eq('in_memory') - expect(described_class.normalize_identifier('inmemory')).to eq('in_memory') - expect(described_class.normalize_identifier('InMemory')).to eq('in_memory') - expect(described_class.normalize_identifier(:sqlite)).to eq('sqlite') - expect(described_class.normalize_identifier(:redis)).to eq('redis') - expect(described_class.normalize_identifier(:postgres)).to eq('postgres') - expect(described_class.normalize_identifier(:postgresql)).to eq('postgres') - expect(described_class.normalize_identifier(:mysql)).to eq('mysql') - expect(described_class.normalize_identifier(:my_sql)).to eq('mysql') - end - - it 'rejects blank backend input' do - expect do - described_class.normalize_identifier(' ') - end.to raise_error(Karya::InvalidBackendSelectionError, /backend must be present/) - end - - it 'rejects non string-or-symbol backend input before normalization' do - [42, true, Time.now].each do |value| - expect do - described_class.normalize_identifier(value) - end.to raise_error(Karya::InvalidBackendSelectionError, /backend must be a String or Symbol/) - end - end - - it 'rejects unsupported backends' do - expect do - described_class.normalize_identifier('mongodb') - end.to raise_error(Karya::UnsupportedBackendError, /unsupported backend "mongodb"/) - end - - it 'rejects undocumented delimiter variants' do - expect do - described_class.normalize_identifier('in-memory') - end.to raise_error(Karya::UnsupportedBackendError, /unsupported backend "in-memory"/) - end - - it 'creates a selection for a known backend identifier' do - selection = described_class.new('InMemory') - - expect(selection.identifier).to eq('in_memory') - end - - it 'reports whether an identifier is known' do - expect(described_class.known_identifier?('inmemory')).to be(true) - expect(described_class.known_identifier?('postgres')).to be(true) - expect(described_class.known_identifier?('mongodb')).to be(false) - expect(described_class.known_identifier?(42)).to be(false) - end -end diff --git a/docs/index.md b/docs/index.md index 1a4b6ebe..f3958196 100644 --- a/docs/index.md +++ b/docs/index.md @@ -21,7 +21,7 @@ workflows, framework hosts, operator tooling, and governed production rollout. ## What The Platform Includes - a canonical runtime and CLI -- backend adapters with a shared backend selection contract +- backend adapters with a shared backend contract - first-class integrations for plain Ruby, Rails, Sinatra, Roda, and Hanami - ActiveJob compatibility and migration guidance - an optional dashboard addon with shared UI delivery, internal APIs, and Kaal @@ -44,9 +44,7 @@ workflows, framework hosts, operator tooling, and governed production rollout. | Area | Position | | ----------------------------- | ------------------------------------------------------------------------- | -| Default production backend | Postgres | -| Additional supported backends | Redis, MySQL, SQLite | -| Local/dev backend | `InMemory` | +| Backend model | shared backend interface with concrete backend classes | | First-class hosts | plain Ruby, Rails, Sinatra, Roda, Hanami | | Compatibility path | ActiveJob | | Scheduler | Kaal-backed recurring job and cron subsystem | @@ -69,8 +67,8 @@ workflows, framework hosts, operator tooling, and governed production rollout. governed recovery, and backpressure - [Workflows](/workflows/): orchestration, replay, signals, child workflows, and versioning -- [Backends](/backends/): selection guidance, backend identifiers, and - adapter fit +- [Backends](/backends/): backend fit, production boundaries, and adapter + pairing - [Frameworks](/frameworks/): host integrations, ActiveJob, and parity notes - [Dashboard Hosting](/dashboard-hosting/): packaged asset contract and host diff --git a/docs/pages/adoption/goodjob.md b/docs/pages/adoption/goodjob.md index 5b0e6ad2..f063949e 100644 --- a/docs/pages/adoption/goodjob.md +++ b/docs/pages/adoption/goodjob.md @@ -7,7 +7,7 @@ permalink: /adoption/goodjob/ # GoodJob -GoodJob aligns naturally with Karya’s Postgres-first backend posture. +GoodJob aligns naturally with Karya when the target runtime stays SQL-backed. ## Guidance @@ -34,7 +34,8 @@ thinking into Karya’s workflow and operator surfaces. ## Related Concepts -- [Backends](/backends/): Postgres remains the default production path here +- [Backends](/backends/): choose the production backend that matches the + target SQL runtime - [Workflow Basics](/workflows/basics/): this is usually the biggest product expansion - [Cutover And Rollback](/adoption/cutover-rollback/): backend continuity does not diff --git a/docs/pages/architecture.md b/docs/pages/architecture.md index 6352f51a..6138d023 100644 --- a/docs/pages/architecture.md +++ b/docs/pages/architecture.md @@ -34,7 +34,7 @@ Karya’s product model is built from these layers: dead-letter isolation, and recovery automation. 3. Orchestration: workflows, batch execution, signals, child workflows, approval checkpoints, replay, and version evolution. -4. Persistence: backend adapters, shared backend selection, and durable runtime +4. Persistence: backend adapters, shared backend contracts, and durable runtime state. 5. Framework integration: host-native mounting, auth/session alignment, adapter pairing, and operator API exposure. diff --git a/docs/pages/backends.md b/docs/pages/backends.md index ab27be44..59a15306 100644 --- a/docs/pages/backends.md +++ b/docs/pages/backends.md @@ -6,78 +6,65 @@ permalink: /backends/ # Backends -Karya documents backend selection through a shared backend identifier contract -instead of implying selection semantics from package names alone. - Backend choice shapes durability, operator workflows, scheduling behavior, failure recovery, and the overall fit between Karya and the rest of the stack. This page helps teams make that choice with intent. -## Recommended Default +Karya supports both local development backends and production backends, but +they are not interchangeable. The right choice depends on whether the goal is +fast local iteration, production-like local evaluation, or a durable +production deployment. -Postgres is the default production recommendation for teams that do not already -have a stronger operational preference. +## Backend Positions -For most teams, Postgres is the easiest recommendation to defend: it fits the -broader Karya product model, works well with framework integrations, and gives -operators one durable system of record for execution and orchestration state. +| Backend | Position | Typical Fit | +| ---------- | ----------------------------- | ------------------------------------------------- | +| `InMemory` | Local quick-start backend | Fast examples, tests, and local development | +| SQLite | Local production-like backend | Local SQL-backed evaluation | +| Redis | Production backend | Production use | +| Postgres | Production backend | Production use | +| MySQL | Production backend | Production use | -## Backend Identifiers +## Support Boundaries -| Backend | Position | Typical Fit | -| ---------- | ----------------------------- | ------------------------------------------------------ | -| Postgres | Default production backend | General-purpose production deployments | -| Redis | Supported production backend | Queue-centric, low-latency operational workloads | -| MySQL | Supported production backend | SQL environments standardized on MySQL | -| SQLite | Supported constrained backend | Embedded, single-node, or lightweight SQL deployments | -| `InMemory` | Local/dev/test backend | Examples, development, tests, and ephemeral evaluation | +- Use `InMemory` for local testing, examples, and development speed. +- Use SQLite when you want a local backend that feels closer to a real SQL + deployment. +- Use Redis, Postgres, or MySQL for production deployments. +- Do not use `InMemory` in production. +- Do not use SQLite in production. ## How To Choose -Choose Postgres when: - -- you want the default production path -- you expect workflows, schedules, audit history, and operator workflows to - matter from the beginning -- you want the broadest fit across hosts and operator workflows - -Choose Redis when: - -- you are optimizing for queue-centric throughput and low-latency operational - behavior -- your team already runs Redis as a core infrastructure dependency -- you are comfortable reviewing backend-specific caveats as the product surface - expands - -Choose MySQL when: +Choose `Karya::Backend::InMemory` when: -- production standards already center on MySQL -- you want a supported SQL-backed path without introducing Postgres +- you want the fastest possible setup +- you are testing locally +- you are building examples +- you do not need durability across restarts or processes Choose SQLite when: -- the deployment is intentionally small, embedded, or single-node -- the operational tradeoffs are acceptable and clearly understood +- you want a local SQL-backed runtime +- you want a production-like local development path +- you are validating SQL-oriented behavior before moving to a production + backend +- the deployment is intentionally local or single-node -Choose `InMemory` when: +Choose Redis when: -- you are developing locally -- you need quick examples or tests -- durability and multi-process production behavior are not part of the goal +- you want a production backend +- Redis is the backend you intend to operate -## Selection Contract +Choose Postgres when: -The shared backend contract defines these normalized backend identifiers: +- you want a production backend +- Postgres is the backend you intend to operate -- `postgres` -- `redis` -- `mysql` -- `sqlite` -- `in_memory` +Choose MySQL when: -Selection input is normalized onto those identifiers. For example, `InMemory` -and `inmemory` resolve to `in_memory`, and `postgresql` resolves to -`postgres`. +- you want a production backend +- MySQL is the backend you intend to operate ## What Backends Influence @@ -88,12 +75,6 @@ Backend choice affects more than persistence: - what adapter path and operational posture fit the deployment best - what troubleshooting guidance applies in production -## Selection Notes - -Backend identifiers are part of the shared product contract. Adapter wiring, -runtime boot behavior, and backend-local implementation details are separate -concerns, but they all build on the same backend names. - ## Common Scenarios ### General-Purpose Production Platform @@ -102,11 +83,11 @@ concerns, but they all build on the same backend names. host: rails backend: postgres goal: durable jobs, workflows, schedules, and operator visibility -recommendation: default production path +recommendation: production deployment ``` -This is the baseline recommendation for teams adopting Karya as a long-term -platform rather than a narrow queue runner. +This fits teams that want durable orchestration and a long-lived operator +surface. ### Existing Queue-Centric Runtime @@ -114,7 +95,7 @@ platform rather than a narrow queue runner. host: plain-ruby backend: redis goal: high-throughput queue execution with strong operational monitoring -recommendation: supported, with explicit review of parity caveats +recommendation: production deployment ``` This fits teams that already operate Redis heavily and want Karya to align with @@ -124,19 +105,31 @@ that environment. ```text host: plain-ruby -backend: InMemory +backend: Karya::Backend::InMemory goal: fast setup, tests, examples recommendation: local/dev/test only ``` This is for speed and simplicity, not as a production durability story. +### Production-Like Local SQL Evaluation + +```text +host: rails +backend: sqlite +goal: local SQL-backed evaluation before production rollout +recommendation: local only, production-like development path +``` + +This fits teams that want local SQL behavior without treating the local backend +as the production deployment target. + ## Adapter Pairings -- Active Record path: typically Rails with `core/karya-activerecord` -- Sequel path: Hanami, Roda, and Sinatra with `core/karya-sequel` -- plain Ruby: choose the adapter path that matches the selected backend and - persistence style +- Active Record path: choose a backend class that fits the SQL/runtime model +- Sequel path: choose a backend class that fits the host and persistence model +- plain Ruby: compose the backend class and persistence adapter that fit the + selected deployment model ## Related Concepts diff --git a/docs/pages/getting-started.md b/docs/pages/getting-started.md index 4fa9c745..7c5edb23 100644 --- a/docs/pages/getting-started.md +++ b/docs/pages/getting-started.md @@ -28,8 +28,7 @@ run the shared verification flow. 1. Read the [Architecture](/architecture/) page for the package map and platform boundaries. -2. Choose a backend identifier using [Backends](/backends/). Postgres is the - default production recommendation. +2. Choose a backend implementation using [Backends](/backends/). 3. Choose a host integration from [Frameworks](/frameworks/). 4. Review [Dashboard Hosting](/dashboard-hosting/) if you want a framework host to include the optional dashboard addon. @@ -54,7 +53,8 @@ bin/prepackage-build ## Recommended Starting Defaults - host: use the framework package that matches your application stack -- backend: start with Postgres unless a documented constraint points elsewhere +- backend: choose the backend implementation that matches the intended runtime + and durability model - scheduler: use the built-in Kaal-backed recurring-job subsystem - operator surface: treat the dashboard, API, and CLI as complementary rather than choosing only one diff --git a/docs/pages/troubleshooting.md b/docs/pages/troubleshooting.md index 122d5813..8f688ed9 100644 --- a/docs/pages/troubleshooting.md +++ b/docs/pages/troubleshooting.md @@ -83,7 +83,7 @@ When work is stuck, backlogged, or repeatedly failing, review: - dead-letter isolation snapshots, recovery action availability, and isolation reasons before retrying or replaying work - workflow replay, checkpoint, or approval state -- backend-specific caveats documented in the support matrix +- backend configuration, durability expectations, and host-adapter fit ### Common Scenario @@ -262,7 +262,7 @@ next move: confirm whether the block is intentional before treating it as a bug - [Dashboard Hosting](/dashboard-hosting/): asset delivery, mount path, and host rendering problems -- [Backends](/backends/): backend fit, parity caveats, and persistence tradeoffs +- [Backends](/backends/): backend fit, production boundaries, and persistence tradeoffs - [Runtime](/runtime/): job, worker, and control-surface behavior - [Reliability](/reliability/): retries, backpressure, uniqueness, and dead-letter flows From d2827df5cf7719213fe5b9fbc556964ea8670a84 Mon Sep 17 00:00:00 2001 From: Nitesh Purohit Date: Sat, 2 May 2026 16:47:16 -0400 Subject: [PATCH 13/13] refactor: clean up backend interface methods --- core/karya/lib/karya/backend/in_memory.rb | 10 ---------- core/karya/lib/karya/worker_supervisor.rb | 2 +- core/karya/lib/karya/worker_supervisor/runtime.rb | 5 ++++- core/karya/sig/karya/backend/in_memory.rbs | 6 ++---- core/karya/spec/karya/backend/in_memory_spec.rb | 2 +- 5 files changed, 8 insertions(+), 17 deletions(-) diff --git a/core/karya/lib/karya/backend/in_memory.rb b/core/karya/lib/karya/backend/in_memory.rb index 3ec1ad26..7c633c4f 100644 --- a/core/karya/lib/karya/backend/in_memory.rb +++ b/core/karya/lib/karya/backend/in_memory.rb @@ -48,16 +48,6 @@ def build_queue_store( raise InvalidBackendConfigurationError, 'queue_store_class must build a Karya::QueueStore::Base' end - def before_start(queue_store:) - _queue_store = queue_store - nil - end - - def after_stop(queue_store:) - _queue_store = queue_store - nil - end - private attr_reader :queue_store_class diff --git a/core/karya/lib/karya/worker_supervisor.rb b/core/karya/lib/karya/worker_supervisor.rb index 7a6740a4..be4e2ec1 100644 --- a/core/karya/lib/karya/worker_supervisor.rb +++ b/core/karya/lib/karya/worker_supervisor.rb @@ -249,8 +249,8 @@ def with_signal_handlers(shutdown_controller) end def collect_signal_restorers(restorers, shutdown_controller) - register_signal_restorers(restorers, shutdown_controller) restorers << WakeupSignal.register_restorer(WAKEUP_SIGNAL) + register_signal_restorers(restorers, shutdown_controller) end def register_signal_restorers(restorers, shutdown_controller) diff --git a/core/karya/lib/karya/worker_supervisor/runtime.rb b/core/karya/lib/karya/worker_supervisor/runtime.rb index 899750f1..bcb59454 100644 --- a/core/karya/lib/karya/worker_supervisor/runtime.rb +++ b/core/karya/lib/karya/worker_supervisor/runtime.rb @@ -32,7 +32,10 @@ def self.default_killer def self.default_signal_subscriber lambda do |signal, handler| previous_handler = Signal.trap(signal) { handler.call } - -> { Signal.trap(signal, previous_handler) } + lambda do + Signal.trap(signal, previous_handler) + nil + end end end diff --git a/core/karya/sig/karya/backend/in_memory.rbs b/core/karya/sig/karya/backend/in_memory.rbs index 6435bcf5..31bd66c8 100644 --- a/core/karya/sig/karya/backend/in_memory.rbs +++ b/core/karya/sig/karya/backend/in_memory.rbs @@ -1,6 +1,6 @@ module Karya module Backend - interface _QueueStoreFactory + interface _QueueStoreFactoryInput def new: ( ?token_generator: Karya::callable_value?, ?expired_tombstone_limit: Integer?, @@ -15,7 +15,7 @@ module Karya class InMemory include Base - def initialize: (?queue_store_class: _QueueStoreFactory) -> void + def initialize: (?queue_store_class: _QueueStoreFactoryInput) -> void def identifier: () -> "in_memory" def build_queue_store: ( ?token_generator: Karya::callable_value?, @@ -26,8 +26,6 @@ module Karya ?circuit_breaker_policy_set: CircuitBreaker::PolicySet?, ?fairness_policy: Fairness::Policy? ) -> QueueStore::Base - def before_start: (queue_store: QueueStore::Base) -> nil - def after_stop: (queue_store: QueueStore::Base) -> nil end end end diff --git a/core/karya/spec/karya/backend/in_memory_spec.rb b/core/karya/spec/karya/backend/in_memory_spec.rb index 2e184a58..d20292ec 100644 --- a/core/karya/spec/karya/backend/in_memory_spec.rb +++ b/core/karya/spec/karya/backend/in_memory_spec.rb @@ -7,7 +7,7 @@ subject(:backend) { described_class.new } it 'loads as a standalone backend file' do - lib_path = File.expand_path('../../lib', __dir__) + lib_path = File.expand_path('../../../lib', __dir__) script = <<~RUBY require 'karya/backend/in_memory' puts Karya::Backend::InMemory.new.identifier