Skip to content

Commit 714c62d

Browse files
schema_registry: add schema_registry_replay_on_startup
Recovery of the _schemas store is lazy: nothing loads the topic until the first request (or an internal schema::registry access) reaches ensure_started(). On a large registry the first request then blocks for the whole replay, and there is no way to hydrate the store ahead of traffic. Add a cluster config (default off) that, when set, proactively drives the replay on the reader shard at service start-up, fire-and-forget under the gate so it does not block broker start-up. It reuses the single-flight ensure_started() path, so a request that races the eager trigger still results in exactly one replay. Covered by ducktape tests for both settings: with the config set the store hydrates on restart with no client request, and with it unset recovery stays lazy until the first request. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8f95234 commit 714c62d

5 files changed

Lines changed: 138 additions & 1 deletion

File tree

src/v/config/configuration.cc

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3974,6 +3974,15 @@ configuration::configuration()
39743974
.visibility = visibility::user,
39753975
.aliases = {"schema_registry_normalize_on_startup"}},
39763976
false)
3977+
, schema_registry_replay_on_startup(
3978+
*this,
3979+
"schema_registry_replay_on_startup",
3980+
"Replay the internal `_schemas` topic into the Schema Registry store at "
3981+
"broker start-up instead of lazily on the first request. Makes recovery "
3982+
"time predictable and keeps the first request from blocking behind a "
3983+
"full replay.",
3984+
{.needs_restart = needs_restart::no, .visibility = visibility::user},
3985+
false)
39773986
, schema_registry_avro_use_named_references(
39783987
*this, "schema_registry_avro_use_named_references")
39793988
, schema_registry_enable_qualified_subjects(

src/v/config/configuration.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -683,6 +683,7 @@ struct configuration final : public config_store {
683683

684684
enterprise<property<bool>> schema_registry_enable_authorization;
685685
property<bool> schema_registry_always_normalize;
686+
property<bool> schema_registry_replay_on_startup;
686687
deprecated_property schema_registry_avro_use_named_references;
687688
property<bool> schema_registry_enable_qualified_subjects;
688689
bounded_property<size_t> schema_registry_sync_memory_bytes;

src/v/pandaproxy/schema_registry/BUILD

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,7 @@ redpanda_cc_library(
477477
"//src/v/security:request_auth",
478478
"//src/v/security/audit",
479479
"//src/v/security/audit:types",
480+
"//src/v/ssx:future_util",
480481
"//src/v/ssx:semaphore",
481482
"//src/v/ssx:sformat",
482483
"//src/v/strings:string_switch",

src/v/pandaproxy/schema_registry/service.cc

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
#include "security/audit/audit_log_manager.h"
3737
#include "security/authorizer.h"
3838
#include "security/request_auth.h"
39+
#include "ssx/future-util.h"
3940
#include "ssx/semaphore.h"
4041
#include "utils/tristate.h"
4142

@@ -824,10 +825,30 @@ service::service(
824825
ss::future<> service::start() {
825826
static std::vector<model::broker_endpoint> not_advertised{};
826827
_server.routes(get_schema_registry_routes(_gate, _ensure_started));
827-
co_return co_await _server.start(
828+
co_await _server.start(
828829
_config.schema_registry_api(),
829830
_config.schema_registry_api_tls(),
830831
not_advertised);
832+
833+
if (
834+
ss::this_shard_id() == seq_writer::reader_shard
835+
&& config::shard_local_cfg().schema_registry_replay_on_startup()) {
836+
// Proactively hydrate the store rather than wait for the first request.
837+
// Fire-and-forget under the gate: a large topic can take a while to
838+
// replay and must not block broker start-up. This goes through the
839+
// single-flight ensure_started(), so a request that races it still
840+
// triggers exactly one replay.
841+
ssx::spawn_with_gate(_gate, [this] {
842+
return ensure_started().handle_exception(
843+
[](const std::exception_ptr& e) {
844+
vlog(
845+
srlog.warn,
846+
"eager _schemas replay failed at start-up; will retry on "
847+
"the first request: {}",
848+
e);
849+
});
850+
});
851+
}
831852
}
832853

833854
ss::future<> service::stop() {

tests/rptest/tests/schema_registry_test.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12134,3 +12134,108 @@ def hammer():
1213412134
)
1213512135
assert r.status_code == requests.codes.ok, r.text
1213612136
assert len(r.json()) >= self.N_SUBJECTS
12137+
12138+
@cluster(num_nodes=3, log_allow_list=_SR_STARTUP_RECOVERY_LOG_ALLOW_LIST)
12139+
def test_replay_on_startup_without_request(self):
12140+
"""
12141+
With schema_registry_replay_on_startup enabled, the store must hydrate
12142+
proactively at startup without any client request driving it.
12143+
12144+
admin restart_service re-runs service::start() (via api::restart ->
12145+
start -> service::start), which is where the eager trigger lives, and
12146+
does not restart the broker process, so the log persists and we can
12147+
watch for the replay without touching the SR API.
12148+
"""
12149+
node = self.redpanda.nodes[0]
12150+
host = node.account.hostname
12151+
12152+
# Populate _schemas so a restart has a topic to replay.
12153+
self._register_schemas(host)
12154+
wait_until(
12155+
lambda: self._all_subjects_served(host),
12156+
timeout_sec=30,
12157+
backoff_sec=1,
12158+
err_msg="subjects not visible after registration",
12159+
)
12160+
12161+
self.redpanda.set_cluster_config({"schema_registry_replay_on_startup": True})
12162+
12163+
init_before = self.redpanda.count_log_node(node, self.INIT_MARKER)
12164+
12165+
admin = Admin(self.redpanda)
12166+
result = admin.restart_service(rp_service="schema-registry", node=node)
12167+
assert result.status_code == requests.codes.ok, (
12168+
f"restart_service failed: {result.status_code} {result.text}"
12169+
)
12170+
12171+
# Deliberately issue NO SR request. The eager startup trigger must drive
12172+
# the replay on its own. count_log_node greps the broker log over ssh,
12173+
# so polling here does not hit the SR API and cannot trigger the lazy
12174+
# path.
12175+
wait_until(
12176+
lambda: self.redpanda.count_log_node(node, self.INIT_MARKER) > init_before,
12177+
timeout_sec=60,
12178+
backoff_sec=1,
12179+
err_msg="eager startup replay did not run without any request",
12180+
)
12181+
12182+
replays = self.redpanda.count_log_node(node, self.INIT_MARKER) - init_before
12183+
assert replays == 1, (
12184+
f"expected exactly one eager replay, saw {replays} "
12185+
f"('{self.INIT_MARKER}' delta)."
12186+
)
12187+
12188+
# The store hydrated without a request; a request now succeeds
12189+
# immediately rather than blocking on a cold replay.
12190+
assert self._all_subjects_served(host)
12191+
12192+
@cluster(num_nodes=3, log_allow_list=_SR_STARTUP_RECOVERY_LOG_ALLOW_LIST)
12193+
def test_no_replay_on_startup_by_default(self):
12194+
"""
12195+
With schema_registry_replay_on_startup unset (the default), recovery
12196+
stays lazy: a restart does not replay _schemas until a request (or an
12197+
internal access) arrives. The complement of
12198+
test_replay_on_startup_without_request, and a guard that the config
12199+
actually gates the eager path.
12200+
"""
12201+
node = self.redpanda.nodes[0]
12202+
host = node.account.hostname
12203+
12204+
self._register_schemas(host)
12205+
wait_until(
12206+
lambda: self._all_subjects_served(host),
12207+
timeout_sec=30,
12208+
backoff_sec=1,
12209+
err_msg="subjects not visible after registration",
12210+
)
12211+
12212+
# Deliberately leave schema_registry_replay_on_startup at its default
12213+
# (off). do_start() logs INIT_MARKER once per run, so a flat count after
12214+
# a restart with no request means recovery did not run.
12215+
init_before = self.redpanda.count_log_node(node, self.INIT_MARKER)
12216+
12217+
admin = Admin(self.redpanda)
12218+
result = admin.restart_service(rp_service="schema-registry", node=node)
12219+
assert result.status_code == requests.codes.ok, (
12220+
f"restart_service failed: {result.status_code} {result.text}"
12221+
)
12222+
12223+
# No SR request is issued. Give recovery ample time to (not) happen;
12224+
# a real replay of this topic completes in well under a second.
12225+
time.sleep(15)
12226+
assert self.redpanda.count_log_node(node, self.INIT_MARKER) == init_before, (
12227+
"recovery ran at startup without the config set and without a "
12228+
"request; it should be lazy by default"
12229+
)
12230+
12231+
# The first request now lazily triggers exactly one replay and serves.
12232+
wait_until(
12233+
lambda: self._all_subjects_served(host),
12234+
timeout_sec=30,
12235+
backoff_sec=1,
12236+
err_msg="lazy recovery did not serve after the first request",
12237+
)
12238+
replays = self.redpanda.count_log_node(node, self.INIT_MARKER) - init_before
12239+
assert replays == 1, (
12240+
f"expected exactly one lazy replay after the first request, saw {replays}."
12241+
)

0 commit comments

Comments
 (0)