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
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ tasks.named('test') {

// Code coverage
jacoco {
toolVersion = "0.8.8"
toolVersion = "0.8.11"
}

jacocoTestReport {
Expand Down
124 changes: 106 additions & 18 deletions src/main/java/com/studysync/config/DatabaseReloadService.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import javax.sql.DataSource;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;

/**
* Service that shuts down and reopens the H2 database in-place, allowing the
Expand All @@ -22,9 +23,9 @@
* <p>The reload cycle is:
* <ol>
* <li>{@code SHUTDOWN} — H2 closes its engine (caches flushed, file lock released).</li>
* <li>Soft-evict all pooled connections so HikariCP discards them.</li>
* <li>A test query forces HikariCP to create a fresh connection, which makes
* H2 open the (now-replaced) database file.</li>
* <li>Evict all pooled connections and wait for active connections to drain.</li>
* <li>A test query (with retries) forces HikariCP to create a fresh connection,
* which makes H2 open the (now-replaced) database file.</li>
* <li>Schema migrations ({@code schema.sql}) are re-applied to ensure the
* downloaded database has all required columns/indexes.</li>
* </ol>
Expand All @@ -34,6 +35,15 @@ public class DatabaseReloadService {

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

/** Maximum time (ms) to wait for active connections to drain after SHUTDOWN. */
private static final long DRAIN_TIMEOUT_MS = 3000;
/** Interval (ms) between drain-wait polls. */
private static final long DRAIN_POLL_MS = 100;
/** Number of reconnect attempts before giving up. */
private static final int RECONNECT_RETRIES = 5;
/** Delay (ms) between reconnect retries. */
private static final long RECONNECT_RETRY_DELAY_MS = 500;

private final DataSource dataSource;
private final JdbcTemplate jdbcTemplate;

Expand All @@ -45,6 +55,7 @@ public DatabaseReloadService(DataSource dataSource, JdbcTemplate jdbcTemplate) {
/**
* Shuts down the H2 engine and evicts all pooled connections, releasing the
* file lock so the {@code .mv.db} file can be safely replaced on any OS.
* Blocks until all active connections have drained (up to a timeout).
* Must be followed by a call to {@link #reconnect()} once the file is ready.
*/
public void shutdown() {
Expand All @@ -58,28 +69,97 @@ public void shutdown() {
logger.debug("H2 SHUTDOWN completed (exception expected): {}", e.getMessage());
}

// 2. Tell HikariCP to discard every idle/returned connection
// 2. Evict connections and wait for active ones to drain
int remaining = -1;
if (dataSource instanceof HikariDataSource hikari) {
HikariPoolMXBean pool = hikari.getHikariPoolMXBean();
if (pool != null) {
pool.softEvictConnections();

// Wait for all active (checked-out) connections to be returned
// and evicted, so H2 fully releases the file lock.
long deadline = System.currentTimeMillis() + DRAIN_TIMEOUT_MS;
while (pool.getActiveConnections() > 0
&& System.currentTimeMillis() < deadline) {
try {
Thread.sleep(DRAIN_POLL_MS);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
break;
}
}
remaining = pool.getActiveConnections();
}
}

if (remaining > 0) {
logger.warn("H2 shutdown completed with {} active connection(s) still present; "
+ "file lock may not be fully released", remaining);
} else if (remaining == 0) {
logger.info("H2 shutdown complete; all connections drained");
} else {
logger.info("H2 shutdown complete");
}
}

/**
* Reconnects to the H2 database file (which may have been replaced since
* {@link #shutdown()}) and re-applies schema migrations.
*
* <p>Uses retries because HikariCP may still hand out stale connections on
* the first attempt if soft-eviction hasn't fully propagated.
*/
public void reconnect() {
logger.info("Reconnecting to H2 database…");

// Force a fresh connection — H2 opens the (possibly replaced) file
try {
jdbcTemplate.queryForObject("SELECT 1", Integer.class);
} catch (Exception e) {
logger.error("Failed to reconnect after reload: {}", e.getMessage());
throw new RuntimeException("Database reload failed — application may need a restart", e);
// Retry loop: validate a raw connection AND verify JdbcTemplate can
// query through the pool. Both checks use the same attempt so that
// stale connections are fully drained before we proceed to migrations.
Exception lastException = null;
for (int attempt = 1; attempt <= RECONNECT_RETRIES; attempt++) {
try {
try (Connection conn = dataSource.getConnection()) {
if (!conn.isValid(2)) {
throw new RuntimeException("Connection.isValid() returned false");
}
}
// Verify JdbcTemplate can also query (uses a potentially
// different pooled connection than the one validated above).
Integer result = jdbcTemplate.queryForObject("SELECT 1", Integer.class);
if (result == null || result != 1) {
throw new RuntimeException("SELECT 1 returned unexpected result: " + result);
}
logger.info("Database connection verified (attempt {})", attempt);
break;
} catch (Exception e) {
lastException = e;
logger.debug("Reconnect attempt {}/{} failed: {}", attempt,
RECONNECT_RETRIES, e.getMessage());
if (attempt == RECONNECT_RETRIES) {
String msg = "Database reconnect failed after " + RECONNECT_RETRIES
+ " attempts — application may need a restart";
logger.error(msg, e);
throw new RuntimeException(msg, e);
}
try {
Thread.sleep(RECONNECT_RETRY_DELAY_MS);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
RuntimeException interrupted = new RuntimeException(
"Database reconnect interrupted while waiting to retry", ie);
if (lastException != null && lastException != ie) {
interrupted.addSuppressed(lastException);
}
throw interrupted;
}
// Re-evict to clear any remaining stale connections
if (dataSource instanceof HikariDataSource hikari) {
HikariPoolMXBean pool = hikari.getHikariPoolMXBean();
if (pool != null) {
pool.softEvictConnections();
}
}
}
}

// Run idempotent schema.sql to apply any missing migrations
Expand Down Expand Up @@ -109,31 +189,39 @@ private void runMigrations() {
populator.setContinueOnError(true); // individual failures are non-fatal
populator.setSeparator(";");
populator.execute(dataSource);
logger.info("Schema migrations re-applied after database reload using {} (continueOnError=true; individual statement failures were silently ignored)",
logger.info("Schema migrations re-applied after database reload using {} "
+ "(continueOnError=true; individual statement failures were silently ignored)",
schemaResource.getDescription());
} catch (Exception e) {
logger.error("Failed to re-apply schema.sql after reload — the database may be missing columns/tables", e);
logger.error("Failed to re-apply schema.sql after reload"
+ " — the database may be missing columns/tables", e);
}
}

private Resource resolveSchemaResource() {
Resource classpathSchema = new ClassPathResource("schema.sql", DatabaseReloadService.class.getClassLoader());
Resource classpathSchema = new ClassPathResource("schema.sql",
DatabaseReloadService.class.getClassLoader());
if (classpathSchema.exists()) {
return classpathSchema;
}

Path installedSchema = Path.of(System.getProperty("user.home"), ".local", "share", "studysync", "resources", "schema.sql");
Path installedSchema = Path.of(System.getProperty("user.home"),
".local", "share", "studysync", "resources", "schema.sql");
if (Files.exists(installedSchema)) {
logger.warn("schema.sql not found on classpath; falling back to installed resource file: {}", installedSchema);
logger.warn("schema.sql not found on classpath; "
+ "falling back to installed resource file: {}", installedSchema);
return new FileSystemResource(installedSchema);
}

Path projectSchema = Path.of("src", "main", "resources", "schema.sql").toAbsolutePath();
Path projectSchema = Path.of("src", "main", "resources", "schema.sql")
.toAbsolutePath();
if (Files.exists(projectSchema)) {
logger.warn("schema.sql not found on classpath; falling back to project resource file: {}", projectSchema);
logger.warn("schema.sql not found on classpath; "
+ "falling back to project resource file: {}", projectSchema);
return new FileSystemResource(projectSchema);
}

throw new IllegalStateException("schema.sql not found in classpath, installed resources, or project resources");
throw new IllegalStateException(
"schema.sql not found in classpath, installed resources, or project resources");
}
}
13 changes: 13 additions & 0 deletions src/main/java/com/studysync/domain/service/StudyService.java
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,19 @@ public StudyService(GoogleDriveService googleDriveService, DateTimeService dateT
this.dateTimeService = dateTimeService;
}

/**
* Clears cached processing guards so that delayed-goal processing
* re-runs against the newly loaded database.
* Must be called after a live database reload (e.g. Google Drive download).
*/
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.NOT_SUPPORTED)
public void resetAfterReload() {
synchronized (this) {
lastDelayProcessingDate = null;
}
logger.info("StudyService caches reset after DB reload");
}
Comment thread
geokoko marked this conversation as resolved.

private void markDirty() {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
Expand Down
13 changes: 13 additions & 0 deletions src/main/java/com/studysync/domain/service/TaskService.java
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,19 @@ public TaskService(CategoryService categoryService, GoogleDriveService googleDri
this.dateTimeService = dateTimeService;
}

/**
* Clears cached processing guards so that delay-marking and other
* once-per-day operations re-run against the newly loaded database.
* Must be called after a live database reload (e.g. Google Drive download).
*/
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.NOT_SUPPORTED)
public void resetAfterReload() {
synchronized (this) {
lastDelayedTasksProcessedDate = null;
}
logger.info("TaskService caches reset after DB reload");
}
Comment thread
geokoko marked this conversation as resolved.

private void markDirty() {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
Expand Down
8 changes: 7 additions & 1 deletion src/main/java/com/studysync/presentation/ui/StudySyncUI.java
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,14 @@ public void start(Stage primaryStage) {
// Register pre-reload listener to show a blocking overlay during DB reload
googleDriveService.addPreReloadListener(() -> Platform.runLater(this::showReloadOverlay));

// Register reload listener to refresh all panels when DB is reloaded from Drive
// Register reload listener to reset service caches and refresh all panels
// when the DB is reloaded from Drive.
googleDriveService.addReloadListener(() -> Platform.runLater(() -> {
// Clear once-per-day processing guards so delay logic re-runs
// against the freshly loaded database.
taskService.resetAfterReload();
studyService.resetAfterReload();

hideReloadOverlay();
refreshAllPanels();
}));
Expand Down
Loading
Loading