Files
parsedmarc/tests
Sean WhalenandClaude Fable 5.1 550244c6d7 fix: roll back the search backends' default alias when output init fails (#906)
_init_output_clients() is now all-or-nothing against the Elasticsearch
and OpenSearch connection registries: either it returns the fully built
client dict, or it closes everything it built and leaves both "default"
aliases naming exactly what they named on entry.

elastic.set_hosts()/opensearch.set_hosts() end in
connections.create_connection(), which registers the new client under
the process-wide "default" alias the moment it is constructed
(elasticsearch/dsl/connections.py:81-88 and
opensearchpy/connection/connections.py:87-95, as installed: 8.19.3 /
3.2.0). Everything that runs after that -- the index migration, and
every output configured later -- can still fail. On the SIGHUP reload
path _main() builds the replacement clients before closing the old ones
and, when the build raises, logs "Config reload failed, continuing with
previous config" and keeps the old opts. But the alias had already been
handed to the new client, so every save -- elastic.py's Search and
Document.save() calls, which resolve the "default" alias through
elasticsearch/dsl/_sync/document.py:99-100 -- reached the new cluster
while the index prefixes/suffixes and index_prefix_domain_map still came
from the old configuration. The half-built client was never
closed either, nor was any client built earlier in the same failed call
(that half also leaked on every attempt of the startup retry loop).

The handler tears down first and restores second -- with the teardown in
a try/finally, since a second Ctrl-C landing in it propagates straight
through _close_output_clients, which swallows only Exception -- and the
two steps are not interchangeable. With an _ElasticsearchHandle already in `clients`,
teardown closes its client and releases the alias -- which still names
that client -- and the restore then re-registers the previous client
into an unset alias. Restoring first would put the previous client back
and only then close the handle, which rests the whole rollback on the
handle declining to touch an alias that no longer names its own client:
true only since #902, and a property of the handle rather than of this
function. Tearing down first keeps the guarantee local. Both failure
points are traced in the comment on the handler.

Closing the discarded client is best-effort, and closing it twice is
safe: Elasticsearch.close() -> Transport.close() closes each node's
urllib3 pool (elastic_transport/_transport.py:499-504,
_node/_http_urllib3.py:224-228, and urllib3 pool close() is a no-op once
cleared), and OpenSearch's connection close() guards on `if self.pool`
(opensearchpy/connection/http_urllib3.py:323-328). Taking the snapshot
cannot itself open a connection: get_connection() lazily builds a client
from kwargs left behind by configure() (dsl/connections.py:90-115), and
parsedmarc never calls configure().

The guard catches BaseException rather than Exception because
migrate_indexes() wraps every cluster call in `except Exception` and
logs a warning (elastic.py:824-829, and again in each of the per-index
loops that follow), so what escapes the Elasticsearch block after
set_hosts() is, in practice, a KeyboardInterrupt landing in one of those
calls --
which is what the regression tests inject at the SDK transport
boundary. For the same reason, closing the discarded client swallows
BaseException: an interrupt there must not cost the alias its hand-back,
and the original failure is re-raised afterwards either way.

The alias is not the only module-level state set_hosts() writes:
elastic.set_hosts() also assigns elastic._SERVERLESS (elastic.py:620-622)
-- before it constructs the client, so it can be stale even on a failure
that never reached the registry -- and create_indexes() consults it
(elastic.py:652-659) to decide whether to strip the shard settings
Serverless rejects. It is snapshotted and restored alongside the alias.
opensearch.py has no equivalent (no `global` statement in the file).

Shape: the existing body keeps its indentation as _build_output_clients(),
which fills a `clients` dict the caller passes in and is explicitly not
transactional; the rollback lives in a short _init_output_clients()
wrapper that owns that dict on both paths. Passing the dict in is what
lets the wrapper close what was already built after the build raises. The
public name is unchanged, so the tests and _main() call sites are
untouched.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 08:40:36 -04:00
..