• The next step is done and the plan is updated. Autoscaling policies is now marked complete in docs/video-clipping-service-implementation-plan.md:717.

I added autoscaling-ready queue metrics in src/main/java/org/example/videoclips/observability/DatabaseQueueMetricsBinder.java:13, backed by new queue repository counters, and enabled Prometheus scraping via src/main/
  resources/application.properties:3 plus the Prometheus registry dependency in pom.xml:43. The policy itself is documented in docs/autoscaling-policies.md:1, including worker/API scaling rules and a sample KEDA ScaledObject.
  Coverage for the new gauges is in src/test/java/org/example/videoclips/observability/DatabaseQueueMetricsBinderTest.java:14.
This commit is contained in:
JSLMPR 2026-07-09 19:00:31 +02:00
parent 5565298351
commit 632959104b
7 changed files with 150 additions and 2 deletions

View File

@ -0,0 +1,65 @@
# Autoscaling Policies
This document captures the implementation and rollout guidance for performance plan item `Autoscaling policies`.
Exposed metrics:
- `/actuator/prometheus` is enabled for scraping.
- `video.clipping.queue.pending`
- `video.clipping.queue.processing`
- `video.clipping.queue.dlq`
- `video.clipping.queue.oldest.pending.age.seconds`
Worker policy:
- Start with one FFmpeg process per worker pod.
- Use `minReplicas: 1` and `maxReplicas: 20`.
- Scale out when either queue depth or queue age indicates backlog:
- `video_clipping_queue_pending > 5` per worker target.
- `video_clipping_queue_oldest_pending_age_seconds > 30`.
- Keep CPU as a secondary safeguard with average utilization near `70%`.
API policy:
- Use a separate HPA from workers.
- Scale API pods on CPU and request latency or concurrent requests if those metrics are available from the ingress/controller layer.
- Start with `minReplicas: 2`, `maxReplicas: 10`, and CPU target near `60%`.
Sizing rationale:
- The worker CPU benchmark and FFmpeg preset benchmark show clip generation is CPU-bound once source staging is local.
- The object-storage bandwidth benchmark shows throughput degradation becomes material below `50 MiB/s`, so queue-age should remain part of the worker policy even when CPU is not yet saturated.
- The visibility-timeout default is conservative, so scaling should respond to queue buildup before stale-claim recovery becomes common.
Suggested worker HPA or KEDA shape:
```yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: video-clipping-worker
spec:
scaleTargetRef:
name: video-clipping-worker
minReplicaCount: 1
maxReplicaCount: 20
cooldownPeriod: 120
pollingInterval: 15
triggers:
- type: prometheus
metadata:
serverAddress: http://prometheus.monitoring.svc.cluster.local:9090
metricName: video_clipping_queue_pending
threshold: "5"
query: sum(video_clipping_queue_pending)
- type: prometheus
metadata:
serverAddress: http://prometheus.monitoring.svc.cluster.local:9090
metricName: video_clipping_queue_oldest_pending_age_seconds
threshold: "30"
query: max(video_clipping_queue_oldest_pending_age_seconds)
```
Operational note:
- If production uses SQS instead of the database queue, keep the same policy shape but source queue depth and age from native cloud metrics instead of the local Micrometer gauges.

View File

@ -714,7 +714,7 @@ Keep the domain independent from Spring framework details. Adapters implement st
2. [x] FFmpeg preset benchmarks. 2. [x] FFmpeg preset benchmarks.
3. [x] Object-storage bandwidth tests. 3. [x] Object-storage bandwidth tests.
4. [x] Queue visibility-timeout tuning. 4. [x] Queue visibility-timeout tuning.
5. [ ] Autoscaling policies. 5. [x] Autoscaling policies.
6. [ ] Cost model by video minute. 6. [ ] Cost model by video minute.
### Operational Readiness ### Operational Readiness

View File

@ -51,6 +51,10 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId> <artifactId>spring-boot-starter-actuator</artifactId>
</dependency> </dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.flywaydb</groupId> <groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId> <artifactId>flyway-core</artifactId>

View File

@ -0,0 +1,39 @@
package org.example.videoclips.observability;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import org.example.videoclips.queue.entity.QueueMessageEntity;
import org.example.videoclips.queue.spring.QueueMessageJpaRepository;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.Instant;
@Component
@ConditionalOnProperty(name = "video-clipping.queue", havingValue = "db")
public class DatabaseQueueMetricsBinder {
public DatabaseQueueMetricsBinder(MeterRegistry meterRegistry, QueueMessageJpaRepository queueMessageJpaRepository) {
Gauge.builder("video.clipping.queue.pending", queueMessageJpaRepository, repository -> repository.countByStatus("PENDING"))
.description("Pending clip-job queue messages")
.register(meterRegistry);
Gauge.builder("video.clipping.queue.processing", queueMessageJpaRepository, repository -> repository.countByStatus("PROCESSING"))
.description("Claimed clip-job queue messages still processing")
.register(meterRegistry);
Gauge.builder("video.clipping.queue.dlq", queueMessageJpaRepository, repository -> repository.countByStatus("DLQ"))
.description("Dead-lettered clip-job queue messages")
.register(meterRegistry);
Gauge.builder("video.clipping.queue.oldest.pending.age.seconds", queueMessageJpaRepository, this::oldestPendingAgeSeconds)
.description("Age in seconds of the oldest pending clip-job queue message")
.register(meterRegistry);
}
private double oldestPendingAgeSeconds(QueueMessageJpaRepository queueMessageJpaRepository) {
QueueMessageEntity oldestPending = queueMessageJpaRepository.findFirstByStatusOrderByAvailableAtAsc("PENDING");
if (oldestPending == null) {
return 0.0;
}
return Math.max(0.0, Duration.between(oldestPending.getAvailableAt(), Instant.now()).toSeconds());
}
}

View File

@ -10,4 +10,8 @@ public interface QueueMessageJpaRepository extends JpaRepository<QueueMessageEnt
List<QueueMessageEntity> findTop10ByStatusAndAvailableAtLessThanEqualOrderByAvailableAtAsc(String status, java.time.Instant availableAt); List<QueueMessageEntity> findTop10ByStatusAndAvailableAtLessThanEqualOrderByAvailableAtAsc(String status, java.time.Instant availableAt);
List<QueueMessageEntity> findTop10ByStatusAndClaimedAtLessThanEqualOrderByClaimedAtAsc(String status, java.time.Instant claimedAt); List<QueueMessageEntity> findTop10ByStatusAndClaimedAtLessThanEqualOrderByClaimedAtAsc(String status, java.time.Instant claimedAt);
long countByStatus(String status);
QueueMessageEntity findFirstByStatusOrderByAvailableAtAsc(String status);
} }

View File

@ -1,6 +1,6 @@
spring.application.name=video-clipping-service spring.application.name=video-clipping-service
spring.mvc.problemdetails.enabled=true spring.mvc.problemdetails.enabled=true
management.endpoints.web.exposure.include=health,info,metrics management.endpoints.web.exposure.include=health,info,metrics,prometheus
spring.jpa.open-in-view=false spring.jpa.open-in-view=false
video-clipping.repository=memory video-clipping.repository=memory
video-clipping.storage=memory video-clipping.storage=memory

View File

@ -0,0 +1,36 @@
package org.example.videoclips.observability;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.example.videoclips.queue.entity.QueueMessageEntity;
import org.example.videoclips.queue.spring.QueueMessageJpaRepository;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class DatabaseQueueMetricsBinderTest {
@Test
void registersQueueDepthAndAgeGauges() {
QueueMessageJpaRepository repository = mock(QueueMessageJpaRepository.class);
when(repository.countByStatus("PENDING")).thenReturn(7L);
when(repository.countByStatus("PROCESSING")).thenReturn(2L);
when(repository.countByStatus("DLQ")).thenReturn(1L);
QueueMessageEntity oldestPending = new QueueMessageEntity();
oldestPending.setAvailableAt(Instant.now().minusSeconds(42));
when(repository.findFirstByStatusOrderByAvailableAtAsc("PENDING")).thenReturn(oldestPending);
SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry();
new DatabaseQueueMetricsBinder(meterRegistry, repository);
assertEquals(7.0, meterRegistry.get("video.clipping.queue.pending").gauge().value());
assertEquals(2.0, meterRegistry.get("video.clipping.queue.processing").gauge().value());
assertEquals(1.0, meterRegistry.get("video.clipping.queue.dlq").gauge().value());
double oldestPendingAgeSeconds = meterRegistry.get("video.clipping.queue.oldest.pending.age.seconds").gauge().value();
assertEquals(42.0, oldestPendingAgeSeconds, 2.0);
}
}