github-actions[bot] commented on code in PR #68032:
URL: https://github.com/apache/doris/pull/68032#discussion_r4018558013
##########
cloud/src/meta-service/meta_service.cpp:
##########
@@ -3530,6 +3530,143 @@ void
MetaServiceImpl::get_tablet_stats(::google::protobuf::RpcController* contro
}
}
+void MetaServiceImpl::report_spill_stats(::google::protobuf::RpcController*
controller,
+ const ReportSpillStatsRequest*
request,
+ ReportSpillStatsResponse* response,
+ ::google::protobuf::Closure* done) {
+ RPC_PREPROCESS(report_spill_stats, put);
Review Comment:
`report_spill_stats` reads the existing record on every periodic report, but
this declaration publishes only the `put` side of the transaction.
`RPC_PREPROCESS` exports detailed KV counters only for the operation names
passed here, and the PR adds no report-spill `get` bvars, so this RPC's FDB
read QPS/bytes are invisible under `use_detailed_metrics`. Please add the get
counter/byte bvars and declare this as `RPC_PREPROCESS(report_spill_stats, get,
put)`.
##########
fe/fe-core/src/main/java/org/apache/doris/cloud/rpc/MetaServiceProxy.java:
##########
@@ -97,6 +97,32 @@ public boolean needReconn() {
}
}
+ public Cloud.GetSpillStatsResponse
getSpillStats(Cloud.GetSpillStatsRequest request)
Review Comment:
This new proxy method bypasses `MetaServiceClientWrapper.executeRequest`,
unlike comparable calls such as `getTabletStats`. One
`UNAVAILABLE`/`UNKNOWN`/retryable timeout or `MS_TOO_BUSY` response therefore
immediately fails `SHOW DATA PROPERTIES("entire_warehouse"="true")`, and a
failed client is not reconnected. Please implement this through
`executeWithMetrics("getSpillStats", client -> client.getSpillStats(request),
Cloud.GetSpillStatsResponse::getStatus)` so it retains the standard retry and
reconnection semantics.
##########
be/src/exec/spill/spill_file_reader.cpp:
##########
@@ -62,6 +66,26 @@ SpillFileReader::SpillFileReader(RuntimeState* state,
RuntimeProfile* profile,
_read_file_size = get_counter(profile::SPILL_READ_FILE_BYTES);
_read_rows_count = get_counter(profile::SPILL_READ_ROWS);
_read_file_count = get_counter(profile::SPILL_READ_FILE_COUNT);
+ // Optional: older profiles may not register it.
+ _remote_read_requests =
custom_profile->get_counter(profile::SPILL_REMOTE_READ_REQUESTS);
+}
+
+void SpillFileReader::_record_read(size_t bytes_read) {
+ COUNTER_UPDATE(_read_file_size, bytes_read);
+
ExecEnv::GetInstance()->spill_file_mgr()->update_spill_read_bytes(bytes_read);
+ if (_is_remote) {
+ // One read_at() is exactly one GET request on object storage.
Review Comment:
One logical `read_at()` is not necessarily one object-store GET.
`S3FileReader::read_at_impl` may issue multiple `get_object` attempts after
throttling, and it also issues requests that ultimately fail; this callback
runs only once after a successful logical read. Query, workload, and global
spill GET counters therefore under-report actual request traffic. Please
account attempts at the S3 request boundary (or propagate the per-call attempt
delta, including failures) and test retry-then-success plus exhausted failure.
##########
cloud/src/recycler/recycler.cpp:
##########
@@ -7828,6 +7835,50 @@ int InstanceRecycler::recycle_expired_stage_objects() {
return ret;
}
+int InstanceRecycler::recycle_expired_spill_objects() {
+ LOG_INFO("begin to recycle expired spill objects").tag("instance_id",
instance_id_);
+
+ int64_t start_time =
duration_cast<seconds>(steady_clock::now().time_since_epoch()).count();
+ RecyclerMetricsContext metrics_context(instance_id_,
"recycle_expired_spill_objects");
+
+ DORIS_CLOUD_DEFER {
+ int64_t cost =
+
duration_cast<seconds>(steady_clock::now().time_since_epoch()).count() -
start_time;
+ metrics_context.finish_report();
+ LOG_INFO("recycle expired spill objects, cost={}s",
cost).tag("instance_id", instance_id_);
+ };
+
+ int64_t expiration_time =
Review Comment:
This age-only sweep has no active boot/query fence. A valid query can have
immutable spill parts older than the seven-day default because `query_timeout`
has no upper bound, so the recycler can delete data the query still needs. The
mutable TTL is also unvalidated: `0` selects objects up to now and a negative
value moves the cutoff into the future, selecting new live objects. Please
validate a positive TTL and add a liveness fence or enforce the maximum-query
invariant; the current mock test ignores expiration and cannot cover either
case.
##########
be/src/exec/spill/spill_file.cpp:
##########
@@ -43,23 +43,26 @@ SpillFile::~SpillFile() {
}
void SpillFile::gc() {
- bool exists = false;
- auto status = io::global_local_filesystem()->exists(_spill_dir, &exists);
- if (status.ok() && exists) {
- // Delete spill directory directly instead of moving it to a GC
directory.
- // This simplifies cleanup and avoids retaining spill data under a GC
path.
- status = io::global_local_filesystem()->delete_directory(_spill_dir);
+ if (_dir_created) {
+ // Delete the spill directory (or object key prefix) directly instead
of moving it to a
+ // GC directory. No existence check: for object storage a "directory"
never exists as an
+ // object, while deleting a missing local directory or an empty prefix
is a no-op.
+ auto fs = _data_dir->fs();
+ Status status = fs != nullptr ? fs->delete_directory(_spill_dir)
+ : Status::InternalError("spill store {}
is not ready",
+
_data_dir->path());
DBUG_EXECUTE_IF("fault_inject::spill_file::gc", {
status = Status::Error<INTERNAL_ERROR>("fault_inject spill_file gc
failed");
});
if (!status.ok()) {
LOG_EVERY_T(WARNING, 1) << fmt::format("failed to delete spill
data, dir {}, error: {}",
_spill_dir,
status.to_string());
}
+ _dir_created = false;
}
// Decrease spill data usage even if per-file cleanup failed. QueryContext
teardown deletes the
// whole query spill directory and retains failures for later retries.
- _data_dir->update_spill_data_usage(-_total_written_bytes);
+ _data_dir->release(_total_written_bytes);
Review Comment:
When remote deletion fails, the objects are still kept in object storage,
but this unconditionally releases their bytes from
`spill_s3_storage_limit_bytes`. The pending query-directory retry stores only a
path, so a prolonged deletion outage lets each failed query release and reuse
the quota while retained objects accumulate without bound. Please transfer the
reservation to pending cleanup and release it only after deletion succeeds,
deduplicating per-file bytes, and extend the retry test to assert capacity
remains charged.
##########
cloud/src/meta-service/meta_service.cpp:
##########
@@ -3530,6 +3530,143 @@ void
MetaServiceImpl::get_tablet_stats(::google::protobuf::RpcController* contro
}
}
+void MetaServiceImpl::report_spill_stats(::google::protobuf::RpcController*
controller,
+ const ReportSpillStatsRequest*
request,
+ ReportSpillStatsResponse* response,
+ ::google::protobuf::Closure* done) {
+ RPC_PREPROCESS(report_spill_stats, put);
+ instance_id = get_instance_id(resource_mgr_, request->cloud_unique_id());
+ if (instance_id.empty()) {
+ code = MetaServiceCode::INVALID_ARGUMENT;
+ msg = "empty instance_id";
+ LOG(INFO) << msg << ", cloud_unique_id=" << request->cloud_unique_id();
+ return;
+ }
+ RPC_RATE_LIMIT(report_spill_stats)
+ if (!request->has_stats() || request->stats().cloud_unique_id().empty()) {
+ code = MetaServiceCode::INVALID_ARGUMENT;
+ msg = "spill stats without cloud_unique_id";
+ return;
+ }
+ const auto& spill_stats = request->stats();
+ if (spill_stats.boot_id() <= 0 || spill_stats.remote_write_bytes() < 0 ||
+ spill_stats.remote_put_requests() < 0) {
+ code = MetaServiceCode::INVALID_ARGUMENT;
+ msg = fmt::format("invalid spill stats, boot_id={} write_bytes={}
put_requests={}",
+ spill_stats.boot_id(),
spill_stats.remote_write_bytes(),
+ spill_stats.remote_put_requests());
+ return;
+ }
+
+ TxnErrorCode err = txn_kv_->create_txn(&txn);
+ if (err != TxnErrorCode::TXN_OK) {
+ code = cast_as<ErrCategory::CREATE>(err);
+ msg = fmt::format("failed to create txn, err={}", err);
+ return;
+ }
+ // One record per BE. The report carries totals since boot, so a report of
the same boot
+ // replaces the previous one (a retry cannot double count). The first
report of a new boot
+ // folds the previous process' totals into prior_boots_*, which keeps the
record count
+ // bounded by the number of BEs instead of the number of BE restarts.
+ std::string key = stats_spill_key({instance_id,
spill_stats.cloud_unique_id()});
+ std::string existing_val;
+ err = txn->get(key, &existing_val);
+ if (err != TxnErrorCode::TXN_OK && err != TxnErrorCode::TXN_KEY_NOT_FOUND)
{
+ code = cast_as<ErrCategory::READ>(err);
+ msg = fmt::format("failed to get spill stats, err={}", err);
+ return;
+ }
+ SpillStatsPB value = spill_stats;
+ if (err == TxnErrorCode::TXN_OK) {
+ SpillStatsPB existing;
+ if (!existing.ParseFromString(existing_val)) {
+ code = MetaServiceCode::PROTOBUF_PARSE_ERR;
+ msg = fmt::format("malformed spill stats, key={}", hex(key));
+ return;
+ }
+ int64_t prior_bytes = existing.prior_boots_write_bytes();
+ int64_t prior_requests = existing.prior_boots_put_requests();
+ if (existing.boot_id() != spill_stats.boot_id()) {
+ prior_bytes += existing.remote_write_bytes();
+ prior_requests += existing.remote_put_requests();
+ } else {
+ // Totals of one process only grow; a late-delivered duplicate
must not roll back.
+ value.set_remote_write_bytes(
+ std::max(existing.remote_write_bytes(),
spill_stats.remote_write_bytes()));
+ value.set_remote_put_requests(
+ std::max(existing.remote_put_requests(),
spill_stats.remote_put_requests()));
+ }
+ value.set_prior_boots_write_bytes(prior_bytes);
+ value.set_prior_boots_put_requests(prior_requests);
+ }
+
value.set_update_time_ms(std::chrono::duration_cast<std::chrono::milliseconds>(
+
std::chrono::system_clock::now().time_since_epoch())
+ .count());
+ txn->put(key, value.SerializeAsString());
+ err = txn->commit();
+ if (err != TxnErrorCode::TXN_OK) {
+ code = cast_as<ErrCategory::COMMIT>(err);
+ msg = fmt::format("failed to commit spill stats, err={}
cloud_unique_id={} boot_id={}", err,
+ spill_stats.cloud_unique_id(),
spill_stats.boot_id());
+ return;
+ }
+}
+
+void MetaServiceImpl::get_spill_stats(::google::protobuf::RpcController*
controller,
+ const GetSpillStatsRequest* request,
+ GetSpillStatsResponse* response,
+ ::google::protobuf::Closure* done) {
+ RPC_PREPROCESS(get_spill_stats, get);
+ instance_id = get_instance_id(resource_mgr_, request->cloud_unique_id());
+ if (instance_id.empty()) {
+ code = MetaServiceCode::INVALID_ARGUMENT;
+ msg = "empty instance_id";
+ LOG(INFO) << msg << ", cloud_unique_id=" << request->cloud_unique_id();
+ return;
+ }
+ RPC_RATE_LIMIT(get_spill_stats)
+
+ TxnErrorCode err = txn_kv_->create_txn(&txn);
+ if (err != TxnErrorCode::TXN_OK) {
+ code = cast_as<ErrCategory::CREATE>(err);
+ msg = fmt::format("failed to create txn, err={}", err);
+ return;
+ }
+ std::string key0 = stats_spill_key_prefix(instance_id);
Review Comment:
This range is not bounded by the number of live BEs. Every add/replacement
generates a new identity and persistent stats key, ordinary node/cluster
removal never removes or folds it, and only whole-instance recycling clears the
range. A long-lived elastic instance therefore scans all identities ever
created in one transaction and returns every PB, eventually hitting
read/response limits although FE needs only the scalar lifetime total. Please
compact retired-node totals into a bounded instance aggregate with fenced
finalization and add churn coverage for bounded key/read cardinality.
##########
be/src/common/config.cpp:
##########
@@ -1610,6 +1610,13 @@ DEFINE_String(spill_storage_limit, "20%");
// 20%
DEFINE_mInt32(spill_gc_interval_ms, "2000"); // 2s
DEFINE_mInt32(spill_gc_work_time_ms, "2000"); // 2s
DEFINE_mInt64(spill_file_part_size_bytes, "1073741824"); // 1GB
+DEFINE_String(spill_storage_type, "local");
+DEFINE_Validator(spill_storage_type, [](const std::string& config) -> bool {
+ return config == "local" || config == "s3";
+});
+DEFINE_String(spill_s3_storage_vault, "");
+DEFINE_mInt64(spill_s3_storage_limit_bytes, "0");
Review Comment:
The documented unlimited sentinel is `0`, but this mutable config accepts
negative values too. `SpillDataDir::_reach_limit_unlocked` checks only `> 0`,
so setting `-1` at startup or dynamically silently disables the billable
object-storage cap and exposes a negative limit metric. Please add a validator
requiring `spill_s3_storage_limit_bytes >= 0` and cover rejection of a negative
update.
##########
be/src/exec/spill/spill_file_manager.cpp:
##########
@@ -330,6 +571,61 @@ SpillDataDir::SpillDataDir(std::string path, int64_t
capacity_bytes,
INT_GAUGE_METRIC_REGISTER(spill_data_dir_metric_entity,
spill_disk_has_spill_gc_data);
}
+Status SpillDataDir::ensure_ready() {
+ if (!_is_remote || ready()) {
+ return Status::OK();
+ }
+ std::lock_guard<std::mutex> lock(_init_mutex);
+ if (ready()) {
+ return Status::OK();
+ }
+ if (!config::is_cloud_mode()) {
+ return Status::InternalError("spill to s3 is only supported in cloud
mode");
+ }
+ if (config::cloud_unique_id.empty()) {
+ return Status::InternalError(
+ "spill to s3 is not ready: cloud_unique_id is empty, waiting
for FE heartbeat");
+ }
+ // Resolve from what is already known locally; never trigger a
meta-service sync here.
+ // The vault refresh thread and the heartbeat fill these in, and callers
retry.
+ auto& engine = ExecEnv::GetInstance()->storage_engine().to_cloud();
+ std::string vault_id = _vault_id.empty() ? engine.default_vault_id() :
_vault_id;
+ io::RemoteFileSystemSPtr fs =
+ vault_id.empty() ? engine.latest_fs() :
doris::get_filesystem(vault_id);
+ if (fs == nullptr) {
+ return Status::InternalError(
+ "spill to s3 is not ready: storage vault '{}' not found (empty
means the default "
+ "vault of the instance; set spill_s3_storage_vault to the
vault ID in be.conf if "
+ "the instance has no default vault)",
+ vault_id);
+ }
+ if (fs->type() != io::FileSystemType::S3) {
+ return Status::NotSupported("spill to s3 only supports S3 storage
vaults, vault '{}' is {}",
+ vault_id, fs->type());
+ }
+ init_remote_fs(fs, config::cloud_unique_id);
+ return Status::OK();
+}
+
+void SpillDataDir::init_remote_fs(io::FileSystemSPtr fs, const std::string&
cloud_unique_id) {
+ DCHECK(_is_remote);
+ _fs = std::move(fs);
+ _remote_be_root = fmt::format("{}/{}", SPILL_DIR_PREFIX, cloud_unique_id);
Review Comment:
`cloud_unique_id` is not unique per BE: `CloudSystemInfoService.addBackends`
generates one value before looping over all requested hosts, ResourceManager
supports multiple nodes with that value, and FE sends it to each BE. Two live
BEs can therefore both use `spill/C/{boot_id}`; when either starts cleanup it
classifies the other's active boot as old and deletes its spill objects. Their
reports also collide in one `(instance_id, C)` record and are repeatedly folded
as restarts. Please include a persisted per-node identity such as heartbeat
`backend_id` in both the object root and stats key, and test two simultaneously
live BEs, including existing duplicate cloud IDs.
##########
cloud/src/meta-service/meta_service.cpp:
##########
@@ -3530,6 +3530,143 @@ void
MetaServiceImpl::get_tablet_stats(::google::protobuf::RpcController* contro
}
}
+void MetaServiceImpl::report_spill_stats(::google::protobuf::RpcController*
controller,
+ const ReportSpillStatsRequest*
request,
+ ReportSpillStatsResponse* response,
+ ::google::protobuf::Closure* done) {
+ RPC_PREPROCESS(report_spill_stats, put);
+ instance_id = get_instance_id(resource_mgr_, request->cloud_unique_id());
+ if (instance_id.empty()) {
+ code = MetaServiceCode::INVALID_ARGUMENT;
+ msg = "empty instance_id";
+ LOG(INFO) << msg << ", cloud_unique_id=" << request->cloud_unique_id();
+ return;
+ }
+ RPC_RATE_LIMIT(report_spill_stats)
+ if (!request->has_stats() || request->stats().cloud_unique_id().empty()) {
+ code = MetaServiceCode::INVALID_ARGUMENT;
+ msg = "spill stats without cloud_unique_id";
+ return;
+ }
+ const auto& spill_stats = request->stats();
+ if (spill_stats.boot_id() <= 0 || spill_stats.remote_write_bytes() < 0 ||
+ spill_stats.remote_put_requests() < 0) {
+ code = MetaServiceCode::INVALID_ARGUMENT;
+ msg = fmt::format("invalid spill stats, boot_id={} write_bytes={}
put_requests={}",
+ spill_stats.boot_id(),
spill_stats.remote_write_bytes(),
+ spill_stats.remote_put_requests());
+ return;
+ }
+
+ TxnErrorCode err = txn_kv_->create_txn(&txn);
+ if (err != TxnErrorCode::TXN_OK) {
+ code = cast_as<ErrCategory::CREATE>(err);
+ msg = fmt::format("failed to create txn, err={}", err);
+ return;
+ }
+ // One record per BE. The report carries totals since boot, so a report of
the same boot
+ // replaces the previous one (a retry cannot double count). The first
report of a new boot
+ // folds the previous process' totals into prior_boots_*, which keeps the
record count
+ // bounded by the number of BEs instead of the number of BE restarts.
+ std::string key = stats_spill_key({instance_id,
spill_stats.cloud_unique_id()});
+ std::string existing_val;
+ err = txn->get(key, &existing_val);
+ if (err != TxnErrorCode::TXN_OK && err != TxnErrorCode::TXN_KEY_NOT_FOUND)
{
+ code = cast_as<ErrCategory::READ>(err);
+ msg = fmt::format("failed to get spill stats, err={}", err);
+ return;
+ }
+ SpillStatsPB value = spill_stats;
+ if (err == TxnErrorCode::TXN_OK) {
+ SpillStatsPB existing;
+ if (!existing.ParseFromString(existing_val)) {
+ code = MetaServiceCode::PROTOBUF_PARSE_ERR;
+ msg = fmt::format("malformed spill stats, key={}", hex(key));
+ return;
+ }
+ int64_t prior_bytes = existing.prior_boots_write_bytes();
+ int64_t prior_requests = existing.prior_boots_put_requests();
+ if (existing.boot_id() != spill_stats.boot_id()) {
Review Comment:
`!=` does not establish that this is a newer boot. Starting with boot
1000/current 300, boot 1001/current 80 stores total 380; a late
boot-1000/current-300 report then stores 680, and the next boot-1001 report
raises it to 760. Transaction conflicts serialize overlap but do not reject
sequential stale requests. Please use independently idempotent per-generation
records, or a durable monotonic fencing token that ignores older generations;
`UnixMillis()` alone is unsafe because wall time can move backward or collide.
Add an old-after-new-after-current delivery test.
##########
be/src/exec/spill/spill_file_manager.cpp:
##########
@@ -308,6 +438,99 @@ void SpillFileManager::gc(int32_t max_work_time_ms) {
}
}
+void SpillFileManager::_remote_gc(SpillDataDir* store) {
+ if (!store->ready()) {
+ // Retry about once a minute at the default 2s GC interval.
ensure_ready() only reads
+ // what the vault refresh thread and the FE heartbeat already brought
in.
+ if (_remote_not_ready_rounds++ % 30 != 0) {
+ return;
+ }
+ auto st = store->ensure_ready();
+ if (!st.ok()) {
+ LOG(WARNING) << "remote spill store is not ready yet: " << st;
+ return;
+ }
+ }
+ _report_remote_spill_stats(store);
+ if (!remote_startup_cleanup_pending()) {
+ return;
+ }
+ auto st = _remote_startup_cleanup(store);
+ if (st.ok()) {
+ _remote_startup_cleanup_pending.store(false,
std::memory_order_release);
+ } else {
+ LOG_EVERY_T(WARNING, 60) << "failed to clean up spill objects of
previous boots, will "
+ "retry: "
+ << st;
+ }
+}
+
+void SpillFileManager::flush_remote_spill_stats() {
+ for (auto& [path, store] : _spill_store_map) {
+ if (store->is_remote()) {
+ _report_remote_spill_stats(store.get(), /*final_report=*/true);
+ }
+ }
+}
+
+void SpillFileManager::_report_remote_spill_stats(SpillDataDir* store, bool
final_report) {
+ std::lock_guard<std::mutex> lock(_remote_report_mutex);
+ // About once a minute at the default 2s GC interval; a final report skips
the cadence.
Review Comment:
These counters are process-local and reported only about once per minute;
the final flush executes only on graceful shutdown. If the BE crashes or the
host is lost after successful uploads but before the next report, the restarted
BE has no durable checkpoint and meta-service can preserve only the older
value, so the missing suffix is permanent despite the proto's
no-loss-across-restarts and billing-input contract. Please make the
acknowledged total recoverable across abrupt restarts, or explicitly label it
best-effort and remove the no-loss guarantee; add a crash-before-next-report
test.
##########
be/src/exec/spill/spill_file_writer.cpp:
##########
@@ -90,44 +145,179 @@ Status SpillFileWriter::_close_current_part(const
std::shared_ptr<SpillFile>& sp
_part_meta.append((const char*)&_part_max_sub_block_size,
sizeof(_part_max_sub_block_size));
_part_meta.append((const char*)&_part_written_blocks,
sizeof(_part_written_blocks));
- {
+ int64_t meta_size = _part_meta.size();
+ // The footer must always be written so that the part can be closed;
account it
+ // without checking the capacity limit.
+ Status status = _data_dir->try_reserve(meta_size, /*force=*/true);
+ if (status.ok()) {
SCOPED_TIMER(_write_file_timer);
- RETURN_IF_ERROR(_file_writer->append(_part_meta));
+ status = _file_writer->append(_part_meta);
+ if (!status.ok()) {
+ _data_dir->release(meta_size);
+ }
}
- int64_t meta_size = _part_meta.size();
- _part_written_bytes += meta_size;
- COUNTER_UPDATE(_write_file_total_size, meta_size);
- if (_resource_ctx) {
-
_resource_ctx->io_context()->update_spill_write_bytes_to_local_storage(meta_size);
- }
- if (_write_file_current_size) {
- COUNTER_UPDATE(_write_file_current_size, meta_size);
- }
- _data_dir->update_spill_data_usage(meta_size);
-
ExecEnv::GetInstance()->spill_file_mgr()->update_spill_write_bytes(meta_size);
- // Incrementally update SpillFile's accounting so gc() can always
- // decrement the correct amount, even if close() is never called.
- if (spill_file) {
- spill_file->update_written_bytes(meta_size);
+ if (status.ok()) {
+ _part_written_bytes += meta_size;
+ COUNTER_UPDATE(_write_file_total_size, meta_size);
+ if (_resource_ctx) {
+ if (_data_dir->is_remote()) {
+
_resource_ctx->io_context()->update_spill_write_bytes_to_remote_storage(meta_size);
+ } else {
+
_resource_ctx->io_context()->update_spill_write_bytes_to_local_storage(meta_size);
+ }
+ }
+ if (_write_file_current_size) {
+ COUNTER_UPDATE(_write_file_current_size, meta_size);
+ }
+
ExecEnv::GetInstance()->spill_file_mgr()->update_spill_write_bytes(meta_size);
+ // Incrementally update SpillFile's accounting so gc() can always
+ // decrement the correct amount, even if close() is never called.
+ if (spill_file) {
+ spill_file->update_written_bytes(meta_size);
+ }
}
- RETURN_IF_ERROR(_file_writer->close());
- _file_writer.reset();
+ // Issue a non-blocking close so that the upload of this part overlaps
with the next
+ // one. The part is confirmed later by _reap_closing_parts(), which also
reconciles
+ // the upload budget, so it is queued even when something above failed.
+ ClosingPart part;
+ part.path = _current_part_path;
+ part.part_index = _current_part_index;
+ part.part_bytes = _part_written_bytes;
+ part.ledger = std::move(_part_ledger);
+ part.stats = std::move(_part_stats);
+ part.close_status = status.ok() ? _file_writer->close(/*non_block=*/true)
: status;
+ part.writer = std::move(_file_writer);
+ _closing_parts.emplace_back(std::move(part));
// Advance to next part
++_current_part_index;
- if (spill_file) {
- spill_file->increment_part_count();
- }
_part_written_blocks = 0;
_part_written_bytes = 0;
_part_max_sub_block_size = 0;
_part_meta.clear();
+ return status;
+}
+
+Status SpillFileWriter::_reap_closing_parts(bool block,
+ const std::shared_ptr<SpillFile>&
spill_file) {
+ Status first_error;
+ while (!_closing_parts.empty()) {
+ auto& part = _closing_parts.front();
+ Status st = part.close_status;
+ if (st.ok()) {
+ st = part.writer->try_finish_close();
+ if (st.is<ErrorCode::NEED_SEND_AGAIN>()) {
+ if (!block) {
+ break;
+ }
+ st = part.writer->close();
+ } else if (st.is<ErrorCode::NOT_IMPLEMENTED_ERROR>()) {
+ // Writers without an async close protocol (local files)
finished the work
+ // in close(true); close() only flips the state.
+ st = part.writer->state() == io::FileWriter::State::CLOSED ?
Status::OK()
+ :
part.writer->close();
+ }
+ }
+ st = _finish_part(part, spill_file, st);
+ _closing_parts.erase(_closing_parts.begin());
+ if (!st.ok() && first_error.ok()) {
+ first_error = st;
+ if (!block) {
+ break;
+ }
+ }
+ }
+ return first_error;
+}
+
+Status SpillFileWriter::_finish_part(ClosingPart& part,
+ const std::shared_ptr<SpillFile>&
spill_file,
+ Status close_status) {
+ MultipartUploadId upload = _multipart_upload_id(part.writer.get());
+ if (!close_status.ok() && part.writer != nullptr &&
+ part.writer->state() != io::FileWriter::State::CLOSED) {
+ // The part never reached a final state (footer failed, or close(true)
could not be
+ // issued). Destroying the writer waits for every in-flight upload, so
the ledger below
+ // is complete afterwards. close() is not used for this: on a
cancelled query it would
+ // be refused by the upload gate right away and drain nothing.
+ part.writer.reset();
+ }
+
+ // Budget: everything the gate took for this part but the upload callback
never gave
+ // back (buffers that failed before their upload started) is released
here. The writer is
+ // in its final state at this point, so every callback that will ever fire
has fired.
+ if (_budget != nullptr && part.ledger != nullptr) {
+ int64_t remaining = part.ledger->acquired.load() -
part.ledger->released.load();
+ DCHECK_GE(remaining, 0) << "upload callback released more than
acquired, part="
+ << part.path;
+ if (remaining > 0) {
+ _budget->release(remaining);
+ }
+ if (_remote_upload_wait_timer != nullptr) {
+ COUNTER_UPDATE(_remote_upload_wait_timer,
part.ledger->wait_ns.load());
+ }
+ }
+
+ if (part.stats != nullptr) {
+ int64_t requests = part.stats->total_requests();
+ int64_t data_requests = part.stats->put_object_requests +
part.stats->upload_part_requests;
+ int64_t uploaded_bytes = part.stats->uploaded_bytes;
+ if (_remote_write_requests != nullptr) {
+ COUNTER_UPDATE(_remote_write_requests, requests);
+ COUNTER_UPDATE(_remote_upload_part_requests, data_requests);
+ COUNTER_UPDATE(_remote_upload_bytes, uploaded_bytes);
+ COUNTER_UPDATE(_remote_upload_timer,
part.stats->request_time_ns.load());
+ }
+ if (_resource_ctx) {
+
_resource_ctx->io_context()->update_spill_remote_write_requests(requests);
+ }
+
ExecEnv::GetInstance()->spill_file_mgr()->update_spill_remote_write(uploaded_bytes,
+
data_requests);
+ }
+
+ if (!close_status.ok()) {
+ LOG(WARNING) << "failed to close spill part " << part.path << ": " <<
close_status;
+ _abort_multipart_upload(upload);
+ return close_status;
+ }
+ if (spill_file) {
+ spill_file->add_part(part.part_bytes);
+ }
return Status::OK();
}
+SpillFileWriter::MultipartUploadId
SpillFileWriter::_multipart_upload_id(io::FileWriter* writer) {
+ auto* s3_writer = dynamic_cast<io::S3FileWriter*>(writer);
+ if (s3_writer == nullptr || s3_writer->upload_id().empty()) {
+ return {};
+ }
+ return {.path = s3_writer->path().native(),
+ .bucket = s3_writer->bucket(),
+ .key = s3_writer->key(),
+ .upload_id = s3_writer->upload_id()};
+}
+
+void SpillFileWriter::_abort_multipart_upload(const MultipartUploadId& upload)
{
+ if (upload.upload_id.empty()) {
+ return;
+ }
+ auto s3_fs = std::dynamic_pointer_cast<io::S3FileSystem>(_data_dir->fs());
+ if (s3_fs == nullptr) {
+ return;
+ }
+ auto client = s3_fs->client_holder()->get();
+ if (client == nullptr) {
+ return;
+ }
+ auto resp = client->abort_multipart_upload(
Review Comment:
This one-shot best-effort abort is the only owner of the upload ID. If it
fails, or the BE exits before reaching it, the uploaded multipart parts are
invisible to `list()`/`delete_directory()`, while the new recycler deletes only
visible objects by prefix; Doris cannot reclaim that billed residue. Please
persist/retry upload IDs, teach the recycler to list and abort expired
multipart uploads, or require and verify a provider lifecycle rule; test abort
failure plus restart residue.
##########
be/src/exec/spill/spill_file_manager.cpp:
##########
@@ -308,6 +438,99 @@ void SpillFileManager::gc(int32_t max_work_time_ms) {
}
}
+void SpillFileManager::_remote_gc(SpillDataDir* store) {
+ if (!store->ready()) {
+ // Retry about once a minute at the default 2s GC interval.
ensure_ready() only reads
+ // what the vault refresh thread and the FE heartbeat already brought
in.
+ if (_remote_not_ready_rounds++ % 30 != 0) {
+ return;
+ }
+ auto st = store->ensure_ready();
+ if (!st.ok()) {
+ LOG(WARNING) << "remote spill store is not ready yet: " << st;
+ return;
+ }
+ }
+ _report_remote_spill_stats(store);
+ if (!remote_startup_cleanup_pending()) {
+ return;
+ }
+ auto st = _remote_startup_cleanup(store);
+ if (st.ok()) {
+ _remote_startup_cleanup_pending.store(false,
std::memory_order_release);
+ } else {
+ LOG_EVERY_T(WARNING, 60) << "failed to clean up spill objects of
previous boots, will "
+ "retry: "
+ << st;
+ }
+}
+
+void SpillFileManager::flush_remote_spill_stats() {
+ for (auto& [path, store] : _spill_store_map) {
+ if (store->is_remote()) {
+ _report_remote_spill_stats(store.get(), /*final_report=*/true);
+ }
+ }
+}
+
+void SpillFileManager::_report_remote_spill_stats(SpillDataDir* store, bool
final_report) {
+ std::lock_guard<std::mutex> lock(_remote_report_mutex);
+ // About once a minute at the default 2s GC interval; a final report skips
the cadence.
+ if (!final_report && _remote_report_rounds++ % 30 != 0) {
+ return;
+ }
+ if (!store->ready() || !config::is_cloud_mode()) {
+ return;
+ }
+ int64_t write_bytes = remote_write_bytes_since_boot();
+ int64_t put_requests = remote_put_requests_since_boot();
+ if (write_bytes == _reported_remote_write_bytes &&
+ put_requests == _reported_remote_put_requests) {
+ return;
+ }
+ auto st =
ExecEnv::GetInstance()->storage_engine().to_cloud().meta_mgr().report_spill_stats(
+ store->boot_id(), write_bytes, put_requests);
+ if (!st.ok()) {
+ LOG_EVERY_T(WARNING, 60) << "failed to report spill stats to
meta-service"
+ << (final_report ? "" : ", will retry") << ":
" << st;
+ return;
+ }
+ _reported_remote_write_bytes = write_bytes;
+ _reported_remote_put_requests = put_requests;
+}
+
+Status SpillFileManager::_remote_startup_cleanup(SpillDataDir* store) {
+ auto fs = store->fs();
+ const auto& be_root = store->get_remote_be_root();
+ const auto current_boot_id = std::to_string(store->boot_id());
+
+ MonotonicStopWatch watch;
+ watch.start();
+ std::vector<io::FileInfo> files;
+ bool exists = false;
+ RETURN_IF_ERROR(fs->list(be_root, true, &files, &exists));
Review Comment:
This eager recursive `list` materializes every object below the BE root
although cleanup needs only distinct first-level boot IDs.
`S3FileSystem::list_impl` first drains the paginated iterator into a complete
`ObjectMeta` vector and then builds a complete `FileInfo` vector, including
current-boot parts. After failed cleanup/restarts this can be arbitrarily
large; it ignores the GC time budget and blocks the sole spill-GC thread, which
`stop()` joins. Please use streaming/delimiter-based generation discovery and
bound deletion across GC rounds, with a multi-page residue test.
##########
be/src/exec/spill/spill_remote_upload_budget.cpp:
##########
@@ -0,0 +1,88 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+#include "exec/spill/spill_remote_upload_budget.h"
+
+#include <glog/logging.h>
+
+#include <chrono>
+
+#include "util/stopwatch.hpp"
+
+namespace doris {
+
+Status SpillRemoteUploadBudget::acquire(int64_t bytes, const
std::function<bool()>& is_cancelled,
+ int64_t* wait_ns) {
+ MonotonicStopWatch watch;
+ watch.start();
+ std::unique_lock<std::mutex> lock(_mutex);
+ while (_inflight_bytes > 0 && _inflight_bytes + bytes > _limit_bytes) {
+ if (is_cancelled && is_cancelled()) {
+ return Status::Cancelled("query cancelled while waiting for spill
upload budget");
+ }
+ _cv.wait_for(lock, std::chrono::milliseconds(100));
+ }
+ _inflight_bytes += bytes;
+ _total_acquired_bytes += bytes;
+ if (wait_ns != nullptr) {
+ *wait_ns = static_cast<int64_t>(watch.elapsed_time());
+ }
+ return Status::OK();
+}
+
+void SpillRemoteUploadBudget::release(int64_t bytes) {
+ {
+ std::lock_guard<std::mutex> lock(_mutex);
+ _inflight_bytes -= bytes;
+ _total_released_bytes += bytes;
+ DCHECK_GE(_inflight_bytes, 0) << "spill upload budget released more
than acquired";
Review Comment:
This is the defensive-continue pattern prohibited by the repository's
assert-correctness rule. In a release build `DCHECK_GE` disappears, so a double
release is silently clamped to zero while `total_released_bytes` remains
greater than `total_acquired_bytes`; the limiter then wakes waiters after
losing its core invariant. Please replace the debug check plus clamp with an
always-on `DORIS_CHECK_GE` (or established equivalent) and add an over-release
death/invariant test.
##########
be/src/cloud/cloud_meta_mgr.cpp:
##########
@@ -1893,7 +1900,30 @@ Status CloudMetaMgr::finish_restore_job(const int64_t
tablet_id, bool is_complet
});
}
-Status CloudMetaMgr::get_storage_vault_info(StorageVaultInfos* vault_infos,
bool* is_vault_mode) {
+Status CloudMetaMgr::report_spill_stats(int64_t boot_id, int64_t
remote_write_bytes,
+ int64_t remote_put_requests) {
+ ReportSpillStatsRequest req;
+ ReportSpillStatsResponse resp;
+ req.set_cloud_unique_id(config::cloud_unique_id);
+ auto* stats = req.mutable_stats();
+ stats->set_cloud_unique_id(config::cloud_unique_id);
Review Comment:
The reporter identity is reread from mutable `config::cloud_unique_id` on
every call, but boot ID and counters are cumulative for the unchanged process.
Dropping and re-adding the same live endpoint gives it a new FE-generated ID:
after reporting 100 under C1, its next cumulative report of 120 creates C2
while C1 remains, so `get_spill_stats` returns 220. Please keep an immutable
stats identity for the process generation while routing with the current outer
ID, or make generations independently idempotent in meta-service; add a
same-boot C1-to-C2 test.
##########
be/src/io/fs/s3_file_writer.cpp:
##########
@@ -93,13 +100,75 @@ S3FileWriter::~S3FileWriter() {
s3_file_being_written << -1;
}
+void S3FileWriter::_record_request(std::atomic<int64_t> RemoteWriteStats::*
counter,
+ int64_t elapsed_ns, bool ok, int64_t
uploaded_bytes) {
+ if (_remote_write_stats == nullptr) {
+ return;
+ }
+ ((*_remote_write_stats).*counter)++;
Review Comment:
This increments once per aggregate SDK call, not per object-store request
attempt. The AWS client uses `S3CustomRetryStrategy(max_s3_client_retry)`, so a
throttled `PutObject`/`UploadPart` that succeeds on its third attempt sends
three billable requests but contributes one here; an exhausted operation is
likewise under-counted. `_finish_part()` persists this value as the warehouse
PUT-request total. Please attribute initial and retry attempts at the SDK
request boundary, including terminal failures, and cover retry success and
exhaustion for both operations.
##########
be/src/exec/spill/spill_file_manager.cpp:
##########
@@ -123,6 +213,24 @@ void SpillFileManager::_spill_gc_thread_callback() {
for (auto& [path, dir] : _spill_store_map) {
static_cast<void>(dir->update_capacity());
}
+ // Both configs are mutable; observe changes without a restart.
+ int64_t budget_limit = config::spill_s3_max_inflight_upload_bytes;
Review Comment:
Startup treats `limit < 2 * s3_write_buffer_size` as fatal, but this runtime
path installs exactly that invalid value after only a warning; both operands
are mutable and the update can be persisted. `acquire()` then admits one
oversized buffer whenever inflight reaches zero, so the live setting can exceed
its documented bound, and the persisted configuration prevents the next
S3-spill restart. Please enforce the same cross-config invariant for dynamic
updates, including buffer-size changes, or make startup accept the documented
degraded mode; test live-update/restart parity.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]