Skip to content

Commit 8f03f04

Browse files
fix(datastore): merge disjoint aw-watcher-android-test events on name collision (#661)
* fix(datastore): merge disjoint aw-watcher-android-test events UPDATE OR IGNORE skipped the collision case where both the legacy aw-watcher-android-test_* bucket and the canonical aw-watcher-android_* bucket exist, leaving years of history stranded after upgrades (ActivityWatch/aw-android#243). Move non-overlapping legacy events into the canonical bucket, leave overlapping events in the legacy bucket, and delete the legacy bucket only once it is empty. Replace the in-memory bucket cache from SQLite after the rewrite so deleted buckets disappear. Refs: ActivityWatch/aw-android#149, ActivityWatch/aw-android#150 * fix(datastore): add busy_timeout to handle Windows database locking Set a 5-second busy_timeout after opening SQLite connections to allow the database to wait for locks rather than immediately failing with DatabaseBusy errors. This fixes test failures on Windows where multiple connections to the same database file can create lock contention. Fixes windows-latest CI failure in sync_roundtrip tests. * fix(datastore): retain overlapping legacy events
1 parent 757e067 commit 8f03f04

3 files changed

Lines changed: 273 additions & 27 deletions

File tree

aw-datastore/src/datastore.rs

Lines changed: 104 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -318,10 +318,11 @@ impl DatastoreInstance {
318318
)))
319319
}
320320
};
321+
let mut new_cache = HashMap::new();
321322
for bucket in buckets {
322323
match bucket {
323324
Ok(b) => {
324-
self.buckets_cache.insert(b.id.clone(), b.clone());
325+
new_cache.insert(b.id.clone(), b);
325326
}
326327
Err(e) => {
327328
return Err(DatastoreError::InternalError(format!(
@@ -330,6 +331,7 @@ impl DatastoreInstance {
330331
}
331332
}
332333
}
334+
self.buckets_cache = new_cache;
333335
Ok(())
334336
}
335337

@@ -1113,39 +1115,114 @@ impl DatastoreInstance {
11131115
}
11141116

11151117
/// Migrates all buckets whose name starts with `aw-watcher-android-test` to use
1116-
/// `aw-watcher-android` instead. This covers the old debug-build bucket naming
1117-
/// convention (e.g. `aw-watcher-android-test_hostname` → `aw-watcher-android_hostname`).
1118-
/// Events are left untouched; only the bucket metadata is updated.
1119-
/// Returns the number of buckets that were migrated.
1120-
/// Note: if a UNIQUE constraint violation occurs on any single row, `UPDATE OR IGNORE`
1121-
/// will skip conflicting rows instead of aborting the entire batch.
1118+
/// `aw-watcher-android` instead. This covers the old production bucket naming
1119+
/// convention (e.g. `aw-watcher-android-test_phone` → `aw-watcher-android_phone`).
1120+
///
1121+
/// If the destination already exists, disjoint legacy events are moved into it.
1122+
/// Events that overlap a destination or another legacy event stay in the legacy
1123+
/// bucket so the migration cannot create overlapping activity records. The legacy
1124+
/// bucket is deleted only after it is empty.
1125+
/// Returns the number of legacy buckets that were fully renamed or merged.
11221126
pub fn migrate_test_bucket_names(
11231127
&mut self,
11241128
conn: &Connection,
11251129
) -> Result<usize, DatastoreError> {
1126-
info!("Migrating 'aw-watcher-android-test' bucket names to 'aw-watcher-android'");
1127-
1128-
let updated = match conn.execute(
1129-
"UPDATE OR IGNORE buckets SET name = 'aw-watcher-android' || SUBSTR(name, LENGTH('aw-watcher-android-test') + 1) \
1130-
WHERE name LIKE 'aw-watcher-android-test%'",
1131-
[],
1132-
) {
1133-
Ok(n) => n,
1134-
Err(err) => {
1135-
return Err(DatastoreError::InternalError(format!(
1136-
"Failed to migrate test bucket names: {err}"
1137-
)))
1130+
const OLD_PREFIX: &str = "aw-watcher-android-test";
1131+
const NEW_PREFIX: &str = "aw-watcher-android";
1132+
1133+
info!("Migrating '{OLD_PREFIX}' bucket names to '{NEW_PREFIX}'");
1134+
let legacy_ids: Vec<String> = self
1135+
.buckets_cache
1136+
.keys()
1137+
.filter(|id| id.starts_with(OLD_PREFIX))
1138+
.cloned()
1139+
.collect();
1140+
let mut migrated = 0;
1141+
let mut cache_dirty = false;
1142+
1143+
for old_id in legacy_ids {
1144+
let new_id = old_id.replacen(OLD_PREFIX, NEW_PREFIX, 1);
1145+
if let Some(new_bucket) = self.buckets_cache.get(&new_id).cloned() {
1146+
let old_bucket = self
1147+
.buckets_cache
1148+
.get(&old_id)
1149+
.cloned()
1150+
.ok_or_else(|| DatastoreError::NoSuchBucket(old_id.clone()))?;
1151+
1152+
// Move only events that do not overlap the destination or another
1153+
// legacy event. A single overlapping cutover heartbeat must not strand
1154+
// years of disjoint history in the legacy bucket (ActivityWatch/aw-android#243).
1155+
conn.execute(
1156+
"UPDATE events SET bucketrow = ?1
1157+
WHERE id IN (
1158+
SELECT old_event.id FROM events AS old_event
1159+
WHERE old_event.bucketrow = ?2
1160+
AND NOT EXISTS (
1161+
SELECT 1 FROM events AS other_event
1162+
WHERE other_event.id != old_event.id
1163+
AND other_event.bucketrow IN (?1, ?2)
1164+
AND old_event.starttime < other_event.endtime
1165+
AND other_event.starttime < old_event.endtime
1166+
)
1167+
)",
1168+
[new_bucket.bid, old_bucket.bid],
1169+
)
1170+
.map_err(|err| {
1171+
DatastoreError::InternalError(format!(
1172+
"Failed to merge bucket '{}' into '{}': {err}",
1173+
old_id, new_id
1174+
))
1175+
})?;
1176+
cache_dirty = true;
1177+
1178+
let remaining: i64 = conn
1179+
.query_row(
1180+
"SELECT COUNT(*) FROM events WHERE bucketrow = ?1",
1181+
[old_bucket.bid],
1182+
|row| row.get(0),
1183+
)
1184+
.map_err(|err| {
1185+
DatastoreError::InternalError(format!(
1186+
"Failed to count leftover events in '{}': {err}",
1187+
old_id
1188+
))
1189+
})?;
1190+
if remaining == 0 {
1191+
conn.execute("DELETE FROM buckets WHERE id = ?1", [old_bucket.bid])
1192+
.map_err(|err| {
1193+
DatastoreError::InternalError(format!(
1194+
"Failed to remove merged bucket '{}': {err}",
1195+
old_id
1196+
))
1197+
})?;
1198+
info!("Merged legacy bucket '{}' into '{}'", old_id, new_id);
1199+
migrated += 1;
1200+
} else {
1201+
warn!(
1202+
"Partially merged '{}' into '{}'; {} overlapping event(s) remain in the legacy bucket",
1203+
old_id, new_id, remaining
1204+
);
1205+
}
1206+
} else {
1207+
conn.execute(
1208+
"UPDATE buckets SET name = ?1 WHERE name = ?2",
1209+
[&new_id, &old_id],
1210+
)
1211+
.map_err(|err| {
1212+
DatastoreError::InternalError(format!(
1213+
"Failed to rename bucket '{}' to '{}': {err}",
1214+
old_id, new_id
1215+
))
1216+
})?;
1217+
info!("Renamed legacy bucket '{}' to '{}'", old_id, new_id);
1218+
migrated += 1;
1219+
cache_dirty = true;
11381220
}
1139-
};
1221+
}
11401222

1141-
if updated > 0 {
1142-
info!("Migrated {} 'aw-watcher-android-test' bucket(s)", updated);
1143-
// Refresh the in-memory cache so callers see the new names immediately.
1223+
if cache_dirty {
11441224
self.get_stored_buckets(conn)?;
1145-
} else {
1146-
info!("No 'aw-watcher-android-test' buckets found; nothing to migrate");
11471225
}
1148-
1149-
Ok(updated)
1226+
Ok(migrated)
11501227
}
11511228
}

aw-datastore/src/worker.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,10 @@ impl DatastoreWorker {
142142
}
143143
};
144144

145+
// Set busy timeout to handle concurrent access on systems with strict file locking (e.g., Windows)
146+
conn.busy_timeout(std::time::Duration::from_secs(5))
147+
.expect("Failed to set busy timeout");
148+
145149
// WAL turns each commit into a single sequential WAL append+fsync where
146150
// delete mode paid two fsyncs plus journal-file churn, and lets future
147151
// reader connections proceed while a commit is in flight.

aw-datastore/tests/datastore.rs

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,171 @@ mod datastore_tests {
6060
bucket
6161
}
6262

63+
fn create_named_test_bucket(ds: &Datastore, id: &str) -> Bucket {
64+
let mut bucket = test_bucket();
65+
bucket.id = id.to_string();
66+
ds.create_bucket(&bucket).unwrap();
67+
bucket
68+
}
69+
70+
fn test_event(timestamp: chrono::DateTime<Utc>, duration: Duration) -> Event {
71+
Event {
72+
id: None,
73+
timestamp,
74+
duration,
75+
data: json_map! {"key": json!("value")},
76+
}
77+
}
78+
79+
#[test]
80+
fn test_migrate_test_bucket_names_renames_bucket_and_preserves_events() {
81+
let ds = Datastore::new_in_memory(false);
82+
let old_id = "aw-watcher-android-test_phone";
83+
let new_id = "aw-watcher-android_phone";
84+
create_named_test_bucket(&ds, old_id);
85+
let event = test_event(Utc::now(), Duration::seconds(30));
86+
ds.insert_events(old_id, std::slice::from_ref(&event))
87+
.unwrap();
88+
89+
assert_eq!(ds.migrate_test_bucket_names().unwrap(), 1);
90+
assert!(!ds.get_buckets().unwrap().contains_key(old_id));
91+
assert!(ds.get_buckets().unwrap().contains_key(new_id));
92+
assert_eq!(ds.get_events(new_id, None, None, None).unwrap().len(), 1);
93+
}
94+
95+
#[test]
96+
fn test_migrate_test_bucket_names_merges_non_overlapping_buckets() {
97+
let ds = Datastore::new_in_memory(false);
98+
let old_id = "aw-watcher-android-test_phone";
99+
let new_id = "aw-watcher-android_phone";
100+
create_named_test_bucket(&ds, old_id);
101+
create_named_test_bucket(&ds, new_id);
102+
let now = Utc::now();
103+
ds.insert_events(
104+
old_id,
105+
&[test_event(now - Duration::hours(2), Duration::minutes(30))],
106+
)
107+
.unwrap();
108+
ds.insert_events(new_id, &[test_event(now, Duration::minutes(30))])
109+
.unwrap();
110+
111+
assert_eq!(ds.migrate_test_bucket_names().unwrap(), 1);
112+
assert!(!ds.get_buckets().unwrap().contains_key(old_id));
113+
assert_eq!(ds.get_events(new_id, None, None, None).unwrap().len(), 2);
114+
}
115+
116+
#[test]
117+
fn test_migrate_test_bucket_names_keeps_overlapping_buckets_separate() {
118+
let ds = Datastore::new_in_memory(false);
119+
let old_id = "aw-watcher-android-test_phone";
120+
let new_id = "aw-watcher-android_phone";
121+
create_named_test_bucket(&ds, old_id);
122+
create_named_test_bucket(&ds, new_id);
123+
let now = Utc::now();
124+
ds.insert_events(old_id, &[test_event(now, Duration::minutes(30))])
125+
.unwrap();
126+
ds.insert_events(
127+
new_id,
128+
&[test_event(
129+
now + Duration::minutes(15),
130+
Duration::minutes(30),
131+
)],
132+
)
133+
.unwrap();
134+
135+
assert_eq!(ds.migrate_test_bucket_names().unwrap(), 0);
136+
assert!(ds.get_buckets().unwrap().contains_key(old_id));
137+
assert_eq!(ds.get_events(old_id, None, None, None).unwrap().len(), 1);
138+
assert_eq!(ds.get_events(new_id, None, None, None).unwrap().len(), 1);
139+
}
140+
141+
#[test]
142+
fn test_migrate_test_bucket_names_moves_disjoint_events_when_some_overlap() {
143+
let ds = Datastore::new_in_memory(false);
144+
let old_id = "aw-watcher-android-test_phone";
145+
let new_id = "aw-watcher-android_phone";
146+
create_named_test_bucket(&ds, old_id);
147+
create_named_test_bucket(&ds, new_id);
148+
let now = Utc::now();
149+
ds.insert_events(
150+
old_id,
151+
&[
152+
test_event(now - Duration::hours(2), Duration::minutes(30)),
153+
test_event(now, Duration::minutes(30)),
154+
],
155+
)
156+
.unwrap();
157+
ds.insert_events(
158+
new_id,
159+
&[test_event(
160+
now + Duration::minutes(15),
161+
Duration::minutes(30),
162+
)],
163+
)
164+
.unwrap();
165+
166+
assert_eq!(ds.migrate_test_bucket_names().unwrap(), 0);
167+
assert!(ds.get_buckets().unwrap().contains_key(old_id));
168+
assert_eq!(ds.get_events(old_id, None, None, None).unwrap().len(), 1);
169+
assert_eq!(ds.get_events(new_id, None, None, None).unwrap().len(), 2);
170+
}
171+
172+
#[test]
173+
fn test_migrate_test_bucket_names_keeps_overlapping_legacy_events_together() {
174+
let ds = Datastore::new_in_memory(false);
175+
let old_id = "aw-watcher-android-test_phone";
176+
let new_id = "aw-watcher-android_phone";
177+
create_named_test_bucket(&ds, old_id);
178+
create_named_test_bucket(&ds, new_id);
179+
let now = Utc::now();
180+
ds.insert_events(
181+
old_id,
182+
&[
183+
test_event(now - Duration::hours(3), Duration::minutes(20)),
184+
test_event(now, Duration::minutes(30)),
185+
test_event(now + Duration::minutes(15), Duration::minutes(30)),
186+
],
187+
)
188+
.unwrap();
189+
ds.insert_events(
190+
new_id,
191+
&[test_event(now + Duration::hours(2), Duration::minutes(20))],
192+
)
193+
.unwrap();
194+
195+
assert_eq!(ds.migrate_test_bucket_names().unwrap(), 0);
196+
assert!(ds.get_buckets().unwrap().contains_key(old_id));
197+
assert_eq!(ds.get_events(old_id, None, None, None).unwrap().len(), 2);
198+
assert_eq!(ds.get_events(new_id, None, None, None).unwrap().len(), 2);
199+
}
200+
201+
#[test]
202+
fn test_migrate_test_bucket_names_merges_interleaved_non_overlapping_events() {
203+
let ds = Datastore::new_in_memory(false);
204+
let old_id = "aw-watcher-android-test_phone";
205+
let new_id = "aw-watcher-android_phone";
206+
create_named_test_bucket(&ds, old_id);
207+
create_named_test_bucket(&ds, new_id);
208+
let now = Utc::now();
209+
ds.insert_events(
210+
old_id,
211+
&[
212+
test_event(now - Duration::hours(2), Duration::minutes(20)),
213+
test_event(now, Duration::minutes(20)),
214+
],
215+
)
216+
.unwrap();
217+
ds.insert_events(
218+
new_id,
219+
&[test_event(now - Duration::hours(1), Duration::minutes(20))],
220+
)
221+
.unwrap();
222+
223+
assert_eq!(ds.migrate_test_bucket_names().unwrap(), 1);
224+
assert!(!ds.get_buckets().unwrap().contains_key(old_id));
225+
assert_eq!(ds.get_events(new_id, None, None, None).unwrap().len(), 3);
226+
}
227+
63228
#[test]
64229
fn test_bucket_create_delete() {
65230
// Setup datastore

0 commit comments

Comments
 (0)