Copilot commented on code in PR #13743:
URL: https://github.com/apache/cloudstack/pull/13743#discussion_r4444853897


##########
server/src/main/java/com/cloud/storage/snapshot/SnapshotSchedulerImpl.java:
##########
@@ -237,58 +295,21 @@ protected void scheduleSnapshots() {
 
         for (final SnapshotScheduleVO snapshotToBeExecuted : 
snapshotsToBeExecuted) {
             SnapshotScheduleVO tmpSnapshotScheduleVO = null;
+            Long eventId = null;
             final long snapshotScheId = snapshotToBeExecuted.getId();
-            final long policyId = snapshotToBeExecuted.getPolicyId();
-            final long volumeId = snapshotToBeExecuted.getVolumeId();
             final VolumeVO volume = 
_volsDao.findByIdIncludingRemoved(snapshotToBeExecuted.getVolumeId());
             try {
-                if (!canSnapshotBeScheduled(snapshotToBeExecuted, volume)) {
+                if (shouldSkipSchedule(snapshotToBeExecuted, volume)) {
                     continue;
                 }
 
                 tmpSnapshotScheduleVO = 
_snapshotScheduleDao.acquireInLockTable(snapshotScheId);
-                final Long eventId =
-                    ActionEventUtils.onScheduledActionEvent(User.UID_SYSTEM, 
volume.getAccountId(), EventTypes.EVENT_SNAPSHOT_CREATE, "creating snapshot for 
volume Id:" +
-                        volume.getUuid(), volumeId, 
ApiCommandResourceType.Volume.toString(), true, 0);
-
-                logger.trace("Mapping parameters required to generate a 
CreateSnapshotCmd for snapshot [{}].", snapshotToBeExecuted);
-                final Map<String, String> params = new HashMap<String, 
String>();
-                params.put(ApiConstants.VOLUME_ID, "" + volumeId);
-                params.put(ApiConstants.POLICY_ID, "" + policyId);
-                params.put("ctxUserId", "1");
-                params.put("ctxAccountId", "" + volume.getAccountId());
-                params.put("ctxStartEventId", String.valueOf(eventId));
-                List<? extends ResourceTag> resourceTags = 
taggedResourceService.listByResourceTypeAndId(ResourceTag.ResourceObjectType.SnapshotPolicy,
 policyId);
-                if (resourceTags != null && !resourceTags.isEmpty()) {
-                    int tagNumber = 0;
-                    for (ResourceTag resourceTag : resourceTags) {
-                        params.put("tags[" + tagNumber + "].key", 
resourceTag.getKey());
-                        params.put("tags[" + tagNumber + "].value", 
resourceTag.getValue());
-                        tagNumber++;
-                    }
-                }
-
-                logger.trace("Generating a CreateSnapshotCmd for snapshot [{}] 
with parameters: [{}].", snapshotToBeExecuted, params.toString());
-                final CreateSnapshotCmd cmd = new CreateSnapshotCmd();
-                ComponentContext.inject(cmd);
-                _dispatcher.dispatchCreateCmd(cmd, params);
-                params.put("id", "" + cmd.getEntityId());
-                params.put("ctxStartEventId", "1");
-
-                final Date scheduledTimestamp = 
snapshotToBeExecuted.getScheduledTimestamp();
-                displayTime = 
DateUtil.displayDateInTimezone(DateUtil.GMT_TIMEZONE, scheduledTimestamp);
-                logger.debug("Scheduling snapshot [{}] for volume [{}] at 
[{}].", snapshotToBeExecuted, volume, displayTime);
-                AsyncJobVO job = new AsyncJobVO("", User.UID_SYSTEM, 
volume.getAccountId(), CreateSnapshotCmd.class.getName(),
-                        ApiGsonHelper.getBuilder().create().toJson(params), 
cmd.getEntityId(),
-                        cmd.getApiResourceType() != null ? 
cmd.getApiResourceType().toString() : null, null);
-                job.setDispatcher(_asyncDispatcher.getName());
-                final long jobId = _asyncMgr.submitAsyncJob(job);
-                logger.debug("Scheduled snapshot [{}] for volume [{}] as job 
[{}].", snapshotToBeExecuted, volume, job);
-
-                tmpSnapshotScheduleVO.setAsyncJobId(jobId);
-                _snapshotScheduleDao.update(snapshotScheId, 
tmpSnapshotScheduleVO);
+                eventId = dispatchSnapshotCreateJob(snapshotToBeExecuted, 
volume, tmpSnapshotScheduleVO);
             } catch (final Exception e) {
                 logger.error("The scheduling of snapshot [{}] for volume [{}] 
failed due to [{}].", snapshotToBeExecuted, volume, e.toString(), e);
+                if (tmpSnapshotScheduleVO != null) {
+                    handleFailedSnapshotDispatch(snapshotToBeExecuted, volume, 
tmpSnapshotScheduleVO, eventId, e);
+                }
             } finally {

Review Comment:
   If `dispatchSnapshotCreateJob` throws after it has already created the 
“scheduled” action event (but before returning), `eventId` will still be `null` 
here and `handleFailedSnapshotDispatch` won’t complete the scheduled event, 
leaving it dangling. A concrete fix is to ensure the scheduled event id is 
assigned in the caller before subsequent dispatch steps can throw (e.g., split 
“create scheduled event” from the rest, or have `dispatchSnapshotCreateJob` 
catch/rewrap exceptions with the `eventId` so the catch block can always call 
`onCompletedActionEvent` when applicable).



##########
engine/schema/src/main/java/com/cloud/event/dao/EventDaoImpl.java:
##########
@@ -125,6 +137,20 @@ public List<EventVO> 
listToArchiveOrDeleteEvents(List<Long> ids, String type, Da
         return search(sc, null);
     }
 
+    @Override
+    public List<EventVO> listLatestOutcomeEventsByResource(long resourceId, 
String resourceType, String type, int limit) {
+        SearchCriteria<EventVO> sc = LatestEventsByResourceSearch.create();
+        sc.setParameters(FIELD_RESOURCE_ID, resourceId);
+        sc.setParameters(FIELD_RESOURCE_TYPE, resourceType);
+        sc.setParameters("type", type);
+        sc.setParameters("state", State.Created, State.Completed);
+        sc.setParameters("archived", false);
+        Filter filter = new Filter(EventVO.class, "createDate", false, 0L, 
(long) limit);

Review Comment:
   The ordering field `"createDate"` looks inconsistent with the comment 
(“created”) and the expected SQL in `EventDaoImplTest` (which asserts `ORDER BY 
event.created ...`). If the mapped field is actually `created`, this will 
silently sort incorrectly (or not at all) depending on ORM mapping. Prefer 
ordering by the same property used elsewhere (likely `"created"`) and keep the 
secondary `id` ordering for tie-breaking.



##########
server/src/main/java/com/cloud/storage/snapshot/SnapshotSchedulerImpl.java:
##########
@@ -297,6 +318,225 @@ protected void scheduleSnapshots() {
         }
     }
 
+    /**
+     * Returns true (after rescheduling as needed) when this iteration's 
snapshot should not be dispatched: either
+     * because it can't be scheduled at all, or because it's a redundant 
snapshot of an unchanged volume that gets
+     * skipped and rescheduled to its next regular run instead. Kept as a 
single decision point so the caller only
+     * needs one {@code continue}.
+     */
+    protected boolean shouldSkipSchedule(final SnapshotScheduleVO 
snapshotToBeExecuted, final VolumeVO volume) {
+        if (!canSnapshotBeScheduled(snapshotToBeExecuted, volume)) {
+            return true;
+        }
+        if (shouldSkipUnchangedVolumeSnapshot(volume)) {
+            skipAndRescheduleSnapshot(snapshotToBeExecuted, volume);
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Builds and dispatches the CreateSnapshotCmd async job for a scheduled 
snapshot, returning the "scheduled"
+     * action event id so the caller can complete it if dispatch subsequently 
fails.
+     */
+    protected Long dispatchSnapshotCreateJob(final SnapshotScheduleVO 
snapshotToBeExecuted, final VolumeVO volume, final SnapshotScheduleVO 
tmpSnapshotScheduleVO) throws Exception {
+        final long snapshotScheId = snapshotToBeExecuted.getId();
+        final long policyId = snapshotToBeExecuted.getPolicyId();
+        final long volumeId = snapshotToBeExecuted.getVolumeId();
+
+        final Long eventId =
+            ActionEventUtils.onScheduledActionEvent(User.UID_SYSTEM, 
volume.getAccountId(), EventTypes.EVENT_SNAPSHOT_CREATE, "creating snapshot for 
volume Id:" +
+                volume.getUuid(), volumeId, 
ApiCommandResourceType.Volume.toString(), true, 0);
+
+        logger.trace("Mapping parameters required to generate a 
CreateSnapshotCmd for snapshot [{}].", snapshotToBeExecuted);
+        final Map<String, String> params = new HashMap<String, String>();
+        params.put(ApiConstants.VOLUME_ID, "" + volumeId);
+        params.put(ApiConstants.POLICY_ID, "" + policyId);
+        params.put("ctxUserId", "1");
+        params.put("ctxAccountId", "" + volume.getAccountId());
+        params.put("ctxStartEventId", String.valueOf(eventId));
+        List<? extends ResourceTag> resourceTags = 
taggedResourceService.listByResourceTypeAndId(ResourceTag.ResourceObjectType.SnapshotPolicy,
 policyId);
+        if (resourceTags != null && !resourceTags.isEmpty()) {
+            int tagNumber = 0;
+            for (ResourceTag resourceTag : resourceTags) {
+                params.put("tags[" + tagNumber + "].key", 
resourceTag.getKey());
+                params.put("tags[" + tagNumber + "].value", 
resourceTag.getValue());
+                tagNumber++;
+            }
+        }
+
+        logger.trace("Generating a CreateSnapshotCmd for snapshot [{}] with 
parameters: [{}].", snapshotToBeExecuted, params.toString());
+        final CreateSnapshotCmd cmd = new CreateSnapshotCmd();
+        ComponentContext.inject(cmd);
+        _dispatcher.dispatchCreateCmd(cmd, params);
+        params.put("id", "" + cmd.getEntityId());
+        params.put("ctxStartEventId", "1");
+
+        final Date scheduledTimestamp = 
snapshotToBeExecuted.getScheduledTimestamp();
+        final String displayTime = 
DateUtil.displayDateInTimezone(DateUtil.GMT_TIMEZONE, scheduledTimestamp);
+        logger.debug("Scheduling snapshot [{}] for volume [{}] at [{}].", 
snapshotToBeExecuted, volume, displayTime);
+        AsyncJobVO job = new AsyncJobVO("", User.UID_SYSTEM, 
volume.getAccountId(), CreateSnapshotCmd.class.getName(),
+                ApiGsonHelper.getBuilder().create().toJson(params), 
cmd.getEntityId(),
+                cmd.getApiResourceType() != null ? 
cmd.getApiResourceType().toString() : null, null);
+        job.setDispatcher(_asyncDispatcher.getName());
+        final long jobId = _asyncMgr.submitAsyncJob(job);
+        logger.debug("Scheduled snapshot [{}] for volume [{}] as job [{}].", 
snapshotToBeExecuted, volume, job);
+
+        tmpSnapshotScheduleVO.setAsyncJobId(jobId);
+        _snapshotScheduleDao.update(snapshotScheId, tmpSnapshotScheduleVO);
+
+        return eventId;
+    }
+
+    /**
+     * Handles a synchronous failure to dispatch the CreateSnapshotCmd (e.g. 
an allocation error) for a recurring
+     * snapshot. Without this, the schedule's {@code scheduledTimestamp} is 
never advanced, so it gets retried on
+     * every poll (every {@code snapshot.poll.interval} seconds) forever. 
Instead: log a failure event keyed by the
+     * volume (so consecutive failures can be counted from event history), and 
either back off by the configured
+     * retry interval, or - once the configured maximum consecutive failures 
is reached - give up until the next
+     * regularly scheduled run and raise a WARN notification event.
+     */
+    protected void handleFailedSnapshotDispatch(final SnapshotScheduleVO 
snapshotToBeExecuted, final VolumeVO volume,
+            final SnapshotScheduleVO lockedSchedule, final Long eventId, final 
Exception cause) {
+        final long volumeId = volume.getId();
+        final Account account = _acctDao.findById(volume.getAccountId());
+        final int maxFailures = 
getScopedConfigValue(SnapshotManager.SnapshotRecurringMaxFailures, volume, 
account);
+        final int retryInterval = 
getScopedConfigValue(SnapshotManager.SnapshotRecurringRetryInterval, volume, 
account);
+        final int totalFailures = countConsecutiveFailedAttempts(volumeId, 
maxFailures) + 1;
+
+        final String failureMessage = String.format("Failed to create 
scheduled snapshot for volume [%s]: %s", volume, cause.getMessage());
+        if (eventId != null) {
+            ActionEventUtils.onCompletedActionEvent(User.UID_SYSTEM, 
volume.getAccountId(), EventVO.LEVEL_ERROR,
+                    EventTypes.EVENT_SNAPSHOT_CREATE, failureMessage, 
volumeId, ApiCommandResourceType.Volume.toString(), eventId);
+        } else {
+            ActionEventUtils.onCreatedActionEvent(User.UID_SYSTEM, 
volume.getAccountId(), EventVO.LEVEL_ERROR,
+                    EventTypes.EVENT_SNAPSHOT_CREATE, true, failureMessage, 
volumeId, ApiCommandResourceType.Volume.toString());
+        }
+
+        if (maxFailures > 0 && totalFailures >= maxFailures) {
+            final Date nextRegularRun = 
getNextScheduledTime(snapshotToBeExecuted.getPolicyId(), _currentTimestamp);
+            lockedSchedule.setScheduledTimestamp(nextRegularRun);
+            logger.warn("Snapshot schedule [{}] for volume [{}] has failed 
[{}] consecutive times; it will not be retried until its next regularly 
scheduled run at [{}].",
+                    snapshotToBeExecuted, volume, totalFailures, 
nextRegularRun);
+            raiseFailureLimitReachedEvent(volume, String.format(
+                    "Recurring snapshot for volume [%s] has failed %d 
consecutive times and will not be retried until its next regularly scheduled 
run.", volume, totalFailures));
+        } else {
+            final Date nextRetry = new Date(_currentTimestamp.getTime() + 
retryInterval * 1000L);
+            lockedSchedule.setScheduledTimestamp(nextRetry);
+            logger.debug("Snapshot schedule [{}] for volume [{}] failed [{}] 
time(s); retrying at [{}].",
+                    snapshotToBeExecuted, volume, totalFailures, nextRetry);
+        }
+        _snapshotScheduleDao.update(lockedSchedule.getId(), lockedSchedule);
+    }
+
+    /**
+     * Counts how many of the most recent {@code EVENT_SNAPSHOT_CREATE} 
outcome events logged for this volume are
+     * consecutive failures (level ERROR), starting from the most recent event 
and stopping at the first
+     * non-failure (or absent) event. The Scheduled event logged before every 
dispatch is not an outcome and is
+     * excluded by the DAO, otherwise it would interrupt every run of 
failures. This derives the "number of failed attempts" from event history 
instead of a
+     * dedicated counter column.
+     */
+    protected int countConsecutiveFailedAttempts(final long volumeId, final 
int limit) {
+        if (limit <= 0) {
+            return 0;
+        }
+        final List<EventVO> recentEvents = 
eventDao.listLatestOutcomeEventsByResource(volumeId, 
ApiCommandResourceType.Volume.toString(),
+                EventTypes.EVENT_SNAPSHOT_CREATE, limit);
+        int count = 0;
+        for (final EventVO event : recentEvents) {
+            if (!EventVO.LEVEL_ERROR.equals(event.getLevel())) {
+                break;
+            }
+            count++;
+        }
+        return count;
+    }
+
+    /**
+     * Resolves a config value in order of most to least specific scope: 
account, domain, zone, then global. A
+     * {@link ConfigKey} can only walk a single scope-parent chain 
automatically (Account-&gt;Domain-&gt;Global, or
+     * Zone-&gt;Global), so the four scopes are resolved manually here.
+     */
+    protected <T> T getScopedConfigValue(final ConfigKey<T> key, final 
VolumeVO volume, final Account account) {
+        T value = key.valueInScope(ConfigKey.Scope.Account, 
volume.getAccountId(), true);
+        if (value == null && account != null) {
+            value = key.valueInScope(ConfigKey.Scope.Domain, 
account.getDomainId(), true);
+        }
+        if (value == null) {
+            value = key.valueInScope(ConfigKey.Scope.Zone, 
volume.getDataCenterId(), true);
+        }
+        if (value == null) {
+            value = key.value();
+        }
+        return value;
+    }

Review Comment:
   Using `valueInScope(..., inherit=true)` for Account/Domain can return a 
Global value before the Zone scope is checked, which makes Zone overrides 
unreachable when Account/Domain aren’t set. To preserve the intended precedence 
(Account → Domain → Zone → Global), query each scope with `inherit=false` and 
only fall back to `key.value()` at the end (or otherwise ensure Zone is 
consulted before any implicit Global fallback is applied).



##########
api/src/main/java/com/cloud/event/EventTypes.java:
##########
@@ -1094,6 +1096,8 @@ public class EventTypes {
 
         // Snapshots
         entityEventDetails.put(EVENT_SNAPSHOT_CREATE, Snapshot.class);
+        entityEventDetails.put(EVENT_SNAPSHOT_SKIPPED, Snapshot.class);
+        entityEventDetails.put(EVENT_SNAPSHOT_RECURRING_FAILURE_LIMIT_REACHED, 
Snapshot.class);

Review Comment:
   The new events (`SNAPSHOT.SKIPPED`, 
`SNAPSHOT.RECURRING.FAILURE.LIMIT.REACHED`) are logged against the Volume 
resource in `SnapshotSchedulerImpl` (resource id = volume id, resource type = 
Volume). Mapping them to `Snapshot.class` risks incorrect entity lookups 
(volumeId ≠ snapshotId) in any code that relies on `entityEventDetails`. 
Consider mapping these new event types to `Volume.class` (or aligning the 
logged resource type/id with Snapshot if that’s required by the event system).



##########
engine/schema/src/test/java/com/cloud/event/dao/EventDaoImplTest.java:
##########
@@ -0,0 +1,85 @@
+// 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.
+package com.cloud.event.dao;
+
+import com.cloud.event.Event;
+import com.cloud.event.EventVO;
+import com.cloud.utils.db.Filter;
+import com.cloud.utils.db.SearchCriteria;
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Spy;
+import org.mockito.junit.MockitoJUnitRunner;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.verify;
+
+@RunWith(MockitoJUnitRunner.class)
+public class EventDaoImplTest {
+
+    @Spy
+    @InjectMocks
+    EventDaoImpl eventDaoImpl = new EventDaoImpl();
+
+    @Test
+    public void 
listLatestOutcomeEventsByResourceQueriesOutcomesByResourceAndTypeExcludingArchived()
 {
+        List<EventVO> expected = Collections.singletonList(new EventVO());
+        
doReturn(expected).when(eventDaoImpl).listBy(any(SearchCriteria.class), 
any(Filter.class));
+
+        List<EventVO> result = 
eventDaoImpl.listLatestOutcomeEventsByResource(1L, "Volume", "SNAPSHOT.CREATE", 
3);
+
+        Assert.assertSame(expected, result);
+
+        ArgumentCaptor<SearchCriteria> scCaptor = 
ArgumentCaptor.forClass(SearchCriteria.class);
+        ArgumentCaptor<Filter> filterCaptor = 
ArgumentCaptor.forClass(Filter.class);
+        verify(eventDaoImpl).listBy(scCaptor.capture(), 
filterCaptor.capture());
+
+        SearchCriteria<EventVO> sc = scCaptor.getValue();
+        Assert.assertEquals("event.resource_id = ?  AND event.resource_type = 
?  AND event.type = ?  AND event.state IN (?,?)  AND event.archived = ? ",
+                sc.getWhereClause());

Review Comment:
   This assertion is brittle because it depends on the exact formatting/order 
of the generated where-clause string (which can change with minor 
DAO/SearchBuilder changes). It would be more robust to assert on the captured 
parameters (which you already do) and only minimally validate the presence of 
key predicates rather than exact full-string equality.



##########
server/src/test/java/com/cloud/storage/snapshot/SnapshotSchedulerImplTest.java:
##########
@@ -202,76 +253,458 @@ public void 
canSnapshotBeScheduledTestAccountIsRemovedOrDisabledReturnFalse() {
     public void canSnapshotBeScheduledTestSnapshotPolicyIsRemovedCallRemove() {
         SnapshotScheduleVO snapshotScheduleVO = new SnapshotScheduleVO();
 
-        Mockito.doReturn(1l).when(volumeVoMock).getPoolId();
-        
Mockito.doReturn(false).when(snapshotSchedulerImplSpy).isAccountRemovedOrDisabled(Mockito.any(),
 Mockito.any());
-        
Mockito.doReturn(null).when(snapshotPolicyDaoMock).findById(Mockito.any());
+        doReturn(1l).when(volumeVoMock).getPoolId();
+        
doReturn(false).when(snapshotSchedulerImplSpy).isAccountRemovedOrDisabled(Mockito.any(),
 Mockito.any());
+        doReturn(null).when(snapshotPolicyDaoMock).findById(Mockito.any());
 
         boolean result = 
snapshotSchedulerImplSpy.canSnapshotBeScheduled(snapshotScheduleVO, 
volumeVoMock);
 
         Assert.assertTrue(result);
 
-        Mockito.verify(snapshotScheduleDaoMock).remove(Mockito.anyLong());
+        verify(snapshotScheduleDaoMock).remove(Mockito.anyLong());
     }
 
     @Test
     public void 
canSnapshotBeScheduledTestSnapshotPolicyIsNotRemovedDoNotCallRemove() {
         SnapshotScheduleVO snapshotScheduleVO = new SnapshotScheduleVO();
         SnapshotPolicyVO snapshotPolicyVO = new SnapshotPolicyVO();
 
-        Mockito.doReturn(1l).when(volumeVoMock).getPoolId();
-        
Mockito.doReturn(false).when(snapshotSchedulerImplSpy).isAccountRemovedOrDisabled(Mockito.any(),
 Mockito.any());
-        
Mockito.doReturn(snapshotPolicyVO).when(snapshotPolicyDaoMock).findById(Mockito.any());
+        doReturn(1l).when(volumeVoMock).getPoolId();
+        
doReturn(false).when(snapshotSchedulerImplSpy).isAccountRemovedOrDisabled(Mockito.any(),
 Mockito.any());
+        
doReturn(snapshotPolicyVO).when(snapshotPolicyDaoMock).findById(Mockito.any());
 
         boolean result = 
snapshotSchedulerImplSpy.canSnapshotBeScheduled(snapshotScheduleVO, 
volumeVoMock);
 
         Assert.assertTrue(result);
 
-        Mockito.verify(snapshotScheduleDaoMock, 
Mockito.never()).remove(Mockito.anyLong());
+        verify(snapshotScheduleDaoMock, never()).remove(Mockito.anyLong());
     }
 
     @Test
     public void 
scheduleNextSnapshotJobIfNecessaryTestAsyncJobIsNullThenScheduleNextSnapshot() {
-        Mockito.doReturn(1L).when(snapshotScheduleVoMock).getAsyncJobId();
-        
Mockito.doReturn(null).when(asyncJobDaoMock).findByIdIncludingRemoved(Mockito.any());
-        Mockito.doReturn(new 
Date()).when(snapshotSchedulerImplSpy).scheduleNextSnapshotJob(Mockito.any(SnapshotScheduleVO.class));
+        doReturn(1L).when(snapshotScheduleVoMock).getAsyncJobId();
+        
doReturn(null).when(asyncJobDaoMock).findByIdIncludingRemoved(Mockito.any());
+        doReturn(new 
Date()).when(snapshotSchedulerImplSpy).scheduleNextSnapshotJob(Mockito.any(SnapshotScheduleVO.class));
 
         
snapshotSchedulerImplSpy.scheduleNextSnapshotJobIfNecessary(snapshotScheduleVoMock);
 
-        
Mockito.verify(snapshotSchedulerImplSpy).scheduleNextSnapshotJob(Mockito.any(SnapshotScheduleVO.class));
+        
verify(snapshotSchedulerImplSpy).scheduleNextSnapshotJob(Mockito.any(SnapshotScheduleVO.class));
     }
 
     @Test
     public void 
scheduleNextSnapshotJobIfNecessaryTestAsyncJobSucceededThenScheduleNextSnapshot()
 {
-        Mockito.doReturn(1L).when(snapshotScheduleVoMock).getAsyncJobId();
-        
Mockito.doReturn(asyncJobVoMock).when(asyncJobDaoMock).findByIdIncludingRemoved(Mockito.any());
-        
Mockito.doReturn(JobInfo.Status.SUCCEEDED).when(asyncJobVoMock).getStatus();
-        Mockito.doReturn(new 
Date()).when(snapshotSchedulerImplSpy).scheduleNextSnapshotJob(Mockito.any(SnapshotScheduleVO.class));
+        doReturn(1L).when(snapshotScheduleVoMock).getAsyncJobId();
+        
doReturn(asyncJobVoMock).when(asyncJobDaoMock).findByIdIncludingRemoved(Mockito.any());
+        doReturn(JobInfo.Status.SUCCEEDED).when(asyncJobVoMock).getStatus();
+        doReturn(new 
Date()).when(snapshotSchedulerImplSpy).scheduleNextSnapshotJob(Mockito.any(SnapshotScheduleVO.class));
 
         
snapshotSchedulerImplSpy.scheduleNextSnapshotJobIfNecessary(snapshotScheduleVoMock);
 
-        
Mockito.verify(snapshotSchedulerImplSpy).scheduleNextSnapshotJob(Mockito.any(SnapshotScheduleVO.class));
+        
verify(snapshotSchedulerImplSpy).scheduleNextSnapshotJob(Mockito.any(SnapshotScheduleVO.class));
     }
 
     @Test
     public void 
scheduleNextSnapshotJobIfNecessaryTestAsyncJobFailedThenScheduleNextSnapshot() {
-        Mockito.doReturn(1L).when(snapshotScheduleVoMock).getAsyncJobId();
-        
Mockito.doReturn(asyncJobVoMock).when(asyncJobDaoMock).findByIdIncludingRemoved(Mockito.any());
-        
Mockito.doReturn(JobInfo.Status.FAILED).when(asyncJobVoMock).getStatus();
-        Mockito.doReturn(new 
Date()).when(snapshotSchedulerImplSpy).scheduleNextSnapshotJob(Mockito.any(SnapshotScheduleVO.class));
+        doReturn(1L).when(snapshotScheduleVoMock).getAsyncJobId();
+        
doReturn(asyncJobVoMock).when(asyncJobDaoMock).findByIdIncludingRemoved(Mockito.any());
+        doReturn(JobInfo.Status.FAILED).when(asyncJobVoMock).getStatus();
+        doReturn(new 
Date()).when(snapshotSchedulerImplSpy).scheduleNextSnapshotJob(Mockito.any(SnapshotScheduleVO.class));
 
         
snapshotSchedulerImplSpy.scheduleNextSnapshotJobIfNecessary(snapshotScheduleVoMock);
 
-        
Mockito.verify(snapshotSchedulerImplSpy).scheduleNextSnapshotJob(Mockito.any(SnapshotScheduleVO.class));
+        
verify(snapshotSchedulerImplSpy).scheduleNextSnapshotJob(Mockito.any(SnapshotScheduleVO.class));
     }
 
     @Test
     public void 
scheduleNextSnapshotJobIfNecessaryTestAsyncJobInProgressThenDoNothing() {
-        Mockito.doReturn(1L).when(snapshotScheduleVoMock).getAsyncJobId();
-        
Mockito.doReturn(asyncJobVoMock).when(asyncJobDaoMock).findByIdIncludingRemoved(Mockito.any());
-        
Mockito.doReturn(JobInfo.Status.IN_PROGRESS).when(asyncJobVoMock).getStatus();
+        doReturn(1L).when(snapshotScheduleVoMock).getAsyncJobId();
+        
doReturn(asyncJobVoMock).when(asyncJobDaoMock).findByIdIncludingRemoved(Mockito.any());
+        doReturn(JobInfo.Status.IN_PROGRESS).when(asyncJobVoMock).getStatus();
 
         
snapshotSchedulerImplSpy.scheduleNextSnapshotJobIfNecessary(snapshotScheduleVoMock);
 
-        Mockito.verify(snapshotSchedulerImplSpy, 
Mockito.never()).scheduleNextSnapshotJob(Mockito.any(SnapshotScheduleVO.class));
+        verify(snapshotSchedulerImplSpy, 
never()).scheduleNextSnapshotJob(Mockito.any(SnapshotScheduleVO.class));
+    }
+
+    // --- countConsecutiveFailedAttempts (#13454) ---
+
+    @Test
+    public void 
countConsecutiveFailedAttemptsTestLimitZeroReturnsZeroWithoutQuerying() {
+        int result = 
snapshotSchedulerImplSpy.countConsecutiveFailedAttempts(1L, 0);
+
+        Assert.assertEquals(0, result);
+        verify(eventDaoMock, 
never()).listLatestOutcomeEventsByResource(Mockito.anyLong(), 
Mockito.anyString(), Mockito.anyString(), Mockito.anyInt());
+    }
+
+    @Test
+    public void countConsecutiveFailedAttemptsTestStopsAtFirstNonErrorEvent() {
+        EventVO error1 = mock(EventVO.class);
+        doReturn(EventVO.LEVEL_ERROR).when(error1).getLevel();
+        EventVO error2 = mock(EventVO.class);
+        doReturn(EventVO.LEVEL_ERROR).when(error2).getLevel();
+        EventVO success = mock(EventVO.class);
+        doReturn(EventVO.LEVEL_INFO).when(success).getLevel();
+
+        doReturn(List.of(error1, error2, 
success)).when(eventDaoMock).listLatestOutcomeEventsByResource(Mockito.anyLong(),
 Mockito.anyString(), Mockito.anyString(), Mockito.anyInt());
+
+        int result = 
snapshotSchedulerImplSpy.countConsecutiveFailedAttempts(1L, 3);
+
+        Assert.assertEquals(2, result);
+    }
+
+    @Test
+    public void countConsecutiveFailedAttemptsTestNoEventsReturnsZero() {
+        
doReturn(Collections.emptyList()).when(eventDaoMock).listLatestOutcomeEventsByResource(Mockito.anyLong(),
 Mockito.anyString(), Mockito.anyString(), Mockito.anyInt());
+
+        int result = 
snapshotSchedulerImplSpy.countConsecutiveFailedAttempts(1L, 3);
+
+        Assert.assertEquals(0, result);
+    }
+
+    // --- getScopedConfigValue (#13454) ---
+
+    @Test
+    public void 
getScopedConfigValueTestFallsBackToGlobalDefaultWhenNoScopeOverrideConfigured() 
{
+        doReturn(1L).when(volumeVoMock).getAccountId();
+        doReturn(1L).when(volumeVoMock).getDataCenterId();
+        doReturn(1L).when(accountVoMock).getDomainId();
+
+        Integer result = 
snapshotSchedulerImplSpy.getScopedConfigValue(SnapshotManager.SnapshotRecurringMaxFailures,
 volumeVoMock, accountVoMock);
+
+        Assert.assertEquals(Integer.valueOf(3), result);
+    }
+

Review Comment:
   The tests only cover the global fallback case, but the new logic is 
specifically about precedence across Account/Domain/Zone/Global. Add unit tests 
that set different values per scope (especially Zone-only overrides) and assert 
the chosen value matches the documented precedence; this would also catch 
regressions like implicit fallback short-circuiting later scopes.



-- 
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]

Reply via email to