Gabriel39 commented on code in PR #67053:
URL: https://github.com/apache/doris/pull/67053#discussion_r3849612984
##########
be/src/runtime/runtime_query_statistics_mgr.cpp:
##########
@@ -466,25 +498,79 @@ void
RuntimeQueryStatisticsMgr::report_runtime_query_statistics() {
}
}
- // 3 when query is finished and (last rpc is send success), remove
finished query statistics
+ // 3 release accepted/expired compact snapshots and shed finished
ResourceContexts.
if (fe_qs_map.empty()) {
return;
}
{
std::lock_guard<std::shared_mutex>
write_lock(_resource_contexts_map_lock);
- for (auto& [addr, qs_map] : fe_qs_map) {
- bool is_rpc_success = rpc_result[addr];
- for (auto& [query_id, qs] : qs_map) {
- auto& qs_status_pair = qs_status[query_id];
- bool is_query_finished = qs_status_pair.first;
- bool is_timeout_after_finish = qs_status_pair.second;
- if ((is_rpc_success && is_query_finished) ||
is_timeout_after_finish) {
- _resource_contexts_map.erase(query_id);
- }
+ for (const auto& [query_id, reported_statistics] :
reported_pending_statistics) {
+ auto iter = _pending_final_statistics.find(query_id);
+ if (iter == _pending_final_statistics.end() ||
+ iter->second.statistics != reported_statistics) {
+ continue;
+ }
+ const auto& report_result = rpc_results[iter->second.fe_addr];
+ int64_t elapsed_after_finish_ms = MonotonicMillis() -
iter->second.finish_time_ms;
+ if (should_remove_finished_query(report_result.response_received,
+ report_result.accepted,
elapsed_after_finish_ms,
+
config::query_statistics_reserve_timeout_ms,
+ report_result.retention_ms)) {
+ _pending_final_statistics.erase(iter);
}
}
+
+ for (const auto& [query_id, final_statistics] :
active_final_statistics) {
+ auto iter = _resource_contexts_map.find(query_id);
+ // A later group-commit generation may reuse the query ID while
this RPC is in flight;
+ // only compact or erase the exact ResourceContext that produced
this snapshot.
+ if (iter == _resource_contexts_map.end() ||
+ iter->second != qs_resource_contexts[query_id]) {
+ continue;
+ }
+ auto resource_ctx = iter->second;
+ const auto& fe_addr = resource_ctx->task_controller()->fe_addr();
+ const auto& report_result = rpc_results[fe_addr];
+ int64_t finish_time_ms =
resource_ctx->task_controller()->finish_time();
+ int64_t elapsed_after_finish_ms = MonotonicMillis() -
finish_time_ms;
+ FinalStatisticsAction action = final_statistics_action(
+ report_result.response_received, report_result.accepted,
+ elapsed_after_finish_ms,
config::query_statistics_reserve_timeout_ms,
+ report_result.retention_ms);
+ if (action == FinalStatisticsAction::COMPACT_RESOURCE_CONTEXT) {
+ _pending_final_statistics[query_id] = {fe_addr,
final_statistics, finish_time_ms};
+ }
+ if (action != FinalStatisticsAction::KEEP_RESOURCE_CONTEXT) {
+ _resource_contexts_map.erase(iter);
+ }
+ }
+ }
+}
+
+bool RuntimeQueryStatisticsMgr::should_remove_finished_query(bool
response_received, bool accepted,
+ int64_t
elapsed_after_finish_ms,
+ int64_t
local_retention_ms,
+ int64_t
fe_retention_ms) {
+ if (accepted) {
+ return true;
+ }
+ // Without an FE response the BE does not know the audit fallback window,
so timing out here
+ // could discard the only final snapshot before a lagging FE can accept a
retry.
+ return response_received &&
Review Comment:
Fixed in 7ef7cda086. Compact final snapshots now use a configurable finite
no-response ceiling (`query_statistics_max_retry_timeout_ms`, default 5
minutes). An FE response still supplies the authoritative audit-retention
window. The BE regression test covers both expiry with no response and
successful recovery/acknowledgement before the ceiling.
##########
fe/fe-core/src/main/java/org/apache/doris/qe/ConnectProcessor.java:
##########
@@ -831,6 +831,7 @@ public TMasterOpResult proxyExecute(TMasterOpRequest
request) throws TException
}
}
if (executor != null) {
+ result.setAuditBackendIds(executor.getAuditBackendIds());
Review Comment:
Fixed in 7ef7cda086. After `handleExecute`, the proxy result now reads the
COM_STMT_EXECUTE executor installed in the context, while non-prepared
forwarding keeps the original executor. `ConnectProcessorAuditExecutorTest`
covers both selections.
##########
fe/fe-core/src/main/java/org/apache/doris/qe/StmtExecutor.java:
##########
@@ -2284,6 +2285,14 @@ public Coordinator getCoord() {
return coord;
}
+ public Set<Long> getAuditBackendIds() {
+ if (coord != null) {
+ return ImmutableSet.copyOf(coord.getInvolvedBackendIds());
+ }
+ return masterOpExecutor == null
Review Comment:
Fixed in 7ef7cda086. Group commit now reserves the cached planner's actual
`loadId` and selected backend before dispatch. That identity is propagated
through `TMasterOpResult` and stored separately from the client audit query ID;
retry backends are unioned for the same identity. Tests cover two prepared
executions sharing the cached load ID and multiple selected backends.
##########
fe/fe-core/src/main/java/org/apache/doris/resource/workloadschedpolicy/WorkloadRuntimeStatusMgr.java:
##########
@@ -147,92 +240,398 @@ public void submitFinishQueryToAudit(AuditEvent event) {
// put the event to queryAuditEventList and let the worker
thread to handle it.
// the worker thread will try best to wait for the statistic
info before logging this event.
event.pushToAuditLogQueueTime = System.currentTimeMillis();
- queryAuditEventList.add(event);
+ PendingAuditEvent pending = new PendingAuditEvent(event,
expectedBackendIds);
+ // Register before publishing to the queue so cleanup cannot
remove a first report
+ // in the gap between queue insertion and participant
retention.
+ registerPendingAuditEvent(pending);
+ queryAuditEventList.add(pending);
}
} finally {
queryAuditEventLogWriteUnlock();
}
}
- private List<AuditEvent> getQueryNeedAudit() {
- List<AuditEvent> ret = new ArrayList<>();
+ @VisibleForTesting
+ List<AuditEvent> getQueryNeedAudit() {
+ RuntimeStatisticsSnapshot runtimeSnapshot =
buildRuntimeStatisticsSnapshot();
+ queryStatisticsSnapshot =
ImmutableMap.copyOf(runtimeSnapshot.queryStatistics);
+
long currentTime = System.currentTimeMillis();
+ int queryAuditLogTimeout = Config.query_audit_log_timeout_ms;
+ long maximumWaitMs = Math.max(queryAuditLogTimeout,
+ Config.be_report_query_statistics_timeout_ms);
+ List<PendingAuditEvent> dueEvents = new ArrayList<>();
+ // Keep only identity/timestamp traversal under the producer-facing
queue lock; participant
+ // lookup and statistics merging may touch every expected backend and
must run lock-free.
queryAuditEventLogWriteLock();
try {
- int queryAuditLogTimeout = Config.query_audit_log_timeout_ms;
- Iterator<AuditEvent> iter = queryAuditEventList.iterator();
- while (iter.hasNext()) {
- AuditEvent ae = iter.next();
- if (currentTime - ae.pushToAuditLogQueueTime >
queryAuditLogTimeout) {
- ret.add(ae);
- iter.remove();
- } else {
+ for (PendingAuditEvent pending : queryAuditEventList) {
+ long elapsed = currentTime -
pending.event.pushToAuditLogQueueTime;
+ if (elapsed <= queryAuditLogTimeout) {
+ // Events are appended in timestamp order, so later
entries cannot be due yet.
break;
}
+ dueEvents.add(pending);
+ }
+ } finally {
+ queryAuditEventLogWriteUnlock();
+ }
+
+ Map<PendingAuditEvent, TQueryStatistics> readyEvents = new
IdentityHashMap<>();
+ for (PendingAuditEvent pending : dueEvents) {
+ beforeHydrateAuditEvent(pending.event.queryId);
+ boolean allFinalSnapshotsReceived = true;
+ TQueryStatistics auditStatistics =
pending.expectedBackendIds.length == 0
+ ?
runtimeSnapshot.queryStatistics.get(pending.event.queryId)
+ : new TQueryStatistics();
+ for (int i = 0; i < pending.expectedBackendIds.length; i++) {
+ bindParticipantFromExistingReport(pending, i);
+ long backendStartTime =
pending.expectedBackendStartTimes.get(i);
+ TQueryStatisticsResult statistics = findStatisticsForBackend(
+ pending.event.queryId, pending.expectedBackendIds[i],
backendStartTime);
+ if (statistics == null) {
+ allFinalSnapshotsReceived = false;
+ continue;
+ }
+ mergeQueryStatistics(auditStatistics, statistics);
+ if (!statistics.isSetQueryFinished() ||
!statistics.isQueryFinished()) {
+ allFinalSnapshotsReceived = false;
+ }
+ }
+ long elapsed = currentTime - pending.event.pushToAuditLogQueueTime;
+ if (allFinalSnapshotsReceived || elapsed > maximumWaitMs) {
+ readyEvents.put(pending, auditStatistics);
+ }
+ }
+
+ List<PendingAuditEvent> removedEvents = new ArrayList<>();
+ queryAuditEventLogWriteLock();
+ try {
+ for (PendingAuditEvent pending : dueEvents) {
+ if (readyEvents.containsKey(pending) &&
queryAuditEventList.remove(pending)) {
+ removedEvents.add(pending);
+ }
}
} finally {
queryAuditEventLogWriteUnlock();
}
+
+ List<AuditEvent> ret = new ArrayList<>(removedEvents.size());
+ for (PendingAuditEvent pending : removedEvents) {
+ // A DML audit must not pass the queue until every scheduled BE
has published its
+ // query-level final snapshot. The upper bound only protects the
queue if a BE dies.
+ applyQueryStatisticsToAuditEvent(pending.event,
readyEvents.get(pending));
+ ret.add(pending.event);
+ unregisterPendingAuditEvent(pending);
+ }
return ret;
}
- public void updateBeQueryStats(TReportWorkloadRuntimeStatusParams params) {
+ public boolean updateBeQueryStats(TReportWorkloadRuntimeStatusParams
params) {
if (!params.isSetBackendId()) {
LOG.warn("be report workload runtime status but without beid");
- return;
+ return false;
}
if (!params.isSetQueryStatisticsResultMap()) {
LOG.warn("be report workload runtime status but without query
stats map");
- return;
+ return false;
+ }
+ if (!params.isSetBackendStartTime()) {
+ LOG.warn("be report workload runtime status without backend start
time");
+ return false;
}
long beId = params.backend_id;
- // NOTE(wb) one be sends update request one by one,
- // so there is no need a global lock for beToQueryStatsMap here,
- // just keep one be's put/remove/get is atomic operation is enough
- long currentTime = System.currentTimeMillis();
- BeReportInfo beReportInfo = beToQueryStatsMap.get(beId);
- if (beReportInfo == null) {
- beReportInfo = new BeReportInfo(currentTime);
- beToQueryStatsMap.put(beId, beReportInfo);
+ long backendStartTime = params.backend_start_time;
+ long currentBackendStartTime = getBackendStartTime(beId);
+ if (currentBackendStartTime > 0 && currentBackendStartTime !=
backendStartTime) {
+ LOG.info("ignore stale workload runtime status from backend {},
report start time {}, current {}",
+ beId, backendStartTime, currentBackendStartTime);
+ return false;
+ }
+ // Newly replayed backends use -1 as well as 0 for an unknown
heartbeat epoch. In either
+ // state the first accepted report must latch the only process allowed
to publish data.
+ if (currentBackendStartTime <= 0) {
+ Long acceptedStartTime =
lastAcceptedBackendStartTimes.putIfAbsent(beId, backendStartTime);
+ if (acceptedStartTime != null && acceptedStartTime !=
backendStartTime) {
+ return false;
+ }
} else {
- beReportInfo.beLastReportTime = currentTime;
+ lastAcceptedBackendStartTimes.put(beId, backendStartTime);
+ }
+ BackendIncarnation incarnation = new BackendIncarnation(beId,
backendStartTime);
+ beToQueryStatsMap.computeIfAbsent(incarnation, ignored -> new
BeReportInfo(backendStartTime));
+ long currentTime = System.currentTimeMillis();
+ for (Map.Entry<String, TQueryStatisticsResult> entry
+ : params.query_statistics_result_map.entrySet()) {
+ beforeUpdateQueryStatistics(entry.getKey());
+ while (true) {
+ BeReportInfo beReportInfo =
beToQueryStatsMap.computeIfAbsent(incarnation,
+ ignored -> new BeReportInfo(backendStartTime));
+ beReportInfo.lifecycleLock.readLock().lock();
+ try {
+ // Cleanup removes only an empty shell under the write
lock. If this insertion
+ // raced with removal, retry the single entry against the
replacement shell.
+ if (beToQueryStatsMap.get(incarnation) != beReportInfo) {
+ continue;
+ }
+ Pair<Long, TQueryStatisticsResult> incoming =
+ Pair.of(currentTime, entry.getValue());
+ beReportInfo.queryStatsMap.compute(entry.getKey(),
(queryId, previous) ->
+ previous == null
+ ||
shouldReplaceQueryStatistics(previous.second, incoming.second)
+ ? incoming : previous);
+ break;
+ } finally {
+ beReportInfo.lifecycleLock.readLock().unlock();
+ }
+ }
}
- for (Map.Entry<String, TQueryStatisticsResult> entry :
params.query_statistics_result_map.entrySet()) {
- beReportInfo.queryStatsMap.put(entry.getKey(),
Pair.of(currentTime, entry.getValue()));
+ // Heartbeat can change while a large report is being merged. Refuse
the acknowledgement
+ // so the BE retries against the FE state that is authoritative after
the merge.
+ long latestBackendStartTime = getBackendStartTime(beId);
+ boolean accepted = latestBackendStartTime <= 0 ||
latestBackendStartTime == backendStartTime;
+ if (accepted) {
+ beforeBindPendingAuditEvents();
+ bindPendingAuditEvents(beId, backendStartTime,
+ params.query_statistics_result_map.keySet());
}
+ return accepted;
}
- private void clearReportTimeoutBeStatistics() {
- // 1 clear report timeout be
- Set<Long> currentBeIdSet = beToQueryStatsMap.keySet();
+ private TQueryStatisticsResult findStatisticsForBackend(String queryId,
+ long backendId, long backendStartTime) {
+ if (backendStartTime <= 0) {
+ return null;
+ }
+ BeReportInfo reportInfo = beToQueryStatsMap.get(
+ new BackendIncarnation(backendId, backendStartTime));
+ if (reportInfo != null) {
+ Pair<Long, TQueryStatisticsResult> pair =
reportInfo.queryStatsMap.get(queryId);
+ return pair == null ? null : pair.second;
+ }
+ return null;
+ }
+
+ private void registerPendingAuditEvent(PendingAuditEvent pending) {
+ pendingAuditBindingLock.lock();
+ try {
+ if (!pending.active.get()) {
+ return;
+ }
+ for (int i = 0; i < pending.expectedBackendIds.length; i++) {
+ if (bindParticipantFromExistingReportLocked(pending, i)) {
+ continue;
+ }
+ BackendQuery key = new BackendQuery(
+ pending.expectedBackendIds[i], pending.event.queryId);
+ unboundAuditParticipants.computeIfAbsent(key, ignored -> new
ArrayList<>())
+ .add(new PendingAuditBinding(pending, i));
+ }
+ } finally {
+ pendingAuditBindingLock.unlock();
+ }
+ }
+
+ private void bindPendingAuditEvents(long backendId, long backendStartTime,
+ Set<String> reportedQueryIds) {
+ for (String queryId : reportedQueryIds) {
+ pendingAuditBindingLock.lock();
+ try {
+ BackendQuery key = new BackendQuery(backendId, queryId);
+ List<PendingAuditBinding> bindings =
unboundAuditParticipants.remove(key);
+ if (bindings == null) {
+ continue;
+ }
+ for (PendingAuditBinding binding : bindings) {
+ bindParticipantLocked(binding.pending,
binding.participantIndex,
+ backendStartTime);
+ }
+ } finally {
+ pendingAuditBindingLock.unlock();
+ }
+ }
+ }
+
+ private void bindParticipantFromExistingReport(PendingAuditEvent pending,
int participantIndex) {
+ if (pending.expectedBackendStartTimes.get(participantIndex) > 0) {
+ return;
+ }
+ pendingAuditBindingLock.lock();
+ try {
+ if (bindParticipantFromExistingReportLocked(pending,
participantIndex)) {
+ removeUnboundParticipantLocked(pending, participantIndex);
+ }
+ } finally {
+ pendingAuditBindingLock.unlock();
+ }
+ }
+
+ private boolean bindParticipantFromExistingReportLocked(
+ PendingAuditEvent pending, int participantIndex) {
+ if (!pending.active.get()) {
+ return false;
+ }
+ if (pending.expectedBackendStartTimes.get(participantIndex) > 0) {
+ return true;
+ }
+ long backendId = pending.expectedBackendIds[participantIndex];
+ long currentBackendStartTime = getBackendStartTime(backendId);
+ long reportStartTime = currentBackendStartTime > 0
+ ? currentBackendStartTime
+ : lastAcceptedBackendStartTimes.getOrDefault(backendId, 0L);
+ if (findStatisticsForBackend(pending.event.queryId, backendId,
reportStartTime) == null) {
+ return false;
+ }
+ bindParticipantLocked(pending, participantIndex, reportStartTime);
+ return true;
+ }
+
+ private void bindParticipantLocked(PendingAuditEvent pending, int
participantIndex,
+ long backendStartTime) {
+ if (!pending.active.get()
+ || !pending.expectedBackendStartTimes.compareAndSet(
+ participantIndex, 0, backendStartTime)) {
+ return;
+ }
+ // Retention is query-scoped so one queued audit cannot pin unrelated
completed queries
+ // from the same long-lived backend process.
+ RetainedQuery retainedQuery = new RetainedQuery(
+ new
BackendIncarnation(pending.expectedBackendIds[participantIndex],
+ backendStartTime), pending.event.queryId);
+ pendingQueryReferences.compute(retainedQuery,
+ (ignored, count) -> count == null ? 1 : count + 1);
+ }
+
+ private void unregisterPendingAuditEvent(PendingAuditEvent pending) {
+ pendingAuditBindingLock.lock();
+ try {
+ if (!pending.active.compareAndSet(true, false)) {
+ return;
+ }
+ for (int i = 0; i < pending.expectedBackendIds.length; i++) {
+ long backendStartTime =
pending.expectedBackendStartTimes.get(i);
+ if (backendStartTime > 0) {
+ RetainedQuery retainedQuery = new RetainedQuery(
+ new
BackendIncarnation(pending.expectedBackendIds[i], backendStartTime),
+ pending.event.queryId);
+ pendingQueryReferences.computeIfPresent(retainedQuery,
(ignored, count) ->
+ count == 1 ? null : count - 1);
+ } else {
+ removeUnboundParticipantLocked(pending, i);
+ }
+ }
+ } finally {
+ pendingAuditBindingLock.unlock();
+ }
+ }
+
+ private void removeUnboundParticipantLocked(PendingAuditEvent pending, int
participantIndex) {
+ BackendQuery key = new BackendQuery(
+ pending.expectedBackendIds[participantIndex],
pending.event.queryId);
+ List<PendingAuditBinding> bindings = unboundAuditParticipants.get(key);
+ if (bindings == null) {
+ return;
+ }
+ bindings.removeIf(binding -> binding.pending == pending
+ && binding.participantIndex == participantIndex);
+ if (bindings.isEmpty()) {
+ unboundAuditParticipants.remove(key);
+ }
+ }
+
+ @VisibleForTesting
+ void beforeHydrateAuditEvent(String queryId) {
+ }
+
+ @VisibleForTesting
+ void beforeBindPendingAuditEvents() {
+ }
+
+ private boolean isQueryRetainedForAudit(BackendIncarnation incarnation,
String queryId) {
+ RetainedQuery retainedQuery = new RetainedQuery(incarnation, queryId);
+ if (pendingQueryReferences.containsKey(retainedQuery)) {
+ return true;
+ }
+ pendingAuditBindingLock.lock();
+ try {
+ // Serialize cleanup with first-report binding. Until the
participant has an epoch,
+ // retaining only this backend/query candidate closes the
insert-to-bind race.
+ return pendingQueryReferences.containsKey(retainedQuery)
+ || unboundAuditParticipants.containsKey(
+ new BackendQuery(incarnation.backendId, queryId));
+ } finally {
+ pendingAuditBindingLock.unlock();
+ }
+ }
+
+ @VisibleForTesting
+ long getBackendStartTime(long backendId) {
+ Backend backend = Env.getCurrentSystemInfo().getBackend(backendId);
+ return backend == null ? 0 : backend.getLastStartTime();
+ }
+
+ @VisibleForTesting
+ void beforeUpdateQueryStatistics(String queryId) {
+ }
+
+ private boolean shouldReplaceQueryStatistics(TQueryStatisticsResult
previous,
+ TQueryStatisticsResult incoming) {
+ long previousGeneration = previous.isSetQueryStatisticsGeneration()
+ ? previous.getQueryStatisticsGeneration() : 0;
+ long incomingGeneration = incoming.isSetQueryStatisticsGeneration()
+ ? incoming.getQueryStatisticsGeneration() : 0;
+ if (previousGeneration != incomingGeneration) {
+ return incomingGeneration > previousGeneration;
+ }
+
+ long previousSequence = previous.isSetQueryStatisticsSequence()
+ ? previous.getQueryStatisticsSequence() : 0;
+ long incomingSequence = incoming.isSetQueryStatisticsSequence()
+ ? incoming.getQueryStatisticsSequence() : 0;
+ if (previousSequence != incomingSequence) {
+ return incomingSequence > previousSequence;
+ }
+
+ return false;
+ }
+
+ void clearReportTimeoutBeStatistics() {
+ Set<BackendIncarnation> currentBeIdSet = beToQueryStatsMap.keySet();
Long currentTime = System.currentTimeMillis();
- for (Long beId : currentBeIdSet) {
- BeReportInfo beReportInfo = beToQueryStatsMap.get(beId);
- if (currentTime - beReportInfo.beLastReportTime >
Config.be_report_query_statistics_timeout_ms) {
- beToQueryStatsMap.remove(beId);
+ for (BackendIncarnation incarnation : currentBeIdSet) {
+ BeReportInfo beReportInfo = beToQueryStatsMap.get(incarnation);
+ if (beReportInfo == null) {
continue;
}
Set<String> queryIdSet = beReportInfo.queryStatsMap.keySet();
for (String queryId : queryIdSet) {
- Pair<Long, TQueryStatisticsResult> pair =
beReportInfo.queryStatsMap.get(queryId);
- long queryLastReportTime = pair.first;
- boolean timeout = currentTime - queryLastReportTime
- > Config.be_report_query_statistics_timeout_ms;
- // Remove query statistics only when both conditions are
satisfied:
- // 1) this query statistics is timeout, and
- // 2) FE no longer has this query in QeProcessorImpl.
- // Example timeline:
- // - t0: query q1 is still running, but one periodic BE report
is delayed for > timeout.
- // - t1: clear thread runs. timeout condition is true, but q1
still exists in FE.
- // - t2: we keep q1 statistics instead of removing it; later
reports can update it again.
- if (timeout && isQueryNotExistInFe(queryId)) {
- beReportInfo.queryStatsMap.remove(queryId);
+ beforeExpireQueryStatistics(queryId);
+ beReportInfo.queryStatsMap.computeIfPresent(queryId,
(ignoredQueryId, pair) -> {
+ long queryLastReportTime = pair.first;
+ boolean timeout = currentTime - queryLastReportTime
+ > Config.be_report_query_statistics_timeout_ms;
+ // Conditional removal and updates serialize only for the
same query. Work on
+ // a high-cardinality report cannot delay another query's
terminal update.
+ return timeout && !isQueryRetainedForAudit(incarnation,
queryId)
Review Comment:
Fixed in 7ef7cda086 with an explicit ownership lifecycle. DML registers an
in-flight statistics owner before forwarding/dispatch or coordinator teardown,
and audit submission atomically replaces it with participant-scoped references
under the same binding lock. Error, debug, proxy, and disabled-audit paths
release ownership. A concurrent cleanup regression test verifies that an aged
accepted final survives the ownership transfer and hydrates the audit event.
--
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]