Skip to content

Commit a5ece5e

Browse files
geokokoCopilot
andauthored
Add Hot-Reload Google Drive sync without restart (#13)
* Hot-reload Google Drive sync without restart * Use ResourceDatabasePopulator for schema re-migration The manual split-by-semicolon approach skipped SQL statements preceded by comment headers because the entire chunk started with '--' after trim(). Spring's ResourceDatabasePopulator handles comment stripping correctly and is the idiomatic choice for executing SQL scripts. * Split DB reload into shutdown/reconnect for Windows file-lock safety downloadAndReload() now shuts down H2 and evicts pool connections before downloading the Drive file, ensuring the .mv.db file lock is released before the move/replace. Reconnect always runs in a finally block so the app stays functional even if the download fails. * Apply Drive sync review fixes - Show blocking overlay on main window during DB reload to prevent user interaction while H2 is shut down (pre-reload/post-reload listeners) - Add Objects.requireNonNull for dbShutdown/dbReconnect callbacks; catch and log exceptions from both so a failed reconnect returns false instead of propagating an unhandled RuntimeException - Defer markLocalDbDirty() to afterCommit via TransactionSynchronization so rolled-back transactions don't falsely flag the DB as dirty - Adjust runMigrations() log message to note continueOnError=true; escalate full failure to logger.error with stack trace - checkSyncStatus() now returns LOCAL_NEWER based on file timestamps (not just the in-memory dirty flag), catching post-restart drift - markLocalDbDirty() sets the flag unconditionally (regardless of sign-in state) so edits made while signed out are tracked - refreshAllPanels() logs full exception (not just getMessage()) --------- Co-authored-by: geokoko <geokoko@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent ca07ded commit a5ece5e

File tree

8 files changed

+486
-28
lines changed

8 files changed

+486
-28
lines changed
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package com.studysync.config;
2+
3+
import com.zaxxer.hikari.HikariDataSource;
4+
import com.zaxxer.hikari.HikariPoolMXBean;
5+
import org.slf4j.Logger;
6+
import org.slf4j.LoggerFactory;
7+
import org.springframework.core.io.ClassPathResource;
8+
import org.springframework.jdbc.core.JdbcTemplate;
9+
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
10+
import org.springframework.stereotype.Service;
11+
12+
import javax.sql.DataSource;
13+
14+
/**
15+
* Service that shuts down and reopens the H2 database in-place, allowing the
16+
* underlying file to be replaced at runtime (e.g. after a Google Drive download).
17+
*
18+
* <p>The reload cycle is:
19+
* <ol>
20+
* <li>{@code SHUTDOWN} — H2 closes its engine (caches flushed, file lock released).</li>
21+
* <li>Soft-evict all pooled connections so HikariCP discards them.</li>
22+
* <li>A test query forces HikariCP to create a fresh connection, which makes
23+
* H2 open the (now-replaced) database file.</li>
24+
* <li>Schema migrations ({@code schema.sql}) are re-applied to ensure the
25+
* downloaded database has all required columns/indexes.</li>
26+
* </ol>
27+
*/
28+
@Service
29+
public class DatabaseReloadService {
30+
31+
private static final Logger logger = LoggerFactory.getLogger(DatabaseReloadService.class);
32+
33+
private final DataSource dataSource;
34+
private final JdbcTemplate jdbcTemplate;
35+
36+
public DatabaseReloadService(DataSource dataSource, JdbcTemplate jdbcTemplate) {
37+
this.dataSource = dataSource;
38+
this.jdbcTemplate = jdbcTemplate;
39+
}
40+
41+
/**
42+
* Shuts down the H2 engine and evicts all pooled connections, releasing the
43+
* file lock so the {@code .mv.db} file can be safely replaced on any OS.
44+
* Must be followed by a call to {@link #reconnect()} once the file is ready.
45+
*/
46+
public void shutdown() {
47+
logger.info("Shutting down H2 database (releasing file lock)…");
48+
49+
// 1. Shut down H2 — all pooled connections become invalid
50+
try {
51+
jdbcTemplate.execute("SHUTDOWN");
52+
} catch (Exception e) {
53+
// The executing connection is killed by H2's SHUTDOWN — expected
54+
logger.debug("H2 SHUTDOWN completed (exception expected): {}", e.getMessage());
55+
}
56+
57+
// 2. Tell HikariCP to discard every idle/returned connection
58+
if (dataSource instanceof HikariDataSource hikari) {
59+
HikariPoolMXBean pool = hikari.getHikariPoolMXBean();
60+
if (pool != null) {
61+
pool.softEvictConnections();
62+
}
63+
}
64+
}
65+
66+
/**
67+
* Reconnects to the H2 database file (which may have been replaced since
68+
* {@link #shutdown()}) and re-applies schema migrations.
69+
*/
70+
public void reconnect() {
71+
logger.info("Reconnecting to H2 database…");
72+
73+
// Force a fresh connection — H2 opens the (possibly replaced) file
74+
try {
75+
jdbcTemplate.queryForObject("SELECT 1", Integer.class);
76+
} catch (Exception e) {
77+
logger.error("Failed to reconnect after reload: {}", e.getMessage());
78+
throw new RuntimeException("Database reload failed — application may need a restart", e);
79+
}
80+
81+
// Run idempotent schema.sql to apply any missing migrations
82+
runMigrations();
83+
84+
logger.info("Database reconnected and ready");
85+
}
86+
87+
/**
88+
* Convenience method: shuts down H2, then immediately reconnects.
89+
* Use when the file has already been replaced before this call.
90+
*/
91+
public void reloadDatabase() {
92+
shutdown();
93+
reconnect();
94+
}
95+
96+
/**
97+
* Re-applies {@code schema.sql} (CREATE IF NOT EXISTS / ALTER ADD IF NOT EXISTS)
98+
* so that a downloaded database from an older schema version gets upgraded.
99+
*/
100+
private void runMigrations() {
101+
try {
102+
ResourceDatabasePopulator populator = new ResourceDatabasePopulator();
103+
populator.addScript(new ClassPathResource("schema.sql"));
104+
populator.setContinueOnError(true); // individual failures are non-fatal
105+
populator.setSeparator(";");
106+
populator.execute(dataSource);
107+
logger.info("Schema migrations re-applied after database reload (continueOnError=true; individual statement failures were silently ignored)");
108+
} catch (Exception e) {
109+
logger.error("Failed to re-apply schema.sql after reload — the database may be missing columns/tables", e);
110+
}
111+
}
112+
}

src/main/java/com/studysync/domain/service/ProjectService.java

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44
import com.studysync.domain.entity.ProjectSession;
55
import com.studysync.domain.valueobject.ProjectStatus;
66
import com.studysync.domain.service.ProjectSessionEnd;
7+
import com.studysync.integration.drive.GoogleDriveService;
78
import org.springframework.beans.factory.annotation.Autowired;
89
import org.springframework.stereotype.Service;
910
import org.springframework.transaction.annotation.Transactional;
11+
import org.springframework.transaction.support.TransactionSynchronization;
12+
import org.springframework.transaction.support.TransactionSynchronizationManager;
1013

1114
import java.time.LocalDate;
1215
import java.time.LocalDateTime;
@@ -23,8 +26,24 @@
2326
@Transactional
2427
public class ProjectService {
2528

29+
private final GoogleDriveService googleDriveService;
30+
2631
@Autowired
27-
public ProjectService() {
32+
public ProjectService(GoogleDriveService googleDriveService) {
33+
this.googleDriveService = googleDriveService;
34+
}
35+
36+
private void markDirty() {
37+
if (TransactionSynchronizationManager.isSynchronizationActive()) {
38+
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
39+
@Override
40+
public void afterCommit() {
41+
googleDriveService.markLocalDbDirty();
42+
}
43+
});
44+
} else {
45+
googleDriveService.markLocalDbDirty();
46+
}
2847
}
2948

3049
@Transactional(readOnly = true)
@@ -50,20 +69,23 @@ public void addProject(Project project) {
5069
throw new IllegalArgumentException("Project title cannot be empty");
5170
}
5271
project.save();
72+
markDirty();
5373
}
5474

5575
public void updateProject(Project project) {
5676
if (project == null) {
5777
throw new IllegalArgumentException("Project cannot be null");
5878
}
5979
project.save();
80+
markDirty();
6081
}
6182

6283
public void deleteProject(String projectId) {
6384
// Delete all associated sessions first
6485
ProjectSession.deleteByProjectId(projectId);
6586
// Then delete the project
6687
Project.deleteById(projectId);
88+
markDirty();
6789
}
6890

6991
@Transactional(readOnly = true)
@@ -92,6 +114,7 @@ public ProjectSession startProjectSession(String projectId) {
92114
ProjectSession session = new ProjectSession(projectId);
93115
session.setStartTime(LocalDateTime.now());
94116
session.save(); // Model handles its own persistence
117+
markDirty();
95118
return session;
96119
}
97120

@@ -120,6 +143,7 @@ public void endProjectSession(ProjectSession session, ProjectSessionEnd endDetai
120143
project.addWorkedMinutes(existingSession.getDurationMinutes());
121144
project.incrementSessionCount();
122145
project.save();
146+
markDirty();
123147
}
124148

125149
public void deleteProjectSession(String sessionId) {
@@ -135,6 +159,7 @@ public void deleteProjectSession(String sessionId) {
135159

136160
// Delete the session
137161
ProjectSession.deleteById(sessionId);
162+
markDirty();
138163
}
139164

140165
@Transactional(readOnly = true)

src/main/java/com/studysync/domain/service/StudyService.java

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
import org.springframework.beans.factory.annotation.Autowired;
1212
import org.springframework.stereotype.Service;
1313
import org.springframework.transaction.annotation.Transactional;
14+
import org.springframework.transaction.support.TransactionSynchronization;
15+
import org.springframework.transaction.support.TransactionSynchronizationManager;
1416

1517
import java.time.LocalDate;
1618
import java.time.LocalDateTime;
@@ -39,6 +41,19 @@ public StudyService(GoogleDriveService googleDriveService) {
3941
this.googleDriveService = googleDriveService;
4042
}
4143

44+
private void markDirty() {
45+
if (TransactionSynchronizationManager.isSynchronizationActive()) {
46+
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
47+
@Override
48+
public void afterCommit() {
49+
googleDriveService.markLocalDbDirty();
50+
}
51+
});
52+
} else {
53+
googleDriveService.markLocalDbDirty();
54+
}
55+
}
56+
4257
@Transactional(readOnly = true)
4358
public List<StudySession> getStudySessions() {
4459
return StudySession.findAll();
@@ -111,6 +126,7 @@ public void addStudyGoal(String description, LocalDate date, String taskId) {
111126
}
112127
StudyGoal goal = new StudyGoal(null, date, description, false, null, 0, false, 0, taskId);
113128
goal.save();
129+
markDirty();
114130
}
115131

116132
public void updateStudyGoalAchievement(String goalId, boolean achieved, String reasonIfNot) {
@@ -120,6 +136,7 @@ public void updateStudyGoalAchievement(String goalId, boolean achieved, String r
120136
goal.setAchieved(achieved);
121137
goal.setReasonIfNotAchieved(reasonIfNot);
122138
goal.save();
139+
markDirty();
123140
}
124141
}
125142

@@ -129,7 +146,7 @@ public boolean deleteStudyGoal(String goalId) {
129146
}
130147
boolean deleted = StudyGoal.deleteById(goalId);
131148
if (deleted) {
132-
// Deleted
149+
markDirty();
133150
} else {
134151
logger.warn("Requested deletion for study goal '{}' but it did not exist", goalId);
135152
}
@@ -140,6 +157,7 @@ public StudySession startStudySession() {
140157
StudySession session = new StudySession();
141158
session.startSession();
142159
session.save(); // Model handles its own persistence
160+
markDirty();
143161
return session;
144162
}
145163

@@ -153,10 +171,12 @@ public void endStudySession(StudySession session, StudySessionEnd endDetails) {
153171

154172
// Save to database
155173
session.save();
174+
markDirty();
156175
}
157176

158177
public void addDailyReflection(DailyReflection reflection) {
159178
reflection.save();
179+
markDirty();
160180
}
161181

162182
@Transactional(readOnly = true)
@@ -177,7 +197,7 @@ public List<DailyReflection> getRecentDailyReflections(int days) {
177197
public void deleteDailyReflection(LocalDate date) {
178198
boolean deleted = DailyReflection.deleteByDate(date);
179199
if (deleted) {
180-
// Deleted
200+
markDirty();
181201
}
182202
}
183203

@@ -200,7 +220,7 @@ public int calculateDailyProgress() {
200220
public void deleteStudySession(String sessionId) {
201221
boolean deleted = StudySession.deleteById(sessionId);
202222
if (deleted) {
203-
// Deleted
223+
markDirty();
204224
}
205225
}
206226

src/main/java/com/studysync/domain/service/TaskService.java

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@
44
import com.studysync.domain.entity.Task;
55
import com.studysync.domain.valueobject.TaskPriority;
66
import com.studysync.domain.valueobject.TaskStatus;
7+
import com.studysync.integration.drive.GoogleDriveService;
78
import org.slf4j.Logger;
89
import org.slf4j.LoggerFactory;
910
import org.springframework.beans.factory.annotation.Autowired;
1011
import org.springframework.stereotype.Service;
1112
import org.springframework.transaction.annotation.Transactional;
13+
import org.springframework.transaction.support.TransactionSynchronization;
14+
import org.springframework.transaction.support.TransactionSynchronizationManager;
1215
import org.springframework.validation.annotation.Validated;
1316
import jakarta.validation.Valid;
1417
import jakarta.validation.constraints.NotNull;
@@ -31,10 +34,25 @@ public class TaskService {
3134
private static final Logger logger = LoggerFactory.getLogger(TaskService.class);
3235

3336
private final CategoryService categoryService;
37+
private final GoogleDriveService googleDriveService;
3438

3539
@Autowired
36-
public TaskService(CategoryService categoryService) {
40+
public TaskService(CategoryService categoryService, GoogleDriveService googleDriveService) {
3741
this.categoryService = categoryService;
42+
this.googleDriveService = googleDriveService;
43+
}
44+
45+
private void markDirty() {
46+
if (TransactionSynchronizationManager.isSynchronizationActive()) {
47+
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
48+
@Override
49+
public void afterCommit() {
50+
googleDriveService.markLocalDbDirty();
51+
}
52+
});
53+
} else {
54+
googleDriveService.markLocalDbDirty();
55+
}
3856
}
3957

4058
@Transactional(readOnly = true)
@@ -66,7 +84,7 @@ public Task addTask(@Valid @NotNull Task task) {
6684

6785
logger.info("Successfully added task '{}' with priority {} and status {}",
6886
savedTask.getTitle(), savedTask.getPriority().stars(), savedTask.getStatus());
69-
87+
markDirty();
7088
return savedTask;
7189
}
7290

@@ -85,6 +103,7 @@ public void removeTask(@NotNull Task task) {
85103
}
86104

87105
logger.info("Removed task: {}", task.getTitle());
106+
markDirty();
88107
}
89108

90109
@Transactional
@@ -95,6 +114,7 @@ public Task updateTask(@NotNull Task task, @NotNull TaskUpdate update) {
95114

96115
Task savedTask = finalTask.save();
97116
logger.info("Updated task: {}", savedTask.getTitle());
117+
markDirty();
98118
return savedTask;
99119
}
100120

@@ -110,6 +130,7 @@ public void updateTaskStatus(@NotNull Task task, @NotNull TaskStatus newStatus)
110130
}
111131

112132
logger.info("Updated task status for '{}' to {}", task.getTitle(), newStatus);
133+
markDirty();
113134
}
114135

115136
@Transactional(readOnly = true)
@@ -168,6 +189,7 @@ public int markDelayedTasks() {
168189

169190
if (updatedCount > 0) {
170191
logger.info("Marked {} tasks as DELAYED", updatedCount);
192+
markDirty();
171193
}
172194

173195
return updatedCount;

0 commit comments

Comments
 (0)