Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 36 additions & 3 deletions backend/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,10 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-liquibase")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-webmvc")
implementation("org.springframework.boot:spring-boot-starter-webclient")
implementation("org.springframework.boot:spring-boot-starter-webmvc"){
exclude(group = "org.springframework.boot", module = "spring-boot-starter-tomcat")
}
implementation("org.springframework.boot:spring-boot-starter-jetty")
implementation("org.springframework.security:spring-security-crypto")
implementation("org.mapstruct:mapstruct:${mapStructVersion}")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${swaggerOpenAPIVersion}")
Expand Down Expand Up @@ -96,6 +98,19 @@ tasks.named<org.springframework.boot.gradle.tasks.run.BootRun>("bootRun") {
mainClass.set("com.devaulty.backend.BackendApplication")
}

tasks.withType<JavaExec>().configureEach {
jvmArgs(
"-Xms64m",
"-Xmx256m",
"-XX:MetaspaceSize=96m",
"-XX:MaxMetaspaceSize=192m",
"-XX:ParallelGCThreads=2",
"-XX:ConcGCThreads=1",
"-XX:+UseG1GC",
"-XX:MaxGCPauseMillis=100"
)
}

// task to build frontend (npm run build)
val buildFrontend by tasks.registering(Exec::class) {
group = "build"
Expand Down Expand Up @@ -146,7 +161,25 @@ runtime {
jpackage {
imageName = "devaulty"
appVersion = jpackageImageVersion
imageOptions = listOf("--java-options", "-Dspring.profiles.active=prod")
imageOptions = listOf(
// 1. Memory do Heap Limits (Obj dynamic RAM)
"--java-options", "-Xms64m",
"--java-options", "-Xmx256m",

// 2. Metaspace limits (Spring/Hibernate Class Metadata Memory)
"--java-options", "-XX:MetaspaceSize=96m",
"--java-options", "-XX:MaxMetaspaceSize=192m",

// 3. Garbage Collector (GC) Thread Control
"--java-options", "-XX:ParallelGCThreads=2",
"--java-options", "-XX:ConcGCThreads=1",
"--java-options", "-XX:+UseG1GC",
"--java-options", "-XX:MaxGCPauseMillis=100",

// 4. Spring Production Profile
"--java-options", "-Dspring.profiles.active=prod"
)

}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.devaulty.backend.adapter.in.web.common;

import jakarta.annotation.PreDestroy;
import org.springframework.stereotype.Component;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

/**
* Runs long-lived, blocking background tasks (e.g. file downloads) outside
* of the servlet request thread pool, so HTTP worker threads are never held
* hostage by slow I/O operations.
*/
@Component
public class BackgroundTaskRunner {

private final ExecutorService executor = Executors.newSingleThreadExecutor();

public void run(Runnable task) {
executor.execute(task);
}

@PreDestroy
public void shutdown() {
executor.shutdown();
try {
if (!executor.awaitTermination(30, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.devaulty.backend.adapter.in.web.util;
package com.devaulty.backend.adapter.in.web.common;

import org.springframework.stereotype.Component;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import com.devaulty.backend.adapter.in.web.credential.dto.CredentialSummaryResponse;
import com.devaulty.backend.adapter.in.web.credential.dto.CredentialViewResponse;
import com.devaulty.backend.adapter.in.web.credential.dto.UpdateCredentialRequest;
import com.devaulty.backend.adapter.in.web.util.UriLocationBuilderHelper;
import com.devaulty.backend.adapter.in.web.common.UriLocationBuilderHelper;
import com.devaulty.backend.application.port.in.credential.*;
import com.devaulty.backend.application.port.in.tag.item.GetTagsForItemUseCase;
import com.devaulty.backend.application.port.in.tag.item.GetTagsForItemsUseCase;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import com.devaulty.backend.adapter.in.web.link.dto.CreateLinkRequest;
import com.devaulty.backend.adapter.in.web.link.dto.LinkViewResponse;
import com.devaulty.backend.adapter.in.web.link.dto.UpdateLinkRequest;
import com.devaulty.backend.adapter.in.web.util.UriLocationBuilderHelper;
import com.devaulty.backend.adapter.in.web.common.UriLocationBuilderHelper;
import com.devaulty.backend.application.port.in.link.*;
import com.devaulty.backend.domain.model.Link;
import com.devaulty.backend.application.port.in.tag.item.GetTagsForItemUseCase;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import com.devaulty.backend.adapter.in.web.note.dto.NoteSummaryResponse;
import com.devaulty.backend.adapter.in.web.note.dto.NoteViewResponse;
import com.devaulty.backend.adapter.in.web.note.dto.UpdateNoteRequest;
import com.devaulty.backend.adapter.in.web.util.UriLocationBuilderHelper;
import com.devaulty.backend.adapter.in.web.common.UriLocationBuilderHelper;
import com.devaulty.backend.application.port.in.note.*;
import com.devaulty.backend.domain.model.Note;
import com.devaulty.backend.application.port.in.tag.item.GetTagsForItemUseCase;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.devaulty.backend.adapter.in.web.problem;

import com.devaulty.backend.adapter.in.web.problem.dto.*;
import com.devaulty.backend.adapter.in.web.util.UriLocationBuilderHelper;
import com.devaulty.backend.adapter.in.web.common.UriLocationBuilderHelper;
import com.devaulty.backend.application.port.in.problem.*;
import com.devaulty.backend.domain.model.Problem;
import com.devaulty.backend.application.port.in.tag.item.GetTagsForItemUseCase;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import com.devaulty.backend.adapter.in.web.project.dto.CreateProjectRequest;
import com.devaulty.backend.adapter.in.web.project.dto.ProjectViewResponse;
import com.devaulty.backend.adapter.in.web.project.dto.UpdateProjectRequest;
import com.devaulty.backend.adapter.in.web.util.UriLocationBuilderHelper;
import com.devaulty.backend.adapter.in.web.common.UriLocationBuilderHelper;
import com.devaulty.backend.application.port.in.project.*;
import com.devaulty.backend.domain.model.Project;
import jakarta.validation.Valid;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import reactor.core.publisher.Flux;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

@Tag(name = "Releases", description = "Endpoints for checking desktop application updates, streaming asset downloads, and invoking native OS installation")
@RequestMapping("/api/v1/releases")
Expand Down Expand Up @@ -48,32 +48,28 @@ public interface ReleaseApi {

@Operation(
summary = "Stream update download and trigger native OS installation",
description = "Establishes a Server-Sent Events (SSE) HTTP stream (`text/event-stream`) to download the latest installer binary into the local platform temp directory (`~/.config/devaulty/temp` on Linux, `%LOCALAPPDATA%\\devaulty\\temp` on Windows, `~/Library/Caches/devaulty/temp` on macOS). Emits real-time progress events (`DOWNLOADING` status with percentage and byte counts). Once download reaches 100%, transitions to `INSTALLING` status, launches the native OS installer/restart script detached from the current process, and gracefully shuts down the running Devaulty application instance."
description = "Establishes a Server-Sent Events (SSE) HTTP stream (`text/event-stream`) to download the latest installer binary into the local platform temp directory (`~/.config/devaulty/temp` on Linux, `%LOCALAPPDATA%\\devaulty\\temp` on Windows, `~/Library/Caches/devaulty/temp` on macOS). Emits real-time progress events (`DOWNLOADING` status with percentage and byte counts). Once download reaches 100%, transitions to `INSTALLING` status, launches the native OS installer/restart script detached from the current process, and gracefully shuts down the running Devaulty application instance. " +
"Backed by a blocking download pipeline (JDK `HttpClient`) executed on a dedicated background thread, decoupled from the servlet request thread pool."
)
@ApiResponses(value = {
@ApiResponse(
responseCode = "200",
description = "Server-Sent Events (SSE) stream established successfully. Emits real-time `UpdateDownloadProgressResponse` events.",
description = "Server-Sent Events (SSE) stream established successfully. Emits real-time `UpdateDownloadProgressResponse` events until the stream completes or errors out.",
content = @Content(mediaType = MediaType.TEXT_EVENT_STREAM_VALUE, schema = @Schema(implementation = UpdateDownloadProgressResponse.class))
),
@ApiResponse(
responseCode = "400",
description = "Bad Request. Thrown when no update is available (`updateAvailable == false`) or no compatible installer asset exists for the current host OS.",
content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class))
),
@ApiResponse(
responseCode = "403",
description = "Forbidden. Request missing or containing an invalid `X-Devaulty-Internal-Token` header.",
content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class))
),
@ApiResponse(
responseCode = "500",
description = "Internal Server Error. Streaming network failure, file I/O error during installer save, or native process execution failure.",
description = "Internal Server Error. Streaming network failure, file I/O error during installer save, or native process execution failure. Reported as a terminal SSE error event, since HTTP headers are already committed by the time streaming begins.",
content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class))
)
})
@PostMapping(value = "/download-and-install", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
ResponseEntity<Flux<UpdateDownloadProgressResponse>> downloadUpdate();
SseEmitter downloadUpdate();

@Operation(
summary = "Get current running application version",
Expand All @@ -93,4 +89,4 @@ public interface ReleaseApi {
})
@GetMapping("/current-app-version")
ResponseEntity<CurrentVersionResponse> getAppInfo();
}
}
Original file line number Diff line number Diff line change
@@ -1,33 +1,44 @@
package com.devaulty.backend.adapter.in.web.release;

import com.devaulty.backend.adapter.in.web.release.dto.CurrentVersionResponse;
import com.devaulty.backend.adapter.in.web.common.BackgroundTaskRunner;
import com.devaulty.backend.adapter.in.web.release.dto.AppUpdateInfoResponse;
import com.devaulty.backend.adapter.in.web.release.dto.UpdateDownloadProgressResponse;
import com.devaulty.backend.adapter.in.web.release.dto.CurrentVersionResponse;
import com.devaulty.backend.application.port.in.release.CheckForUpdatesUseCase;
import com.devaulty.backend.application.port.in.release.DownloadUpdateUseCase;
import com.devaulty.backend.application.port.in.release.GetCurrentVersionUseCase;
import org.springframework.http.MediaType;
import com.devaulty.backend.application.port.in.release.UpdateProgressInfo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.io.IOException;
import java.util.concurrent.atomic.AtomicBoolean;

@RestController
@RequestMapping("/api/v1/releases")
public class ReleaseController implements ReleaseApi {

private static final Logger logger = LoggerFactory.getLogger(ReleaseController.class);

private final CheckForUpdatesUseCase checkForUpdatesUseCase;
private final DownloadUpdateUseCase downloadUpdateUseCase;
private final GetCurrentVersionUseCase getCurrentVersionUseCase;
private final ReleaseWebMapper releaseWebMapper;
private final BackgroundTaskRunner backgroundTaskRunner;

private static final long SSE_TIMEOUT_MS = 20 * 60 * 1000L;

public ReleaseController(CheckForUpdatesUseCase checkForUpdatesUseCase, DownloadUpdateUseCase downloadUpdateUseCase, GetCurrentVersionUseCase getCurrentVersionUseCase, ReleaseWebMapper releaseWebMapper) {
public ReleaseController(CheckForUpdatesUseCase checkForUpdatesUseCase, DownloadUpdateUseCase downloadUpdateUseCase, GetCurrentVersionUseCase getCurrentVersionUseCase, ReleaseWebMapper releaseWebMapper, BackgroundTaskRunner backgroundTaskRunner) {
this.checkForUpdatesUseCase = checkForUpdatesUseCase;
this.downloadUpdateUseCase = downloadUpdateUseCase;
this.getCurrentVersionUseCase = getCurrentVersionUseCase;
this.releaseWebMapper = releaseWebMapper;
this.backgroundTaskRunner = backgroundTaskRunner;
}

@Override
Expand All @@ -37,19 +48,47 @@ public ResponseEntity<AppUpdateInfoResponse> checkUpdates() {
}

@Override
@PostMapping(value = "/download-and-install", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public ResponseEntity<Flux<UpdateDownloadProgressResponse>> downloadUpdate() {
Flux<UpdateDownloadProgressResponse> stream = downloadUpdateUseCase.execute()
.map(releaseWebMapper::toProgressResponse);

return ResponseEntity.ok()
.contentType(MediaType.TEXT_EVENT_STREAM)
.body(stream);
@PostMapping("/download-and-install")
public SseEmitter downloadUpdate() {
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
AtomicBoolean isCancelled = new AtomicBoolean(false);

emitter.onCompletion(() -> isCancelled.set(true));
emitter.onTimeout(() -> isCancelled.set(true));
emitter.onError(ex -> isCancelled.set(true));

backgroundTaskRunner.run(() -> runDownload(emitter, isCancelled));
return emitter;
}

@Override
@GetMapping("/current-app-version")
public ResponseEntity<CurrentVersionResponse> getAppInfo() {
return ResponseEntity.ok(new CurrentVersionResponse(getCurrentVersionUseCase.execute()));
}

private void runDownload(SseEmitter emitter, AtomicBoolean isCancelled) {
try {
downloadUpdateUseCase.execute(progress -> sendProgress(emitter, progress, isCancelled));
if (!isCancelled.get()) {
emitter.complete();
}
} catch (Exception ex) {
if (!isCancelled.get()) {
emitter.completeWithError(ex);
}
}
}

private void sendProgress(SseEmitter emitter, UpdateProgressInfo progress, AtomicBoolean isCancelled) {
if (isCancelled.get()) {
throw new IllegalStateException("Client disconnected or SSE stream cancelled.");
}
try {
emitter.send(releaseWebMapper.toProgressResponse(progress));
} catch (IOException | IllegalStateException e) {
isCancelled.set(true);
logger.debug("Failed to send SSE progress, client likely disconnected: {}", e.getMessage());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import com.devaulty.backend.adapter.in.web.snippet.dto.SnippetSummaryResponse;
import com.devaulty.backend.adapter.in.web.snippet.dto.SnippetViewResponse;
import com.devaulty.backend.adapter.in.web.snippet.dto.UpdateSnippetRequest;
import com.devaulty.backend.adapter.in.web.util.UriLocationBuilderHelper;
import com.devaulty.backend.adapter.in.web.common.UriLocationBuilderHelper;
import com.devaulty.backend.application.port.in.snippet.*;
import com.devaulty.backend.application.port.in.tag.item.GetTagsForItemUseCase;
import com.devaulty.backend.application.port.in.tag.item.GetTagsForItemsUseCase;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import com.devaulty.backend.adapter.in.web.tag.dto.CreateTagRequest;
import com.devaulty.backend.adapter.in.web.tag.dto.TagViewResponse;
import com.devaulty.backend.adapter.in.web.tag.dto.UpdateTagRequest;
import com.devaulty.backend.adapter.in.web.util.UriLocationBuilderHelper;
import com.devaulty.backend.adapter.in.web.common.UriLocationBuilderHelper;
import com.devaulty.backend.application.port.in.tag.*;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
Expand Down
Loading