Summary
TStatsServiceActor removes per-volume metrics from the TDynamicCounters tree when a volume has neither clients nor checkpoints, but leaves volume.ServiceVolumeCounters non-null.
If the same volume later gets a client or checkpoint, RegisterServiceVolumeCounters() sees the non-null pointer and returns without attaching the subgroup back to the monitoring tree. PerfCounters.Publish() then continues updating detached counters that are no longer reachable from the monitoring root.
As a result, metrics with component=service_volume, host=cluster may disappear after a temporary period without clients and never return until the corresponding TVolumeStatsInfo is destroyed or the process/actor is restarted.
Affected code
cloud/blockstore/libs/storage/stats_service/stats_service_actor_solomon.cpp:
void TStatsServiceActor::RegisterServiceVolumeCounters(
const NMonitoring::TDynamicCounterPtr& counters,
TVolumeStatsInfo& volume)
{
// ...
if (volume.ServiceVolumeCounters) {
return;
}
auto head =
counters->GetSubgroup("counters", "blockstore")
->GetSubgroup("component", "service_volume")
->GetSubgroup("host", "cluster");
volume.ServiceVolumeCounters =
RegisterChain(head, BuildVolumeChain(volume.VolumeInfo));
volume.PerfCounters.Register(volume.ServiceVolumeCounters);
// NUserCounter::RegisterServiceVolume(...)
}
void TStatsServiceActor::UnregisterServiceVolumeCounters(
const NMonitoring::TDynamicCounterPtr& counters,
TVolumeStatsInfo& volume)
{
// ...
auto head =
counters->GetSubgroup("counters", "blockstore")
->GetSubgroup("component", "service_volume")
->GetSubgroup("host", "cluster");
head->RemoveSubgroupChain(BuildVolumeChain(volume.VolumeInfo));
NUserCounter::UnregisterServiceVolume(/* ... */);
// volume.ServiceVolumeCounters remains non-null.
}
The publication condition in UpdateVolumeSelfCounters() is:
const bool shouldPublishServiceVolumeCounters =
vol.PerfCounters.HasCheckpoint || vol.PerfCounters.HasClients;
if (shouldPublishServiceVolumeCounters) {
RegisterServiceVolumeCounters(AppData(ctx)->Counters, vol);
vol.PerfCounters.Publish(ctx.Now());
} else {
UnregisterServiceVolumeCounters(AppData(ctx)->Counters, vol);
}
Steps to reproduce
-
Register a volume.
-
Submit statistics with HasClients = true.
-
Run a counters update.
-
Verify that the following branch exists:
counters=blockstore
/ component=service_volume
/ host=cluster
/ volume=<disk-id>
/ cloud=<cloud-id>
/ folder=<folder-id>
-
Submit statistics with HasClients = false and HasCheckpoint = false.
-
Run a counters update. The volume branch is removed from the tree.
-
Submit statistics with HasClients = true again.
-
Run another counters update.
-
Check for the branch using FindSubgroup().
The metrics are expected to exist again at step 9. In practice, the branch is still missing.
Regression-test pseudocode for stats_service_ut.cpp:
RegisterVolume(runtime, DefaultDiskId);
BroadcastVolumeCounters(runtime, {0}, VOLUME_HASCLIENTS);
UNIT_ASSERT(VolumeMetricsExists(*runtime.GetAppData(0).Counters));
BroadcastVolumeCounters(runtime, {0}, 0);
UNIT_ASSERT(!VolumeMetricsExists(*runtime.GetAppData(0).Counters));
BroadcastVolumeCounters(runtime, {0}, VOLUME_HASCLIENTS);
UNIT_ASSERT(VolumeMetricsExists(*runtime.GetAppData(0).Counters)); // fails
The check must use FindSubgroup() rather than GetSubgroup(): GetSubgroup() creates a missing branch and may hide the bug.
Actual behavior
After the following transition:
HasClients/HasCheckpoint: true -> false -> true
the lifecycle is:
- On
true, a subgroup is created and assigned to volume.ServiceVolumeCounters.
- On
false, RemoveSubgroupChain() removes the subgroup from its parent TDynamicCounters map.
- The subgroup object itself remains alive because it is still referenced by:
volume.ServiceVolumeCounters;
- counters registered inside
volume.PerfCounters;
VolumeBindingCounter and other nested counter holders.
- On the next
true, RegisterServiceVolumeCounters() returns early because volume.ServiceVolumeCounters != nullptr.
PerfCounters.Publish() writes values to an object that is alive but detached from the monitoring root.
- The metrics remain absent from
/counters, Solomon, and any other consumer that traverses the tree from its root.
The current implementation therefore incorrectly uses a non-null ServiceVolumeCounters pointer as both:
- an owning reference to the subgroup;
- an indication that the subgroup is attached to the monitoring tree.
These two states diverge after RemoveSubgroupChain().
Additional scenario: cloud/folder update
The same problem occurs in HandleVolumeConfigUpdated():
- When
CloudId or FolderId changes, UnregisterServiceVolumeCounters() is called for the old path.
volume.VolumeInfo is replaced with the new configuration.
RegisterServiceVolumeCounters() is called for the new path.
- Registration is skipped because the old
volume.ServiceVolumeCounters pointer is still non-null.
Consequently:
- the old branch is removed;
- the new branch is not created;
NUserCounter::UnregisterServiceVolume() has already been called for the old labels;
NUserCounter::RegisterServiceVolume() is not called for the new labels because of the early return.
Expected behavior
- Whenever
HasClients || HasCheckpoint == true, the volume subgroup must be attached to AppData(ctx)->Counters at the current host=cluster/volume/cloud/folder path.
- Metrics must resume publication after a
true -> false -> true transition.
- After a
CloudId or FolderId update, the old branch must disappear and a new branch with the current labels must be created.
- User-counter registration must remain consistent with service-volume-counter registration.
- Repeated register/unregister operations must be idempotent.
Root cause
TDynamicCounters::RemoveSubgroupChain() removes references to a subgroup from the parent tree but does not invalidate external intrusive pointers. This is valid container behavior, but the current ServiceVolumeCounters lifecycle does not account for it.
UnregisterServiceVolumeCounters() does not change any state that would allow a subsequent registration attempt to determine that the retained subgroup is no longer attached to the monitoring root.
Impact
- Loss of per-volume performance metrics after an unmount/remount cycle that does not destroy
TVolumeStatsInfo.
- Loss of metrics after a volume's
cloud or folder changes.
PerfCounters.Publish() continues doing work but updates invisible counters.
- User counters may remain unregistered after clients or a checkpoint reappear.
- The defect is easy to miss in tests that use
GetSubgroup(), because that method creates missing groups.
Important note for fix
Simply adding the following assignment is insufficient for expiring scalar counters that have already been materialized:
volume.ServiceVolumeCounters = nullptr;
TDiskPerfData::Register() registers DiskCounters and VolumeSelfCounters against the new subgroup. Permanent TSolomonValueHolder instances are rebound because Init() replaces their SolomonValue. For an expiring holder, however, Init() only updates Parent, CounterName, and Derivative; it does not clear an existing SolomonValue.
For example, IORequestsQueued is a service_volume counter with ECounterExpirationPolicy::Expiring. If it was materialized before the old subgroup was detached, re-registering TDiskPerfData leaves its SolomonValue pointing to the detached counter. Subsequent publications continue updating that invisible counter instead of creating one under the new subgroup. If the value remains positive, LastNonZeroValueTs is refreshed on every publication, so the stale pointer may be retained indefinitely.
The same applies to other expiring service_volume counters, including UsedBlocksMapMemSize, LongRunningReadBlob, and LongRunningWriteBlob.
By contrast, the histogram and request-counter registration paths replace their stored pointers during Register()/ForceRegister(). They should still be covered by regression tests, but the stale-pointer behavior is specifically confirmed for expiring TSolomonValueHolder counters.
Summary
TStatsServiceActorremoves per-volume metrics from theTDynamicCounterstree when a volume has neither clients nor checkpoints, but leavesvolume.ServiceVolumeCountersnon-null.If the same volume later gets a client or checkpoint,
RegisterServiceVolumeCounters()sees the non-null pointer and returns without attaching the subgroup back to the monitoring tree.PerfCounters.Publish()then continues updating detached counters that are no longer reachable from the monitoring root.As a result, metrics with
component=service_volume, host=clustermay disappear after a temporary period without clients and never return until the correspondingTVolumeStatsInfois destroyed or the process/actor is restarted.Affected code
cloud/blockstore/libs/storage/stats_service/stats_service_actor_solomon.cpp:The publication condition in
UpdateVolumeSelfCounters()is:Steps to reproduce
Register a volume.
Submit statistics with
HasClients = true.Run a counters update.
Verify that the following branch exists:
Submit statistics with
HasClients = falseandHasCheckpoint = false.Run a counters update. The volume branch is removed from the tree.
Submit statistics with
HasClients = trueagain.Run another counters update.
Check for the branch using
FindSubgroup().The metrics are expected to exist again at step 9. In practice, the branch is still missing.
Regression-test pseudocode for
stats_service_ut.cpp:The check must use
FindSubgroup()rather thanGetSubgroup():GetSubgroup()creates a missing branch and may hide the bug.Actual behavior
After the following transition:
the lifecycle is:
true, a subgroup is created and assigned tovolume.ServiceVolumeCounters.false,RemoveSubgroupChain()removes the subgroup from its parentTDynamicCountersmap.volume.ServiceVolumeCounters;volume.PerfCounters;VolumeBindingCounterand other nested counter holders.true,RegisterServiceVolumeCounters()returns early becausevolume.ServiceVolumeCounters != nullptr.PerfCounters.Publish()writes values to an object that is alive but detached from the monitoring root./counters, Solomon, and any other consumer that traverses the tree from its root.The current implementation therefore incorrectly uses a non-null
ServiceVolumeCounterspointer as both:These two states diverge after
RemoveSubgroupChain().Additional scenario: cloud/folder update
The same problem occurs in
HandleVolumeConfigUpdated():CloudIdorFolderIdchanges,UnregisterServiceVolumeCounters()is called for the old path.volume.VolumeInfois replaced with the new configuration.RegisterServiceVolumeCounters()is called for the new path.volume.ServiceVolumeCounterspointer is still non-null.Consequently:
NUserCounter::UnregisterServiceVolume()has already been called for the old labels;NUserCounter::RegisterServiceVolume()is not called for the new labels because of the early return.Expected behavior
HasClients || HasCheckpoint == true, the volume subgroup must be attached toAppData(ctx)->Countersat the currenthost=cluster/volume/cloud/folderpath.true -> false -> truetransition.CloudIdorFolderIdupdate, the old branch must disappear and a new branch with the current labels must be created.Root cause
TDynamicCounters::RemoveSubgroupChain()removes references to a subgroup from the parent tree but does not invalidate external intrusive pointers. This is valid container behavior, but the currentServiceVolumeCounterslifecycle does not account for it.UnregisterServiceVolumeCounters()does not change any state that would allow a subsequent registration attempt to determine that the retained subgroup is no longer attached to the monitoring root.Impact
TVolumeStatsInfo.cloudorfolderchanges.PerfCounters.Publish()continues doing work but updates invisible counters.GetSubgroup(), because that method creates missing groups.Important note for fix
Simply adding the following assignment is insufficient for expiring scalar counters that have already been materialized:
volume.ServiceVolumeCounters = nullptr;TDiskPerfData::Register()registersDiskCountersandVolumeSelfCountersagainst the new subgroup. PermanentTSolomonValueHolderinstances are rebound becauseInit()replaces theirSolomonValue. For an expiring holder, however,Init()only updatesParent,CounterName, andDerivative; it does not clear an existingSolomonValue.For example,
IORequestsQueuedis aservice_volumecounter withECounterExpirationPolicy::Expiring. If it was materialized before the old subgroup was detached, re-registeringTDiskPerfDataleaves itsSolomonValuepointing to the detached counter. Subsequent publications continue updating that invisible counter instead of creating one under the new subgroup. If the value remains positive,LastNonZeroValueTsis refreshed on every publication, so the stale pointer may be retained indefinitely.The same applies to other expiring
service_volumecounters, includingUsedBlocksMapMemSize,LongRunningReadBlob, andLongRunningWriteBlob.By contrast, the histogram and request-counter registration paths replace their stored pointers during
Register()/ForceRegister(). They should still be covered by regression tests, but the stale-pointer behavior is specifically confirmed for expiringTSolomonValueHoldercounters.