opensearch-sugar 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.agents/skills/diataxis/SKILL.md +142 -0
  3. data/.agents/skills/diataxis/references/examples.md +420 -0
  4. data/.agents/skills/diataxis/references/explanation-template.md +96 -0
  5. data/.agents/skills/diataxis/references/framework.md +400 -0
  6. data/.agents/skills/diataxis/references/how-to-guide-template.md +105 -0
  7. data/.agents/skills/diataxis/references/reference-template.md +110 -0
  8. data/.agents/skills/diataxis/references/tutorial-template.md +101 -0
  9. data/.agents/skills/diataxis/scripts/generate_index.py +139 -0
  10. data/.rspec +3 -0
  11. data/.standard.yml +3 -0
  12. data/AGENTS.md +120 -0
  13. data/CHANGELOG.md +5 -0
  14. data/Dockerfile.opensearch +4 -0
  15. data/Increase_Coverage.md +311 -0
  16. data/README.md +143 -0
  17. data/Rakefile +27 -0
  18. data/Steepfile +23 -0
  19. data/adrs/ADR-000-template.md +87 -0
  20. data/adrs/ADR-001-simpledelegator-for-client.md +138 -0
  21. data/adrs/ADR-002-facade-pattern-for-index.md +126 -0
  22. data/adrs/ADR-003-repository-pattern-for-models.md +148 -0
  23. data/adrs/ADR-004-integration-tests-no-mocking.md +91 -0
  24. data/adrs/ADR-005-exceptions-over-result-objects.md +107 -0
  25. data/adrs/ADR-006-ssl-on-by-default.md +95 -0
  26. data/adrs/ADR-007-selective-sugar-surface.md +118 -0
  27. data/adrs/ADR-008-integration-test-design.md +178 -0
  28. data/compose.yml +2 -0
  29. data/compose_opensearch.yml +31 -0
  30. data/docs/HOWTO.md +844 -0
  31. data/docs/REFERENCE.md +725 -0
  32. data/docs/TUTORIAL.md +327 -0
  33. data/docs/alias-api-design-notes.md +119 -0
  34. data/lib/opensearch/sugar/client.rb +300 -0
  35. data/lib/opensearch/sugar/index/include/utilities.rb +6 -0
  36. data/lib/opensearch/sugar/index.rb +339 -0
  37. data/lib/opensearch/sugar/models.rb +209 -0
  38. data/lib/opensearch/sugar/version.rb +8 -0
  39. data/lib/opensearch/sugar.rb +61 -0
  40. data/old_docs/DELEGATED_METHODS_ANALYSIS.md +361 -0
  41. data/old_docs/EXPLANATION.md +685 -0
  42. data/old_docs/README.md +155 -0
  43. data/old_docs/docs/CLI-PROPOSAL.md +257 -0
  44. data/old_docs/docs/HOWTO.md +798 -0
  45. data/old_docs/docs/REFERENCE.md +901 -0
  46. data/old_docs/docs/TUTORIAL.md +493 -0
  47. data/sig/opensearch/sugar.rbs +162 -0
  48. metadata +240 -0
data/README.md ADDED
@@ -0,0 +1,143 @@
1
+ # opensearch-sugar
2
+
3
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.1.0-ruby.svg)](https://www.ruby-lang.org/)
4
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
+
6
+ A Ruby gem that wraps the [opensearch-ruby](https://github.com/opensearch-project/opensearch-ruby)
7
+ client with an object-oriented shell -- collections (indexes), documents, ML models, etc --
8
+ instead of having everything runn straight off the client. I find it an easier way
9
+ to think about and code interactions with OpenSearch.
10
+
11
+ The gem was a finished product before I had access to LLMs. All the _original_ code
12
+ is hand-crafted like a Seattle beer, but a variety of improvements and much
13
+ of the documentation (and rbs) is AI-generated.
14
+
15
+ Of course, AI may have negated the need for nice interfaces and
16
+ human-centered program design. So...YMMV in terms of usefulness.
17
+
18
+
19
+ ## Quick example
20
+
21
+ ```ruby
22
+ require "opensearch/sugar"
23
+
24
+ client = OpenSearch::Sugar::Client.new # Pass credentials, or default uses ENV variables
25
+
26
+ index = client.open_or_create_index("products")
27
+
28
+ index.update_mappings(
29
+ mappings: {
30
+ properties: {
31
+ title: { type: "text" },
32
+ category: { type: "keyword" },
33
+ price: { type: "float" }
34
+ }
35
+ }
36
+ )
37
+
38
+ index.index_document({ title: "Dune", category: "fiction", price: 12.99 }, "isbn-0441013597")
39
+ index.refresh # make the document immediately searchable
40
+
41
+ results = client.search(
42
+ index: "products",
43
+ body: { query: { match: { title: "dune" } } }
44
+ )
45
+ puts results["hits"]["hits"].first.dig("_source", "title")
46
+ #=> "Dune"
47
+ ```
48
+
49
+ The underlying Client object is attatched to every object created through the API,
50
+ and is a paper-thin shell on a client from the official ruby gem.
51
+
52
+ See the AI-written [Tutorial](docs/TUTORIAL.md) for a full walkthrough.
53
+
54
+ ## Installation
55
+
56
+ ```ruby
57
+ # Gemfile
58
+ gem "opensearch-sugar"
59
+ ```
60
+
61
+ ```bash
62
+ bundle install
63
+ ```
64
+
65
+ ## Configuration
66
+
67
+ Connection details are read from environment variables:
68
+
69
+ | Variable | Used for | Default |
70
+ |----------|----------|---------|
71
+ | `OPENSEARCH_URL` | Cluster URL | `https://localhost:9000` |
72
+ | `OPENSEARCH_HOST` | Cluster URL (lower priority) | — |
73
+ | `OPENSEARCH_USER` | Basic auth user | `"admin"` |
74
+ | `OPENSEARCH_PASSWORD` | Basic auth password | — |
75
+ | `OPENSEARCH_INITIAL_ADMIN_PASSWORD` | Basic auth password (lower priority) | — |
76
+
77
+ Any keyword argument accepted by `OpenSearch::Client.new` can be passed to
78
+ `OpenSearch::Sugar::Client.new` and will override the defaults.
79
+
80
+ ## Documentation
81
+
82
+ Provided by various AI models, although I've done some editing. Those things are faily good at this, even though
83
+ they don't so much have a unique voice (or, rather, they all have the same unique voice)
84
+
85
+ - **[Tutorial](docs/TUTORIAL.md)** — step-by-step walkthrough building a searchable book catalog from scratch
86
+ - **[How-to Guides](docs/HOWTO.md)** — practical recipes for connection options, document CRUD, search, aliases, ML models, error handling, and more
87
+ - **[API Reference](docs/REFERENCE.md)** — complete method reference for `Client`, `Index`, and `Models`
88
+
89
+ ## Tests and development
90
+
91
+ I'v never seen the point of writing unit tests for this sort of thing. I mean, a few, but anything
92
+ that interacts with a server and needs to verify that The Right Thing Happened benefits from
93
+ talking to the real thing. Plus I don't have to maintain the mocks. Something like
94
+ [VCR](https://github.com/vcr/vcr) is appealing; maybe soon.
95
+
96
+ ### To run the tests:
97
+
98
+
99
+ Start a local OpenSearch cluster:
100
+
101
+ ```bash
102
+ docker compose up -d
103
+ ```
104
+
105
+ OpenSearch takes ~30 seconds to be ready. Wait until the following command succeeds
106
+ before running the test suite:
107
+
108
+ ```bash
109
+ ❯ curl -sk https://localhost:29200 -u 'admin:Dw2F%3E*!m&psx64' | grep -q tagline && echo "Ready"
110
+ ```
111
+
112
+ Run the specs:
113
+
114
+ ```bash
115
+ bundle exec rspec
116
+ ```
117
+
118
+ To generate a coverage report:
119
+
120
+ ```bash
121
+ bundle exec rake coverage
122
+ ```
123
+
124
+ or:
125
+
126
+ ```bash
127
+ COVERAGE=true bundle exec rspec
128
+ ```
129
+
130
+ Coverage reports are written to `coverage/index.html`.
131
+
132
+ To see full HTTP request/response logs during a run:
133
+
134
+ ```bash
135
+ OPENSEARCH_LOG=true bundle exec rspec
136
+ ```
137
+
138
+ ### Contributing
139
+
140
+ Bug reports and pull requests are welcome at
141
+ <https://github.com/mlibrary/opensearch-sugar>. Please open an issue before
142
+ submitting large changes.
143
+
data/Rakefile ADDED
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+ require "yard"
6
+
7
+ RSpec::Core::RakeTask.new(:spec)
8
+
9
+ require "standard/rake"
10
+
11
+ YARD::Rake::YardocTask.new(:yard) do |t|
12
+ t.files = ["lib/**/*.rb"]
13
+ t.options = ["--output-dir", "doc/", "--markup", "markdown"]
14
+ end
15
+
16
+ desc "Run Steep type checker"
17
+ task :steep do
18
+ sh "bundle exec steep check"
19
+ end
20
+
21
+ desc "Run specs with coverage report"
22
+ task :coverage do
23
+ ENV["COVERAGE"] = "true"
24
+ Rake::Task["spec"].invoke
25
+ end
26
+
27
+ task default: %i[spec standard]
data/Steepfile ADDED
@@ -0,0 +1,23 @@
1
+ D = Steep::Diagnostic
2
+
3
+ target :lib do
4
+ signature "sig"
5
+
6
+ check "lib"
7
+
8
+ library "logger"
9
+ library "delegate"
10
+ library "json"
11
+
12
+ configure_code_diagnostics(D::Ruby.default) do |hash|
13
+ # OpenSearch::Sugar::Client extends SimpleDelegator; delegated methods (e.g. `indices`,
14
+ # `cluster`) are resolved dynamically at runtime and cannot be statically typed.
15
+ hash[D::Ruby::NoMethod] = :information
16
+
17
+ # opensearch-ruby and other third-party gems ship no RBS; suppress noise from
18
+ # unresolvable constants and untyped collection literals that arise at their boundaries.
19
+ hash[D::Ruby::UnknownConstant] = :information
20
+ hash[D::Ruby::UnannotatedEmptyCollection] = :information
21
+ hash[D::Ruby::ArgumentTypeMismatch] = :information
22
+ end
23
+ end
@@ -0,0 +1,87 @@
1
+ # ADR-NNN: Title
2
+
3
+ ## Status
4
+
5
+ <!-- One of: Proposed | Accepted | Deprecated | Superseded by [ADR-NNN](ADR-NNN-title.md) -->
6
+
7
+ ## Date
8
+
9
+ YYYY-MM-DD
10
+
11
+ ## Context
12
+
13
+ <!--
14
+ Describe the problem, the forces at play, and the background needed to understand
15
+ the decision. Include:
16
+ - What situation requires a decision
17
+ - Prior art or existing approaches (e.g., how a reference implementation handles this)
18
+ - Constraints imposed by earlier decisions
19
+ - Options that were on the table (summarized here; detailed in Alternatives Considered)
20
+
21
+ This section answers: "Why are we making a decision at all?"
22
+ -->
23
+
24
+ ## Decision
25
+
26
+ <!--
27
+ State the decision clearly and unambiguously. Use present tense ("We use...", not
28
+ "We will use..." or "We used...").
29
+
30
+ Include code samples (real Kotlin or clearly-marked pseudocode) demonstrating the
31
+ chosen approach at the API level. Samples should show both the definition and the
32
+ call site where relevant.
33
+ -->
34
+
35
+ ## Consequences
36
+
37
+ ### Positive
38
+
39
+ <!-- Benefits, improvements, and capabilities unlocked by this decision. -->
40
+
41
+ ### Negative
42
+
43
+ <!-- Costs, limitations, and risks introduced. Be honest. -->
44
+
45
+ ### Neutral
46
+
47
+ <!-- Things that are neither good nor bad but worth recording — behavioral changes,
48
+ constraints on future decisions, things that may surprise readers, etc. -->
49
+
50
+ ## Alternatives Considered
51
+
52
+ <!--
53
+ For each rejected alternative:
54
+ - Name or brief description
55
+ - Why it was rejected
56
+
57
+ If the Context section already covers alternatives in sufficient detail, this
58
+ section may be omitted.
59
+ -->
60
+
61
+ ## Diagram
62
+
63
+ <!--
64
+ Optional. Include a Mermaid diagram when the decision has a structural, flow, or
65
+ sequence component worth visualizing. Use flowchart, sequenceDiagram, or classDiagram
66
+ as appropriate.
67
+
68
+ ```mermaid
69
+ flowchart LR
70
+ A --> B
71
+ ```
72
+ -->
73
+
74
+ ## Open Questions
75
+
76
+ <!--
77
+ Optional. Deferred sub-decisions that follow directly from this decision but are
78
+ not yet settled. Each item should reference the constraint or choice that makes
79
+ it dependent on future work.
80
+ -->
81
+
82
+ ## Documentation Requirements
83
+
84
+ <!--
85
+ Optional. Explicit callouts for what must be documented in the public API,
86
+ README, or contributor guide as a direct result of this decision.
87
+ -->
@@ -0,0 +1,138 @@
1
+ # ADR-001: Use SimpleDelegator to Wrap OpenSearch::Client
2
+
3
+ ## Status
4
+
5
+ Accepted
6
+
7
+ ## Date
8
+
9
+ 2026-04-28
10
+
11
+ ## Context
12
+
13
+ `OpenSearch::Sugar::Client` needs to provide convenient, higher-level methods (e.g., `has_index?`,
14
+ `open_or_create_index`, index management helpers) while also giving callers access to the full
15
+ `OpenSearch::Client` API. The OpenSearch Ruby client exposes a large, versioned surface area —
16
+ cluster operations, document APIs, index APIs, ML Commons, and more — that would be impractical
17
+ to wrap exhaustively and expensive to keep in sync as the upstream client evolves.
18
+
19
+ Options considered:
20
+
21
+ - **Inheritance** — subclass `OpenSearch::Client` directly
22
+ - **Manual delegation** — write `def_delegator` calls for every method needed
23
+ - **`SimpleDelegator` (Forwardable)** — wrap the client via Ruby's built-in delegation mechanism
24
+ - **Composition with `method_missing`** — forward unknown calls via `method_missing`/`respond_to_missing?`
25
+
26
+ ## Decision
27
+
28
+ We use `SimpleDelegator` from Ruby's standard library to wrap an `OpenSearch::Client` instance.
29
+ `Client` inherits from `SimpleDelegator`, calls `__setobj__` with the raw client at initialization,
30
+ and adds Sugar methods on top.
31
+
32
+ ```ruby
33
+ class OpenSearch::Sugar::Client < SimpleDelegator
34
+ def initialize(**kwargs)
35
+ @raw_client = OpenSearch::Client.new(**kwargs)
36
+ super(@raw_client)
37
+ end
38
+
39
+ # Sugar methods
40
+ def has_index?(name)
41
+ indices.exists?(index: name)
42
+ end
43
+
44
+ def open_or_create_index(name, ...)
45
+ # ...
46
+ end
47
+ end
48
+
49
+ # Call site — Sugar method
50
+ client.has_index?("my_index")
51
+
52
+ # Call site — delegated to raw OpenSearch::Client transparently
53
+ client.search(index: "my_index", body: { query: { match_all: {} } })
54
+ client.cluster.health
55
+ client.bulk(body: operations)
56
+ ```
57
+
58
+ The raw client remains accessible via `__getobj__` (or an explicit `raw_client` accessor) when
59
+ callers need it explicitly.
60
+
61
+ ## Consequences
62
+
63
+ ### Positive
64
+
65
+ - **Zero maintenance burden for the full API**: every current and future `OpenSearch::Client`
66
+ method is automatically available without any wrapper code.
67
+ - **No abstraction trap**: callers are never forced to find a Sugar equivalent; they can drop
68
+ through to the raw client at any time.
69
+ - **Official documentation applies directly**: users can read opensearch-ruby docs and apply
70
+ them without translation.
71
+ - **Transparent migration**: code already written against `OpenSearch::Client` works without
72
+ modification when the object is a `Sugar::Client`.
73
+
74
+ ### Negative
75
+
76
+ - **`is_a?` / `instance_of?` surprises**: `SimpleDelegator` instances do not report as instances
77
+ of the wrapped class. Callers relying on `client.is_a?(OpenSearch::Client)` will get `false`.
78
+ - **Method resolution ambiguity**: if Sugar adds a method with the same name as a raw-client
79
+ method, the Sugar method wins silently. This requires discipline when naming Sugar helpers.
80
+ - **`SimpleDelegator` is less common than plain composition**: contributors unfamiliar with
81
+ Ruby's delegator classes may find the inheritance unusual.
82
+
83
+ ### Neutral
84
+
85
+ - `__getobj__` / `__setobj__` are available for testing and introspection but are not part of
86
+ the public Sugar API.
87
+ - Adding Sugar methods that conflict with upstream client methods is a breaking change; names
88
+ must be chosen carefully.
89
+
90
+ ## Alternatives Considered
91
+
92
+ **Inheritance from `OpenSearch::Client`**
93
+ Rejected because `OpenSearch::Client` is not designed for subclassing; its initializer and
94
+ internal wiring make safe subclassing fragile and likely to break across upstream releases.
95
+
96
+ **Manual delegation with `Forwardable`**
97
+ Rejected because it requires enumerating every method to forward. Any new method added to the
98
+ upstream client would be invisible until manually added here — exactly the maintenance trap we
99
+ want to avoid.
100
+
101
+ **`method_missing` / `respond_to_missing?`**
102
+ Rejected because `SimpleDelegator` already implements this pattern correctly and is battle-tested
103
+ in Ruby's standard library. Rolling our own would duplicate that work with higher risk.
104
+
105
+ ## Diagram
106
+
107
+ ```mermaid
108
+ classDiagram
109
+ class SimpleDelegator {
110
+ +__getobj__()
111
+ +__setobj__(obj)
112
+ }
113
+ class `OpenSearch::Sugar::Client` {
114
+ -@raw_client : OpenSearch::Client
115
+ +has_index?(name) bool
116
+ +open_or_create_index(name) Index
117
+ +index_names() Array
118
+ +models() Models
119
+ }
120
+ class `OpenSearch::Client` {
121
+ +indices IndicesNamespace
122
+ +cluster ClusterNamespace
123
+ +search(...) Hash
124
+ +bulk(...) Hash
125
+ +index(...) Hash
126
+ }
127
+
128
+ SimpleDelegator <|-- `OpenSearch::Sugar::Client`
129
+ `OpenSearch::Sugar::Client` --> `OpenSearch::Client` : wraps via __setobj__
130
+ ```
131
+
132
+ ## Documentation Requirements
133
+
134
+ - The public README and HOWTO must explain that `Sugar::Client` exposes the full
135
+ `OpenSearch::Client` API via delegation, and show side-by-side examples of Sugar methods
136
+ vs. delegated calls.
137
+ - The EXPLANATION doc should describe the `SimpleDelegator` pattern and its implications for
138
+ `is_a?` checks.
@@ -0,0 +1,126 @@
1
+ # ADR-002: Facade Pattern for OpenSearch::Sugar::Index
2
+
3
+ ## Status
4
+
5
+ Accepted
6
+
7
+ ## Date
8
+
9
+ 2026-04-28
10
+
11
+ ## Context
12
+
13
+ Several OpenSearch index operations require multiple coordinated API calls to complete safely.
14
+ The clearest example is updating certain index settings (e.g., analyzer configuration): OpenSearch
15
+ requires the index to be closed before applying the settings and then reopened afterward. If any
16
+ step fails partway through, the index can be left in an inconsistent state.
17
+
18
+ Other examples of multi-step sequences include:
19
+
20
+ - Applying settings or mappings that require a close/reopen cycle
21
+ - Introspecting settings or mappings (requires knowing which sub-keys to traverse)
22
+ - Managing aliases (creating, listing)
23
+ - Text analysis testing (`analyze` API)
24
+
25
+ Without a wrapper, callers must remember and correctly sequence every step every time. This is
26
+ error-prone and produces repeated, scattered boilerplate across application code.
27
+
28
+ Options considered:
29
+
30
+ - **Expose raw client methods only** — callers assemble sequences themselves
31
+ - **Module of helper functions** — stateless utility methods that accept an index name
32
+ - **`Index` class as a Facade** — an object that encapsulates the index name, holds a client
33
+ reference, and exposes single-method operations for each multi-step sequence
34
+
35
+ ## Decision
36
+
37
+ `OpenSearch::Sugar::Index` acts as a Facade over the raw index APIs. It holds the index name
38
+ and a reference to the `Sugar::Client`, and exposes single-method operations that internally
39
+ perform all necessary steps with consistent error handling.
40
+
41
+ ```ruby
42
+ # Behind the scenes: close → put_settings → open
43
+ index.update_settings(
44
+ settings: {
45
+ analysis: { analyzer: { my_analyzer: { type: "standard" } } }
46
+ }
47
+ )
48
+
49
+ # Behind the scenes: get_settings, traverse nested keys, return clean hash
50
+ index.settings
51
+
52
+ # Behind the scenes: close → open (if index exists); create (if not)
53
+ index = client.open_or_create_index("my_index")
54
+
55
+ # Single call for text analysis
56
+ tokens = index.test_analyzer_by_name(analyzer: "my_analyzer", text: "Hello World")
57
+ ```
58
+
59
+ Each method handles errors uniformly and raises `OpenSearch::Sugar::Error` (or a subclass) on
60
+ failure, rather than leaking raw transport exceptions from intermediate steps.
61
+
62
+ ## Consequences
63
+
64
+ ### Positive
65
+
66
+ - **Error-prone sequences become single calls**: callers cannot accidentally omit a step or
67
+ leave an index closed after a settings update.
68
+ - **Consistent error handling**: all multi-step operations funnel through the same error
69
+ boundary; callers handle one exception type.
70
+ - **Encapsulation**: the index name is carried by the object, eliminating repetitive
71
+ `index: name` keyword arguments scattered throughout call sites.
72
+ - **Easier to test**: sequence logic is isolated in one class, making it straightforward to
73
+ verify the correct sequence is called.
74
+
75
+ ### Negative
76
+
77
+ - **Increased abstraction**: callers working with the raw client directly may find it confusing
78
+ that `Index` and `Client` both offer some overlapping capabilities (e.g., `update_settings`
79
+ exists on both, with `Index#update_settings` being the preferred single-object entry point).
80
+ - **Performance transparency**: the automatic close/reopen cycle for settings updates is hidden
81
+ from callers. A caller unaware of this could be surprised by the two extra API calls and the
82
+ brief unavailability of the index.
83
+
84
+ ### Neutral
85
+
86
+ - `Index` objects are lightweight value-like wrappers; they do not cache state from OpenSearch
87
+ and always fetch fresh data on each call.
88
+ - The Facade does not prevent callers from accessing the raw client via `index.client` (or
89
+ through the delegated `Sugar::Client`) when they need finer control.
90
+
91
+ ## Alternatives Considered
92
+
93
+ **Raw client methods only**
94
+ Rejected because it pushes multi-step orchestration into every call site. The close/reopen
95
+ sequence for settings updates is particularly easy to get wrong (e.g., forgetting to reopen
96
+ after a failure).
97
+
98
+ **Module of stateless helper functions**
99
+ Rejected because it requires passing the index name (and client) on every call, which is
100
+ repetitive. It also makes it harder to build a coherent, discoverable API — callers would
101
+ need to know which helpers to compose for each task.
102
+
103
+ ## Diagram
104
+
105
+ ```mermaid
106
+ sequenceDiagram
107
+ participant Caller
108
+ participant Index as Sugar::Index
109
+ participant OS as OpenSearch::Client
110
+
111
+ Caller->>Index: update_settings(settings)
112
+ Index->>OS: indices.close(index: name)
113
+ OS-->>Index: ok
114
+ Index->>OS: indices.put_settings(index: name, body: settings)
115
+ OS-->>Index: ok
116
+ Index->>OS: indices.open(index: name)
117
+ OS-->>Index: ok
118
+ Index-->>Caller: (returns)
119
+ ```
120
+
121
+ ## Documentation Requirements
122
+
123
+ - HOWTO must note that `update_settings` for analyzer-related settings automatically performs
124
+ the close/reopen cycle and that the index is briefly unavailable during the operation.
125
+ - EXPLANATION should describe the Facade pattern and explain why certain operations require
126
+ closing the index.
@@ -0,0 +1,148 @@
1
+ # ADR-003: Repository Pattern for OpenSearch::Sugar::Models
2
+
3
+ ## Status
4
+
5
+ Accepted
6
+
7
+ ## Date
8
+
9
+ 2026-04-28
10
+
11
+ ## Context
12
+
13
+ OpenSearch ML Commons stores models with internal IDs that are opaque UUIDs assigned at
14
+ registration time. Callers typically know a model by its human-readable name or a short
15
+ nickname, not its UUID. Retrieving, deploying, and deleting a model through the raw client
16
+ requires:
17
+
18
+ 1. Listing all models (paginated)
19
+ 2. Scanning results to match by name or ID
20
+ 3. Using the resolved UUID in subsequent calls
21
+
22
+ This lookup-then-act sequence must be repeated at every call site that needs to work with a
23
+ model by name. Additionally, the ML model lifecycle has strict ordering requirements:
24
+
25
+ - A model must be registered and fully deployed before it can be used
26
+ - A model must be undeployed before it can be deleted
27
+
28
+ Without a dedicated abstraction, callers are responsible for both the lookup logic and the
29
+ lifecycle sequencing — producing fragile, repetitive code.
30
+
31
+ Options considered:
32
+
33
+ - **Raw client calls at each call site** — callers handle lookup and lifecycle themselves
34
+ - **Module of helper functions** — stateless helpers that accept a client and model identifier
35
+ - **`Models` class as a Repository** — an object bound to a client that treats ML models as
36
+ first-class resources and unifies lookup, lifecycle management, and pipeline construction
37
+
38
+ ## Decision
39
+
40
+ `OpenSearch::Sugar::Models` implements the Repository pattern. It is accessed via
41
+ `client.models` and provides a unified interface for all ML model operations. Lookup accepts
42
+ a name, UUID, or partial name match via `[]`:
43
+
44
+ ```ruby
45
+ models = client.models
46
+
47
+ # Register and wait for deployment (blocks until deployed)
48
+ model = models.register(
49
+ name: "huggingface/sentence-transformers/all-MiniLM-L12-v2",
50
+ version: "1.0.1"
51
+ )
52
+
53
+ # Look up by name, UUID, or partial match
54
+ model = models["all-MiniLM-L12-v2"]
55
+ model = models["3a4b5c6d-..."] # UUID
56
+
57
+ # List all registered models
58
+ all_models = models.list
59
+
60
+ # Delete safely (undeploys first)
61
+ models.delete!(model)
62
+
63
+ # Build an ingest pipeline for embeddings
64
+ models.create_pipeline(
65
+ name: "embedding_pipeline",
66
+ model: model.name,
67
+ field_map: { "text" => "text_embedding" }
68
+ )
69
+ ```
70
+
71
+ The `Models` class hides the paginated list API, name/ID resolution, and the undeploy-before-delete
72
+ requirement behind these single-purpose methods.
73
+
74
+ ## Consequences
75
+
76
+ ### Positive
77
+
78
+ - **Unified lookup**: callers do not need to know whether they have a name or UUID; `[]` resolves
79
+ either, hiding the paginated scan.
80
+ - **Lifecycle safety**: `delete!` always undeploys before deleting; callers cannot accidentally
81
+ delete a deployed model and produce an error.
82
+ - **`register` blocks until deployed**: removes the need for callers to poll deployment status
83
+ themselves.
84
+ - **Discoverable API**: all model operations are co-located on one object, making the surface
85
+ easy to explore.
86
+
87
+ ### Negative
88
+
89
+ - **Blocking `register`**: waiting synchronously for model deployment may be unacceptable in
90
+ latency-sensitive contexts (e.g., a web request). Callers in those contexts must run
91
+ registration in a background job.
92
+ - **Partial-match ambiguity**: `[]` with a partial name string will match the first result;
93
+ if multiple models share a partial name, the result is non-deterministic. Callers must use
94
+ sufficiently specific identifiers.
95
+ - **Pagination hidden**: `list` returns all models by iterating pages internally. For clusters
96
+ with very large model catalogs this could be slow; there is no lazy/streaming alternative.
97
+
98
+ ### Neutral
99
+
100
+ - `Models` does not cache; each `list` call hits OpenSearch. Callers that need repeated
101
+ lookups in a tight loop should retrieve and store the model object themselves.
102
+ - The pipeline construction method (`create_pipeline`) is included here because pipelines are
103
+ tightly coupled to a specific model ID. This is a design convenience, not a strict Repository
104
+ concern.
105
+
106
+ ## Alternatives Considered
107
+
108
+ **Raw client calls at each call site**
109
+ Rejected because the paginated lookup and lifecycle sequencing would be duplicated everywhere
110
+ models are used. The undeploy-before-delete requirement is easy to forget and results in
111
+ confusing OpenSearch errors.
112
+
113
+ **Module of stateless helper functions**
114
+ Rejected for the same reasons as in ADR-002: requires passing the client on every call,
115
+ produces a flat, less discoverable API, and makes it harder to enforce invariants like
116
+ blocking until deployment is complete.
117
+
118
+ ## Diagram
119
+
120
+ ```mermaid
121
+ sequenceDiagram
122
+ participant Caller
123
+ participant Models as Sugar::Models
124
+ participant OS as OpenSearch ML Commons
125
+
126
+ Caller->>Models: register(name:, version:)
127
+ Models->>OS: ml.register_model(...)
128
+ OS-->>Models: task_id
129
+ loop Poll until deployed
130
+ Models->>OS: ml.get_task(task_id)
131
+ OS-->>Models: status
132
+ end
133
+ Models-->>Caller: Model object
134
+
135
+ Caller->>Models: delete!(model)
136
+ Models->>OS: ml.undeploy_model(model_id)
137
+ OS-->>Models: ok
138
+ Models->>OS: ml.delete_model(model_id)
139
+ OS-->>Models: ok
140
+ Models-->>Caller: (returns)
141
+ ```
142
+
143
+ ## Documentation Requirements
144
+
145
+ - HOWTO must warn that `register` blocks and is not appropriate for use in a hot path.
146
+ - HOWTO must document the partial-match behavior of `[]` and recommend using explicit names
147
+ or UUIDs where uniqueness matters.
148
+ - REFERENCE must document the `Models` public API with parameter types and return values.