Sekcja Zasoby: podglad i sprzatanie zadan w tle
Deploy PROD / deploy (push) Successful in 26s

Kilka rzeczy w tej platformie zyje dluzej niz zadanie HTTP, ktore je
uruchomilo: nagranie u dostawcy, wiadomosc w kolejce, podejscie do quizu,
otwarta sesja obecnosci. Kazda moze utknac, a administrator nie mial jak sie
o tym dowiedziec: w panelu widac skutek (brak nagrania, niewyslany mail),
a nie przyczyne.

POWSTALO PO REALNYM ZDARZENIU
Nagranie zostalo osierocone, bo webinar usunieto w trakcie: wpis znikl
kaskadowo z bazy, a zadanie u dostawcy nagrywalo dalej. Przegladarka Chrome
po stronie Egress krecila sie KILKANASCIE GODZIN i zjadala jedna piata
procesora na serwerze dzielonym z dwiema innymi aplikacjami. Zaden ekran tego
nie pokazywal; wyszlo przypadkiem, przy sprawdzaniu czegos innego.

Zadanie osierocone jest przy tym nie do zatrzymania zwyklymi srodkami:
scheduler domykajacy nagrania szuka po NASZYCH wpisach, wiec czegos, czego
w bazie nie ma, nigdy nie zobaczy.

CO POKAZUJE
Sekcje w kolejnosci waznosci: zadania osierocone (dzialaja u dostawcy, brak
wpisu w bazie), nagrania w toku z oznaczeniem wiszacych po szesciu godzinach,
kolejka wiadomosci QUEUED i FAILED wraz z trescia bledu, liczniki porzuconych
podejsc do quizow i nieswiezych sesji obecnosci. Kazda pozycja ma akcje:
zatrzymanie, usuniecie wpisu, ponowienie wysylki, reczne domkniecie.

Zatrzymanie nagrania idzie po identyfikatorze U DOSTAWCY, a nie naszym: to
jedyna droga do czegos, co po naszej stronie juz nie istnieje. Usuniecie wpisu
najpierw probuje zatrzymac zadanie, dopiero potem kasuje: odwrotna kolejnosc
zostawialaby dzialajace nagranie bez zadnego sladu, czyli dokladnie ten stan,
ktoremu ten ekran ma zapobiegac.

BLAD JEDNEJ SEKCJI NIE PRZEWRACA CALOSCI
Odpytanie dostawcy idzie po sieci i bywa niedostepne. Wtedy pokazujemy
ostrzezenie i informacje, ze lista osieroconych moze byc niepelna, a reszta
ekranu dziala. Ekran diagnostyczny, ktory sam sie nie otwiera, jest
bezuzyteczny dokladnie wtedy, gdy jest potrzebny.

CZEGO TU NIE MA I NIE BEDZIE
Procesow systemowych ani kontenerow Dockera. Backend nie ma dostepu do gniazda
Dockera i to jest bariera bezpieczenstwa, nie przeoczenie: kto przejmie panel,
przejalby caly serwer razem z aplikacjami stojacymi obok. Do zarzadzania
kontenerami sluzy SSH. Ekran pokazuje wylacznie to, co aplikacja wie o sobie.

Dostep wylacznie dla ADMIN, regula w `SecurityConfig` przed ogolna `/admin/**`:
zatrzymanie zadania dotyczy calej instancji, takze cudzych webinarow, a
moderator prowadzi spotkania, nie zarzadza infrastruktura.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 08:06:19 +02:00
parent 06097be829
commit f35bfe1b8c
7 changed files with 1085 additions and 0 deletions
@@ -166,6 +166,13 @@ public class SecurityConfig {
* permitAll powyzej i sprawdza klucz w kontrolerze.
*/
auth.requestMatchers("/admin/api-keys/**").hasRole("ADMIN");
/*
* Zasoby: podglad zadan w tle i ich zatrzymywanie. Dotycza
* calej instancji, takze cudzych webinarow, wiec wylacznie
* ADMIN. Moderator prowadzi spotkania, nie zarzadza
* infrastruktura.
*/
auth.requestMatchers("/admin/resources/**").hasRole("ADMIN");
// pozostale operacje administracyjne takze dla moderatora
auth.requestMatchers("/admin/**").hasAnyRole("ADMIN", "MODERATOR");
@@ -0,0 +1,145 @@
package com.softspm.webinar.health;
import com.softspm.webinar.attendance.AttendanceService;
import com.softspm.webinar.media.RecordingService;
import com.softspm.webinar.quiz.QuizAttemptService;
import com.softspm.webinar.repository.NotificationRepository;
import com.softspm.webinar.video.LiveKitEgressService;
import com.softspm.webinar.web.NotFoundException;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Podglad i sprzatanie zadan prowadzonych w tle.
*
* WYLACZNIE DLA ADMINISTRATORA
* ---------------------------------------------------------------------------
* Sciezka `/admin/resources/**` jest w `SecurityConfig` zawezona do roli ADMIN.
* Zatrzymanie nagrania albo skasowanie wpisu z kolejki ma skutki dla calej
* instancji, takze dla cudzych webinarow: moderator prowadzi spotkania, a nie
* zarzadza infrastruktura.
*
* KAZDA AKCJA JEST ODWRACALNA ALBO NIESZKODLIWA
* ---------------------------------------------------------------------------
* Nic tu nie kasuje danych uczestnikow. Zatrzymanie nagrania konczy zadanie
* u dostawcy, domkniecie sesji obecnosci ustawia czas zakonczenia na ostatni
* sygnal, a ponowienie wiadomosci wraca ja do kolejki. Skasowanie wpisu
* z kolejki usuwa wylacznie sam wpis, nigdy zapisu uczestnika.
*/
@RestController
@RequestMapping("/admin/resources")
@Tag(name = "Zasoby", description = "Stan zadan w tle i ich sprzatanie")
public class ResourcesAdminController {
private static final Logger log = LoggerFactory.getLogger(ResourcesAdminController.class);
private final ResourcesService resources;
private final LiveKitEgressService egressService;
private final RecordingService recordings;
private final NotificationRepository notificationRepository;
private final QuizAttemptService quizAttempts;
private final AttendanceService attendance;
public ResourcesAdminController(ResourcesService resources,
LiveKitEgressService egressService,
RecordingService recordings,
NotificationRepository notificationRepository,
QuizAttemptService quizAttempts,
AttendanceService attendance) {
this.resources = resources;
this.egressService = egressService;
this.recordings = recordings;
this.notificationRepository = notificationRepository;
this.quizAttempts = quizAttempts;
this.attendance = attendance;
}
@GetMapping
@Operation(summary = "Stan zadan prowadzonych w tle")
public ResourcesService.ResourcesView snapshot() {
return resources.snapshot();
}
/**
* Zatrzymuje nagranie u dostawcy.
*
* Dziala takze dla zadania OSIEROCONEGO, czyli takiego, ktorego nie ma juz
* w naszej bazie: przyjmujemy identyfikator u dostawcy, a nie nasz wlasny.
* To jedyna droga zatrzymania czegos, co przestalo istniec po naszej
* stronie, a wciaz zajmuje moc serwera.
*/
@PostMapping("/recordings/{egressId}/stop")
@Operation(summary = "Zatrzymuje nagranie u dostawcy, takze osierocone")
public void stopRecording(@PathVariable String egressId) {
egressService.stopRecording(egressId);
log.info("Zasoby: administrator zatrzymal nagranie {}", egressId);
}
/**
* Usuwa wpis nagrania, ktore utknelo.
*
* Najpierw probuje zatrzymac zadanie u dostawcy, dopiero potem kasuje wpis.
* Odwrotna kolejnosc zostawialaby dzialajace nagranie bez zadnego sladu
* w bazie, czyli dokladnie ten stan, ktoremu ten ekran ma zapobiegac.
*/
@DeleteMapping("/recordings/{id}")
@Operation(summary = "Zatrzymuje nagranie u dostawcy i usuwa wpis")
public void dropRecording(@PathVariable Long id) {
String egressId = recordings.providerIdOf(id);
if (egressId != null && !egressId.isBlank()) {
try {
egressService.stopRecording(egressId);
} catch (Exception e) {
// brak zadania u dostawcy nie moze zablokowac sprzatania wpisu:
// najczestszy powod to zadanie, ktore juz samo sie zakonczylo
log.warn("Zasoby: nie udalo sie zatrzymac {}: {}", egressId, e.getMessage());
}
}
recordings.delete(id);
log.info("Zasoby: administrator usunal nagranie {}", id);
}
/** Wraca wiadomosc do kolejki, zeby wysylacz sprobowal ponownie. */
@PostMapping("/notifications/{id}/retry")
@Operation(summary = "Ponowna proba wyslania wiadomosci")
@Transactional
public void retryNotification(@PathVariable Long id) {
var notification = notificationRepository.findById(id)
.orElseThrow(() -> new NotFoundException("Nie znaleziono wpisu w kolejce"));
notification.setStatus(com.softspm.webinar.domain.NotificationStatus.QUEUED);
notification.setError(null);
notificationRepository.save(notification);
log.info("Zasoby: administrator ponowil wiadomosc {}", id);
}
/**
* Usuwa wpis z kolejki.
*
* Kasuje WYLACZNIE wpis powiadomienia. Zapis uczestnika, webinar i cala
* reszta zostaja nietkniete: to jest sprzatanie kolejki, nie danych.
*/
@DeleteMapping("/notifications/{id}")
@Operation(summary = "Usuwa wpis z kolejki wiadomosci")
public void dropNotification(@PathVariable Long id) {
notificationRepository.deleteById(id);
log.info("Zasoby: administrator usunal wpis kolejki {}", id);
}
/** Domyka porzucone podejscia do quizow i sesje obecnosci. */
@PostMapping("/cleanup")
@Operation(summary = "Domyka porzucone podejscia do quizow i sesje obecnosci")
public void cleanup() {
quizAttempts.closeAbandoned();
attendance.closeStaleSessions();
log.info("Zasoby: administrator uruchomil sprzatanie recznie");
}
}
@@ -0,0 +1,238 @@
package com.softspm.webinar.health;
import com.softspm.webinar.domain.NotificationStatus;
import com.softspm.webinar.domain.Recording;
import com.softspm.webinar.domain.RecordingStatus;
import com.softspm.webinar.repository.AttendanceSessionRepository;
import com.softspm.webinar.repository.NotificationRepository;
import com.softspm.webinar.repository.QuizAttemptRepository;
import com.softspm.webinar.repository.RecordingRepository;
import com.softspm.webinar.settings.SettingKey;
import com.softspm.webinar.settings.SettingsService;
import com.softspm.webinar.video.LiveKitEgressService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Stan zadan, ktore aplikacja prowadzi w tle.
*
* PO CO TO ISTNIEJE
* ---------------------------------------------------------------------------
* Kilka rzeczy w tej platformie zyje dluzej niz zadanie HTTP, ktore je
* uruchomilo: nagranie u dostawcy, wiadomosc w kolejce, podejscie do quizu,
* otwarta sesja obecnosci. Kazda z nich moze UTKNAC, a administrator nie ma
* jak sie o tym dowiedziec: w panelu widac skutek (brak nagrania, niewyslany
* mail), a nie przyczyne.
*
* Zdarzylo sie to realnie. Nagranie zostalo osierocone, bo administrator usunal
* webinar w trakcie: wpis znikl kaskadowo z bazy, a zadanie u dostawcy nagrywalo
* dalej. Przegladarka Chrome po stronie Egress krecila sie kilkanascie godzin
* i zjadala jedna piata procesora, a zaden ekran w aplikacji tego nie pokazywal.
* Wyszlo przypadkiem, przy sprawdzaniu czegos innego.
*
* CZEGO TU NIE MA I NIE BEDZIE
* ---------------------------------------------------------------------------
* **Procesow systemowych ani kontenerow Dockera.** Backend nie ma dostepu do
* gniazda Dockera i to jest bariera bezpieczenstwa, nie przeoczenie: kto
* przejmie panel administracyjny, przejalby wtedy caly serwer, razem z innymi
* aplikacjami stojacymi obok. Do zarzadzania kontenerami sluzy SSH.
*
* Ten ekran pokazuje wylacznie to, co aplikacja wie O SOBIE.
*/
@Service
public class ResourcesService {
private static final Logger log = LoggerFactory.getLogger(ResourcesService.class);
/**
* Po ilu godzinach nagranie w stanie DOWNLOADING uznajemy za wiszace.
*
* Najdluzszy sensowny webinar to kilka godzin, a montaz pliku po stronie
* Egress trwa minuty. Szesc godzin jest wiec bezpiecznym progiem: nie
* oznaczy trwajacego szkolenia, a zlapie zadanie, ktorego nikt nie zatrzymal.
*/
private static final Duration RECORDING_STUCK_AFTER = Duration.ofHours(6);
/** Po ilu godzinach wiadomosc w kolejce uznajemy za zalegajaca. */
private static final Duration NOTIFICATION_STUCK_AFTER = Duration.ofHours(2);
private final RecordingRepository recordingRepository;
private final NotificationRepository notificationRepository;
private final QuizAttemptRepository quizAttemptRepository;
private final AttendanceSessionRepository attendanceRepository;
private final LiveKitEgressService egressService;
private final SettingsService settings;
public ResourcesService(RecordingRepository recordingRepository,
NotificationRepository notificationRepository,
QuizAttemptRepository quizAttemptRepository,
AttendanceSessionRepository attendanceRepository,
LiveKitEgressService egressService,
SettingsService settings) {
this.recordingRepository = recordingRepository;
this.notificationRepository = notificationRepository;
this.quizAttemptRepository = quizAttemptRepository;
this.attendanceRepository = attendanceRepository;
this.egressService = egressService;
this.settings = settings;
}
/**
* Pelny obraz stanu.
*
* Kazda sekcja liczy sie osobno i BLAD JEDNEJ NIE MOZE PRZEWROCIC CALOSCI:
* odpytanie dostawcy wideo idzie po sieci i bywa niedostepne, a wtedy
* administrator ma nadal zobaczyc kolejke maili i reszte. Ekran
* diagnostyczny, ktory sam sie nie otwiera, jest bezuzyteczny dokladnie
* wtedy, gdy jest potrzebny.
*/
@Transactional(readOnly = true)
public ResourcesView snapshot() {
return new ResourcesView(
recordings(),
notifications(),
quizAttempts(),
attendanceSessions(),
OffsetDateTime.now());
}
/**
* Nagrania: nasze wpisy i zadania u dostawcy.
*
* Zestawiamy obie strony, bo rozjazd miedzy nimi jest tu najgrozniejszy.
* Zadanie dzialajace u dostawcy bez wpisu w bazie nie zatrzyma sie samo:
* scheduler domykajacy nagrania szuka po naszych wpisach, wiec czegos,
* czego w bazie nie ma, nigdy nie zobaczy.
*/
private RecordingsSection recordings() {
OffsetDateTime stuckBefore = OffsetDateTime.now().minus(RECORDING_STUCK_AFTER);
List<Recording> pending = recordingRepository.findByStatus(RecordingStatus.DOWNLOADING);
List<RecordingItem> ours = new ArrayList<>();
for (Recording recording : pending) {
ours.add(new RecordingItem(
recording.getId(),
recording.getProviderRecordingId(),
recording.getWebinar() == null ? null : recording.getWebinar().getId(),
recording.getWebinar() == null ? null : recording.getWebinar().getTitle(),
recording.getCreatedAt(),
recording.getCreatedAt() != null && recording.getCreatedAt().isBefore(stuckBefore)));
}
// zadania dzialajace u dostawcy, ktorych nie mamy u siebie
List<String> orphans = new ArrayList<>();
String problem = null;
if ("LIVEKIT".equalsIgnoreCase(settings.get(SettingKey.VIDEO_PROVIDER))) {
try {
Set<String> known = pending.stream()
.map(Recording::getProviderRecordingId)
.filter(id -> id != null && !id.isBlank())
.collect(Collectors.toSet());
orphans.addAll(egressService.activeEgressIds().stream()
.filter(id -> !known.contains(id))
.toList());
} catch (Exception e) {
// brak polaczenia z dostawca nie moze zaslonic reszty ekranu
problem = "Nie udalo sie odpytac dostawcy wideo: " + e.getMessage();
log.warn("Zasoby: odpytanie Egress nieudane: {}", e.getMessage());
}
}
return new RecordingsSection(ours, orphans, problem);
}
/** Kolejka wiadomosci: co czeka i co sie nie udalo. */
private NotificationsSection notifications() {
OffsetDateTime stuckBefore = OffsetDateTime.now().minus(NOTIFICATION_STUCK_AFTER);
List<NotificationItem> queued = notificationRepository.findByStatus(NotificationStatus.QUEUED).stream()
.map(n -> new NotificationItem(n.getId(), n.getTemplate(), n.getRecipient(),
n.getChannel().name(), n.getCreatedAt(), n.getScheduledAt(), n.getError(),
n.getCreatedAt() != null && n.getCreatedAt().isBefore(stuckBefore)))
.toList();
List<NotificationItem> failed = notificationRepository.findByStatus(NotificationStatus.FAILED).stream()
.map(n -> new NotificationItem(n.getId(), n.getTemplate(), n.getRecipient(),
n.getChannel().name(), n.getCreatedAt(), n.getScheduledAt(), n.getError(), false))
.toList();
return new NotificationsSection(queued, failed);
}
/**
* Podejscia do quizu wciaz otwarte.
*
* Kursant, ktory zamknal karte, nie przysle juz zadnego zadania, a jego
* podejscie BLOKUJE KOLEJNE: unikalny indeks czesciowy dopuszcza jedno
* otwarte na pare quiz-zapis. Zadanie w tle je domyka, ale gdy z jakiegos
* powodu tego nie zrobi, kursant nie moze podejsc ponownie i nie wie dlaczego.
*/
private List<QuizAttemptItem> quizAttempts() {
return quizAttemptRepository.findOpenWithTimeLimit().stream()
.map(a -> new QuizAttemptItem(a.getId(), a.getStartedAt()))
.toList();
}
/**
* Sesje obecnosci otwarte dluzej, niz pozwala prog.
*
* Sesja bez zamkniecia ZAWYZA CZAS OBECNOSCI, a z niego wylicza sie flaga
* `attended`, ktora decyduje o certyfikacie.
*/
private List<AttendanceItem> attendanceSessions() {
int timeout = Math.max(1, settings.getInt(SettingKey.ATTENDANCE_TIMEOUT_MINUTES));
OffsetDateTime threshold = OffsetDateTime.now().minusMinutes(timeout);
return attendanceRepository.findStale(threshold).stream()
.map(s -> new AttendanceItem(s.getId(),
s.getWebinar() == null ? null : s.getWebinar().getId(),
s.getJoinedAt(), s.getLastSeenAt()))
.toList();
}
// --- widoki ---
public record ResourcesView(RecordingsSection recordings,
NotificationsSection notifications,
List<QuizAttemptItem> openQuizAttempts,
List<AttendanceItem> staleAttendance,
OffsetDateTime checkedAt) {
}
/**
* @param orphanEgressIds zadania dzialajace u dostawcy, ktorych nie ma w bazie
* @param problem opis bledu odpytania dostawcy, null gdy wszystko poszlo
*/
public record RecordingsSection(List<RecordingItem> inProgress,
List<String> orphanEgressIds,
String problem) {
}
public record RecordingItem(Long id, String providerRecordingId, Long webinarId,
String webinarTitle, OffsetDateTime startedAt, boolean stuck) {
}
public record NotificationsSection(List<NotificationItem> queued, List<NotificationItem> failed) {
}
public record NotificationItem(Long id, String template, String recipient, String channel,
OffsetDateTime createdAt, OffsetDateTime scheduledAt,
String error, boolean stuck) {
}
public record QuizAttemptItem(Long id, OffsetDateTime startedAt) {
}
public record AttendanceItem(Long id, Long webinarId, OffsetDateTime joinedAt,
OffsetDateTime lastSeenAt) {
}
}
@@ -332,6 +332,38 @@ public class LiveKitEgressService {
}
}
/**
* Identyfikatory zadan DZIALAJACYCH u dostawcy.
*
* Sluzy ekranowi zasobow do wykrycia zadan osieroconych: takich, ktore
* Egress wciaz wykonuje, a ktorych nie ma juz w naszej bazie. Taki rozjazd
* powstaje, gdy administrator usunie nagranie albo caly webinar w trakcie
* nagrywania: wpis znika kaskadowo, a zadanie u dostawcy nie ma juz kto
* zatrzymac, bo scheduler szuka wylacznie po naszych wpisach.
*
* Bierzemy stany oznaczajace prace w toku. ENDING tez, bo zadanie
* konczace sie od kilku godzin jest rownie podejrzane jak aktywne.
*/
public List<String> activeEgressIds() {
try {
var response = egressClient().listEgress().execute();
if (!response.isSuccessful() || response.body() == null) {
throw new ValidationException("Serwer LiveKit odpowiedzial: " + describe(response.code()));
}
return response.body().stream()
.filter(info -> switch (info.getStatus()) {
case EGRESS_STARTING, EGRESS_ACTIVE, EGRESS_ENDING -> true;
default -> false;
})
.map(LivekitEgress.EgressInfo::getEgressId)
.toList();
} catch (ValidationException e) {
throw e;
} catch (Exception e) {
throw new ValidationException("Nie udalo sie polaczyc z serwerem LiveKit: " + e.getMessage());
}
}
/**
* Oznacza nagranie jako nieudane.
*