mirror of
https://github.com/domainaware/parsedmarc.git
synced 2026-09-12 00:48:00 +00:00
_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>