Skip to content

feat(datastore): add explicit device-scoped SQLite handles - #183

Draft
TimeToBuildBob wants to merge 17 commits into
ActivityWatch:masterfrom
TimeToBuildBob:feat/device-addressing-143e
Draft

TimeToBuildBob wants to merge 17 commits into
ActivityWatch:masterfrom
TimeToBuildBob:feat/device-addressing-143e

Conversation

@TimeToBuildBob

@TimeToBuildBob TimeToBuildBob commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Storage-side Python parity groundwork for ActivityWatch/activitywatch#302 and draft ActivityWatch/aw-server-rust#836. Stacked on #177 at c6f6a20fb4a9; do not merge before that schema PR. This draft does not claim Python HTTP parity or enable watcher/V2 adoption.

  • Explicit Datastore.for_device and SqliteStorage.for_device handles select (device_id, id) without encoded string keys. Scoped handles share the connection/commit owner but have separate Bucket-object caches.
  • Resolve event read/write/count/iterator/replace/delete and metadata/bucket operations to their exact scoped bucket row. Unscoped ambiguous names fail before mutation or lossy listing. Cached global Bucket objects cannot bypass a newly ambiguous name.
  • device_buckets() exposes grouped storage metadata. Unsupported Memory/Peewee scoped routing fails closed.
  • Preserve missing-bucket event return contracts, validate empty device IDs before insertion, and keep replace-last selection inside its bucket row.
  • cfd2db8 adds exact-first bidirectional hostname aliases for SQLite lookups and all event operations. Complete underscore-bearing hostname metadata is matched, unknown names excluded, stored IDs preserved. Creation refuses silent alias splits, global alias redirection and acknowledgement of a different scoped/global target.
  • 4ac2146 adds SqliteStorage.snapshot(): a dedicated query-only connection that pins a WAL reader snapshot after a real table read (sqlite_master, not SELECT 1). Long-lived exports can release the writer without seeing later commits.
  • README records single-thread ownership and remaining server integration. Storage performance test still prohibits SELECT-before-UPDATE of events, while allowing the necessary bucket-identity lookup.

Verification

  • New storage/cache tests failed before implementation with missing for_device; cached-global ambiguity and two independent-review findings reproduced as failures before repair.
  • Snapshot isolation reproduced as a failure when the pin used constant SELECT 1; a sqlite_master read pins it. Isolated serial suite at 4ac2146: 392 passed, 5 skipped. Repository mypy: 37 files passed. Pinned Ruff and whitespace checks pass.
  • Alias lookup, creation split and peer exact-name shadowing each reproduced as failures before repair. Alias review independently caught the shadowing case; repaired and regression-tested. Tests cover exact precedence, ambiguous global aliases, both directions, unknown/underscore hostnames and pre-existing conflicting rows.
  • Read-only cheap-model review found empty-device insertion-before-error and changed missing-bucket return contracts; both reproduced, repaired and regression-tested.
  • Brain make test-fast: 1505 passed, 1 skipped; make typecheck: 286 package / 1803 script files passed. These are not aw-core verification.
  • Initial overlapping suites hit shared default testing DB locks; stopped both owned runs and reran serially with isolated XDG_DATA_HOME. No production DB changed.

Remaining boundary

Python HTTP device routes, server UUID/sentinel normalization, grouped HTTP import/export, query/heartbeat cache integration, metadata-repair/alias interaction acceptance, non-SQLite parity and shared Rust/Python acceptance are not implemented here. The Rust draft keeps both global name guards; this preparatory Python storage API is not permission to remove them. Existing schema migration policy discussions on #177 remain separate; no migration policy is changed by this diff.

…_id, id))

Adds `device_id` to the `buckets` table so buckets from different devices can
share a bucket name without colliding. Python mirror of ActivityWatch/aw-server-rust#786.

**Before:** `UNIQUE(id)` — two devices' `aw-watcher-window_hostname` buckets
can't coexist in the same db.
**After:** `UNIQUE(device_id, id)` — each device's buckets are scoped independently.

Changes:
- `LATEST_VERSION` 1 → 2 (new file: `sqlite.v2.db`)
- `CREATE_BUCKETS_TABLE`: add `device_id TEXT NOT NULL DEFAULT 'local'`, replace
  inline `UNIQUE` on `id` with table-level `UNIQUE(device_id, id)`
- `create_bucket()`: add `device_id` param (default `"local"`)
- `buckets()`, `buckets_with_last_updated()`, `get_metadata()`: include `device_id`
- `migration.py`: add `sqlite_v1_to_v2()` to copy v1 data with `device_id='local'`;
  `check_for_migration()` now detects and runs it on first open of the new db file

Part of ActivityWatch/activitywatch#302 (hostname-free bucket IDs).

Git-Session-Id: 02ce
- Add device_id param to AbstractStorage.create_bucket() so mypy accepts
  migration.py's call with device_id="local" (fixes CI typecheck failure)
- Add device_id param to PeeweeStorage and MemoryStorage to satisfy override
  compatibility with the updated abstract signature
- Fix sqlite_v1_to_v2(): clear event.id before insert_many so events get
  INSERTed rather than UPDATE-attempted on an empty destination database
  (fixes migrated buckets silently having zero events — Greptile P1)
- Fix check_for_migration(): prefer SQLite v1 over Peewee; running both into
  the same destination caused IntegrityError on UNIQUE(device_id, id) when
  users had both files (Greptile P1)
- Add in-place schema upgrade in SqliteStorage.__init__() via ALTER TABLE ADD
  COLUMN so databases opened via custom filepath= get device_id without
  failing on SELECT/INSERT queries (Greptile P1)

Git-Session-Id: 5ec50786-5c09-5bb0-b4f1-0ffd16b82ac1
…UE(device_id,id) on legacy dbs; commit migration tail

Git-Session-Id: 0cec7590-494f-538b-9531-87ebf5dff828
…ned error

Git-Session-Id: 9a545a26-3da2-5263-b86a-df4f86f42f20
…ce_id in memory/peewee backends

- Migration: skip buckets already present in the v2 datastore and rename the
  v1 db to *.migrated-v1.db on success so it is not re-detected on next
  startup (re-running previously raised IntegrityError and blocked startup).
- MemoryStorage: include device_id in bucket metadata (API contract parity
  with the sqlite backend).
- PeeweeStorage: report device_id='local' in json() without a schema change
  (existing peewee DBs have no such column).
- Add tests/test_sqlite_migration.py covering migration, rename, and rerun.

Git-Session-Id: b4d76fbc-0b40-5323-a560-63560f9e7534
Git-Session-Id: 44e46ae0-002f-5190-958f-74c5450808fa
Git-Session-Id: d421133f-910e-51a5-b6fd-2d6bb107f2e6
Git-Session-Id: d421133f-910e-51a5-b6fd-2d6bb107f2e6
Git-Session-Id: ee485138-49ef-455b-9c15-d08a96a24bd7
Comment thread tests/test_device_addressing.py Fixed
Comment thread tests/test_device_addressing.py Fixed
Comment thread tests/test_device_addressing.py Fixed
Comment thread tests/test_device_addressing.py Fixed
Comment thread tests/test_device_addressing.py Fixed
@greptile-apps

greptile-apps Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 2/5

[Medium impact] The changes reach SQLite-backed datastore workflows and existing database startup.

The PR needs fixes for scoped creation, concurrent alias creation, and snapshot reads before merging.

Findings

  1. P1 Third device creation fails ▶
  2. P1 Concurrent creation splits history ▶
  3. P1 Ordinary reads end snapshots ▶

Summary

Adds device-scoped SQLite handles and hostname aliases while keeping stored bucket names unchanged.

  • Callers can keep same-named bucket histories separate by device.
  • SQLite exports can keep reading one view while writes continue.
  • SQLite upgrades old bucket tables and copies legacy data into the local device.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    D[Datastore] --> S[Device-scoped handle]
    S --> C[Shared writer connection]
    C --> B[Bucket selected by device and name]
    B --> E[Events selected by bucket row]
    C --> P[snapshot]
    P --> R[Separate query-only connection]
    R --> T[Pinned read transaction]
    T --> I[iter_events with flush false]
    T --> G[Ordinary event reads]
    G --> X[Commit ends snapshot]
Loading

Reviews (2) · Last reviewed commit: "feat(sqlite): add a dedicated read-only ..." · Reviewed by Greptile

Comment thread aw_datastore/storages/sqlite.py Outdated
Comment thread aw_datastore/storages/memory.py
Comment thread tests/test_sqlite_migration.py
Git-Session-Id: ee485138-49ef-455b-9c15-d08a96a24bd7
Comment thread tests/test_device_addressing.py Fixed
Git-Session-Id: 9d1828c6-192e-58ea-946a-4405cde28acf
Git-Session-Id: 931478ca-2d2b-48c5-bb99-0bb5ad608478
Open an independent query-only connection that pins a WAL reader
snapshot so long-lived exports do not hold the writer connection.

Git-Session-Id: aeaa4ffa-df33-4fe0-8487-f35c72996602
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

Comment thread aw_datastore/storages/sqlite.py Outdated
Comment on lines +378 to +380
unscoped = copy.copy(self)
unscoped.device_scope = None
global_row = unscoped._bucket_row(bucket_id, required=False)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Third device creation fails

Once two devices have a bucket named window, for_device("third").create_bucket("window", ...) always fails. The global lookup raises on the two existing exact matches, even though the caller selected a device and that device has no bucket yet. This blocks the same-name device routing this PR adds. Check for alias redirection without requiring global exact names to be unique.

Comment thread aw_datastore/storages/sqlite.py Outdated
device_id = self.device_scope
if not device_id:
raise ValueError("Device scope must not be empty")
selected = self.for_device(device_id)._bucket_row(bucket_id, required=False)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Concurrent creation splits history

The alias checks run before creation acquires a write lock. Two connections can both pass them while the names are absent, then create window_host and window with hostname host on the same device. Both inserts satisfy the database constraint, but they split the history that sequential creation rejects. Hold the write lock across the checks and insertion, as the event insert paths now do.

Comment on lines +225 to +231
snap.conn.execute("PRAGMA query_only=ON")
snap._owner = None
snap.enable_lazy_commit = False
# Deferred BEGIN does not pin a WAL snapshot until a real table read.
# `SELECT 1` is a constant and does not establish the reader snapshot.
snap.conn.execute("BEGIN")
snap.conn.execute("SELECT 1 FROM sqlite_master LIMIT 1").fetchone()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Ordinary reads end snapshots

The new snapshot() handle loses its pinned view when used with get_event, get_events, or get_eventcount. Each method calls commit(), which ends the snapshot's read transaction because its _owner is None. Later writer commits can then appear in these reads and subsequent export iteration, even with flush=False, mixing data from different times. Keep automatic read-time commits from ending snapshot transactions.

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Convergence adjudication: do not merge yet

  • Fixed in this session: none. No non-blocking dismissals: all three current findings reproduce as correctness bugs.
  • Remaining blockers (P1):
    1. Third-device creation: after scoped window creation on first and second, scoped creation on third raises ValueError: Ambiguous bucket 'window'; select a device. The unscoped check at sqlite.py:380 rejects valid device-scoped creation. Preserve alias-redirection protection without requiring global exact-name uniqueness.
    2. Concurrent alias split: with a second real SQLite connection invoked immediately before the first connection's bucket INSERT, both window_host and window (hostname host, same device) are created successfully. The checks and INSERT need the same writer-lock boundary; sequential alias protection alone is insufficient.
    3. Snapshot consistency: call snapshot.get_eventcount('window'), commit a new event through the writer, then call the count again: it changes from 0 to 1. Ordinary read-time commits end the pinned transaction. Snapshot handles must retain their view across ordinary reads and subsequent export iteration.
  • Verified against: 4ac214618dd9e93cc8e054b4e797ad70df96f641, also the newest Greptile summary's reviewed commit. There is no bob-ai-review marker comment. Three isolated pytest reproductions all failed with the behaviors above; temporary databases only.
  • CI: all seven current-head checks report success, including all three platform test jobs, lint and CodeQL. Those existing checks do not cover these reproductions.
  • Domain risk: same-name multi-device creation, concurrent hostname-alias creation, and mixed ordinary-read/streaming snapshot exports. The PR remains a draft stacked on feat(datastore): add device_id column, v1→v2 migration (UNIQUE(device_id, id)) #177; no merge recommendation for either migration policy or full HTTP parity is implied.
  • Convergence: detector round_convergence.stable_rounds = 0 (required 2), status new_blocking; three new blockers in the latest pass. No further Greptile review was triggered.

Repair handoff: the PR branch and worktree are still leased to the existing implementation owner until 14:16 UTC. I requested a handoff and sent the three executable reproductions through coordination rather than bypassing that ownership. The three valid Greptile threads remain open pending actual fixes; our own reviewer has no open findings to dispose.

Git-Session-Id: 0dc232f4-6721-4652-9e55-bd51fe8eec69
Git-Session-Id: 0dc232f4-6721-4652-9e55-bd51fe8eec69
Git-Session-Id: 0dc232f4-6721-4652-9e55-bd51fe8eec69
Git-Session-Id: c2d218fa-690b-4e77-96e3-959267ad65f3
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Integrated the previously leased follow-up #184 into this branch; both are now at 26001d3. This includes all three reproduced creation/snapshot P1 repairs, archive microsecond/pre-epoch preservation, and WAL-safe migration source discovery. Shared real-HTTP testing against Rust then exposed a further alias-query failure: CRUD resolved hostname aliases but query existence checked literal catalogue membership. 26001d3 uses has_bucket; both alias directions and device-isolated query results have regression coverage. Final isolated core suite: 431 passed / 5 skipped; repository mypy37, pinned Ruff and normal hooks pass. ActivityWatch/aw-server#200 pins and installs this exact Git SHA; its package slice passes 156 tests including eight shared HTTP cases with the Rust binary. This remains a draft stacked on #177; full identity/same-name Rust acceptance and release are not claimed.

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Freshness gate: deferring convergence adjudication. Current head 26001d3 is four commits ahead of the newest Greptile pass (4ac2146); no bob-ai-review marker exists. The integration update above reports repairs, but it is not current-head review provenance. All 11 CI checks pass; the detector still records three unresolved P1 threads and stable_rounds=0. No classifications, dismissals, thread resolutions or merge recommendation made on this unreviewed head, and no review retriggered. Await automatic current-head review before adjudication; keep this draft behind #177.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants