Bug 1720151 - Hook up Snapshot handling to the real database. r=mak

Differential Revision: https://phabricator.services.mozilla.com/D119652
This commit is contained in:
Mark Banner 2021-07-14 16:18:37 +00:00
Родитель 0f85cc5da1
Коммит 317fbf055e
3 изменённых файлов: 231 добавлений и 76 удалений

Просмотреть файл

@ -49,6 +49,10 @@ const Snapshots = new (class Snapshots {
/**
* Adds a new snapshot.
*
* If the snapshot already exists, and this is a user-persisted addition,
* then the userPersisted flag will be set, and the removed_at flag will be
* cleared.
*
* @param {object} details
* @param {string} details.url
* The url associated with the snapshot.
@ -57,53 +61,48 @@ const Snapshots = new (class Snapshots {
* false.
*/
async add({ url, userPersisted = false }) {
let now = new Date();
let db = await PlacesUtils.promiseDBConnection();
let rows = await db.executeCached(
`
WITH places(place_id) AS (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url),
inserts(created_at, updated_at, document_type) AS (
VALUES(
(SELECT min(created_at) FROM moz_places_metadata WHERE place_id in places),
(SELECT max(updated_at) FROM moz_places_metadata WHERE place_id in places),
(SELECT document_type FROM moz_places_metadata WHERE place_id in places ORDER BY updated_at DESC LIMIT 1)
)
)
SELECT * from inserts WHERE created_at is not null
`,
{ url }
);
if (!rows.length) {
throw new Error("Could not find existing interactions");
if (!url) {
throw new Error("Missing url parameter to Snapshots.add()");
}
this.#snapshots.set(url, {
url,
userPersisted,
createdAt: now,
removedAt: null,
documentType: rows[0].getResultByName("document_type"),
firstInteractionAt: new Date(rows[0].getResultByName("created_at")),
lastInteractionAt: new Date(rows[0].getResultByName("updated_at")),
await PlacesUtils.withConnectionWrapper("Snapshots: add", async db => {
await db.executeCached(
`
INSERT INTO moz_places_metadata_snapshots
(place_id, first_interaction_at, last_interaction_at, document_type, created_at, user_persisted)
SELECT place_id, min(created_at), max(created_at),
first_value(document_type) OVER (PARTITION BY place_id ORDER BY created_at DESC),
:createdAt, :userPersisted
FROM moz_places_metadata
WHERE place_id = (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url)
ON CONFLICT DO UPDATE SET user_persisted = :userPersisted, removed_at = NULL WHERE :userPersisted = 1
`,
{ createdAt: Date.now(), url, userPersisted }
);
});
Services.obs.notifyObservers(null, "places-snapshot-added", url);
}
/**
* Deletes a snapshot, creating a tombstone.
* Deletes a snapshot, creating a tombstone. Note, the caller is expected
* to take account of the userPersisted value for a Snapshot when appropriate.
*
* @param {string} url
* The url of the snapshot to delete.
*/
async delete(url) {
let snapshot = this.#snapshots.get(url);
await PlacesUtils.withConnectionWrapper("Snapshots: delete", async db => {
await db.executeCached(
`
UPDATE moz_places_metadata_snapshots
SET removed_at = :removedAt
WHERE place_id = (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url)
`,
{ removedAt: Date.now(), url }
);
});
if (snapshot) {
snapshot.removedAt = new Date();
Services.obs.notifyObservers(null, "places-snapshot-deleted", url);
}
Services.obs.notifyObservers(null, "places-snapshot-deleted", url);
}
/**
@ -116,12 +115,30 @@ const Snapshots = new (class Snapshots {
* @returns {?Snapshot}
*/
async get(url, includeTombstones = false) {
let snapshot = this.#snapshots.get(url);
if (!snapshot || (snapshot.removedAt && !includeTombstones)) {
let db = await PlacesUtils.promiseDBConnection();
let extraWhereCondition = "";
if (!includeTombstones) {
extraWhereCondition = " AND removed_at IS NULL";
}
let rows = await db.executeCached(
`
SELECT h.url AS url, created_at, removed_at, document_type,
first_interaction_at, last_interaction_at,
user_persisted FROM moz_places_metadata_snapshots s
JOIN moz_places h ON h.id = s.place_id
WHERE h.url_hash = hash(:url) AND h.url = :url
${extraWhereCondition}
`,
{ url }
);
if (!rows.length) {
return null;
}
return snapshot;
return this.#translateRow(rows[0]);
}
/**
@ -136,11 +153,64 @@ const Snapshots = new (class Snapshots {
* Returns snapshots in order of descending last interaction time.
*/
async query({ limit = 100, includeTombstones = false } = {}) {
let snapshots = Array.from(this.#snapshots.values());
let db = await PlacesUtils.promiseDBConnection();
let whereStatement = "";
if (!includeTombstones) {
return snapshots.filter(s => !s.removedAt).slice(0, limit);
whereStatement = " WHERE removed_at IS NULL";
}
return snapshots.slice(0, limit);
let rows = await db.executeCached(
`
SELECT h.url AS url, created_at, removed_at, document_type,
first_interaction_at, last_interaction_at,
user_persisted FROM moz_places_metadata_snapshots s
JOIN moz_places h ON h.id = s.place_id
${whereStatement}
ORDER BY last_interaction_at DESC
LIMIT :limit
`,
{ limit }
);
return rows.map(row => this.#translateRow(row));
}
/**
* Translates a database row to a Snapshot.
*
* @param {object} row
* The database row to translate.
* @returns {Snapshot}
*/
#translateRow(row) {
return {
url: row.getResultByName("url"),
createdAt: this.#toDate(row.getResultByName("created_at")),
removedAt: this.#toDate(row.getResultByName("removed_at")),
firstInteractionAt: this.#toDate(
row.getResultByName("first_interaction_at")
),
lastInteractionAt: this.#toDate(
row.getResultByName("last_interaction_at")
),
documentType: row.getResultByName("document_type"),
userPersisted: !!row.getResultByName("user_persisted"),
};
}
/**
* Translates a date value from the database.
*
* @param {number} value
* The date in milliseconds from the epoch.
* @returns {Date?}
*/
#toDate(value) {
if (value) {
return new Date(value);
}
return null;
}
})();

Просмотреть файл

@ -7,15 +7,27 @@
const TEST_URL1 = "https://example.com/";
const TEST_URL2 = "https://example.com/12345";
const TEST_URL3 = "https://example.com/14235";
const TOPIC_ADDED = "places-snapshot-added";
const TOPIC_DELETED = "places-snapshot-deleted";
add_task(async function test_add_simple_snapshot() {
add_task(async function setup() {
let now = Date.now();
await addInteractions([
{ url: TEST_URL1, documentType: Interactions.DOCUMENT_TYPE.MEDIA },
{ url: TEST_URL2 },
// The updated_at values are force to ensure unique times so that we can
// retrieve the snapshots in the expected order.
{
url: TEST_URL1,
documentType: Interactions.DOCUMENT_TYPE.MEDIA,
created_at: now - 20000,
updated_at: now - 20000,
},
{ url: TEST_URL2, created_at: now - 10000, updated_at: now - 10000 },
{ url: TEST_URL3, created_at: now, updated_at: now },
]);
});
add_task(async function test_add_simple_snapshot() {
let promise = TestUtils.topicObserved(
TOPIC_ADDED,
(subject, data) => !subject && data == TEST_URL1
@ -31,8 +43,8 @@ add_task(async function test_add_simple_snapshot() {
await promise;
await assertSnapshots([
{ url: TEST_URL1, documentType: Interactions.DOCUMENT_TYPE.MEDIA },
{ url: TEST_URL2, userPersisted: true },
{ url: TEST_URL1, documentType: Interactions.DOCUMENT_TYPE.MEDIA },
]);
let snapshot = await Snapshots.get(TEST_URL2);
@ -42,11 +54,50 @@ add_task(async function test_add_simple_snapshot() {
add_task(async function test_add_snapshot_without_interaction() {
await Assert.rejects(
Snapshots.add({ url: "https://invalid.com/" }),
/Could not find existing interactions/,
/NOT NULL constraint failed/,
"Should reject if an interaction is missing"
);
});
add_task(async function test_add_duplicate_snapshot() {
await Snapshots.add({ url: TEST_URL3 });
let initialSnapshot = await Snapshots.get(TEST_URL3);
await Snapshots.add({ url: TEST_URL3 });
let newSnapshot = await Snapshots.get(TEST_URL3);
Assert.deepEqual(
initialSnapshot,
newSnapshot,
"Snapshot should have remained the same"
);
// Check that the other snapshots have not had userPersisted changed.
await assertSnapshots([
{ url: TEST_URL3 },
{ url: TEST_URL2, userPersisted: true },
{ url: TEST_URL1, documentType: Interactions.DOCUMENT_TYPE.MEDIA },
]);
info("Re-add existing snapshot to check for userPersisted flag");
await Snapshots.add({ url: TEST_URL3, userPersisted: true });
newSnapshot = await Snapshots.get(TEST_URL3);
Assert.deepEqual(
{ ...initialSnapshot, userPersisted: true },
newSnapshot,
"Snapshot should have remained the same apart from the userPersisted value"
);
// Check that the other snapshots have not had userPersisted changed.
await assertSnapshots([
{ url: TEST_URL3, userPersisted: true },
{ url: TEST_URL2, userPersisted: true },
{ url: TEST_URL1, documentType: Interactions.DOCUMENT_TYPE.MEDIA },
]);
});
add_task(async function test_get_snapshot_not_found() {
let snapshot = await Snapshots.get("https://invalid.com/");
Assert.ok(
@ -55,7 +106,7 @@ add_task(async function test_get_snapshot_not_found() {
);
});
add_task(async function test_remove_snapshot() {
add_task(async function test_delete_snapshot() {
let removedAt = new Date();
let promise = TestUtils.topicObserved(
@ -74,4 +125,24 @@ add_task(async function test_remove_snapshot() {
documentType: Interactions.DOCUMENT_TYPE.MEDIA,
removedAt,
});
info("Attempt to re-add non-user persisted snapshot");
await Snapshots.add({ url: TEST_URL1 });
snapshot = await Snapshots.get(TEST_URL1, true);
assertSnapshot(snapshot, {
url: TEST_URL1,
documentType: Interactions.DOCUMENT_TYPE.MEDIA,
removedAt,
});
info("Re-add user persisted snapshot");
await Snapshots.add({ url: TEST_URL1, userPersisted: true });
snapshot = await Snapshots.get(TEST_URL1);
assertSnapshot(snapshot, {
url: TEST_URL1,
documentType: Interactions.DOCUMENT_TYPE.MEDIA,
userPersisted: true,
});
});

Просмотреть файл

@ -6,38 +6,52 @@
*/
add_task(async function setup() {
// Force 2 seconds between interactions so that we can guarantee the expected
// orders.
let now = Date.now() - 2000 * 10;
for (let i = 0; i < 10; i++) {
let url = `https://example.com/${i}/`;
await addInteractions([{ url }]);
await addInteractions([
{ url, created_at: now + i * 2000, updated_at: now + i * 2000 },
]);
await Snapshots.add({ url: `https://example.com/${i}/` });
// Give a little time between updates to help the ordering.
/* eslint-disable-next-line mozilla/no-arbitrary-setTimeout */
await new Promise(r => setTimeout(r, 20));
}
});
add_task(async function test_query_returns_correct_order() {
await assertSnapshots([
{ url: "https://example.com/0/" },
{ url: "https://example.com/1/" },
{ url: "https://example.com/2/" },
{ url: "https://example.com/3/" },
{ url: "https://example.com/4/" },
{ url: "https://example.com/5/" },
{ url: "https://example.com/6/" },
{ url: "https://example.com/7/" },
{ url: "https://example.com/8/" },
{ url: "https://example.com/9/" },
{ url: "https://example.com/8/" },
{ url: "https://example.com/7/" },
{ url: "https://example.com/6/" },
{ url: "https://example.com/5/" },
{ url: "https://example.com/4/" },
{ url: "https://example.com/3/" },
{ url: "https://example.com/2/" },
{ url: "https://example.com/1/" },
{ url: "https://example.com/0/" },
]);
// TODO: Update an interaction, and check the new order is returned corectly.
await addInteractions([{ url: "https://example.com/0/" }]);
await assertSnapshots([
{ url: "https://example.com/0/" },
{ url: "https://example.com/9/" },
{ url: "https://example.com/8/" },
{ url: "https://example.com/7/" },
{ url: "https://example.com/6/" },
{ url: "https://example.com/5/" },
{ url: "https://example.com/4/" },
{ url: "https://example.com/3/" },
{ url: "https://example.com/2/" },
{ url: "https://example.com/1/" },
]);
});
add_task(async function test_query_limit() {
await assertSnapshots(
[{ url: `https://example.com/0/` }, { url: `https://example.com/1/` }],
[{ url: `https://example.com/0/` }, { url: `https://example.com/9/` }],
{ limit: 2 }
);
});
@ -50,28 +64,28 @@ add_task(async function test_query_handles_tombstones() {
info("Deleted snapshots should not appear in the query");
await assertSnapshots([
{ url: "https://example.com/0/" },
{ url: "https://example.com/1/" },
{ url: "https://example.com/2/" },
{ url: "https://example.com/4/" },
{ url: "https://example.com/5/" },
{ url: "https://example.com/7/" },
{ url: "https://example.com/8/" },
{ url: "https://example.com/9/" },
{ url: "https://example.com/8/" },
{ url: "https://example.com/7/" },
{ url: "https://example.com/5/" },
{ url: "https://example.com/4/" },
{ url: "https://example.com/2/" },
{ url: "https://example.com/1/" },
]);
info("Deleted snapshots should appear if tombstones are requested");
await assertSnapshots(
[
{ url: "https://example.com/0/" },
{ url: "https://example.com/1/" },
{ url: "https://example.com/2/" },
{ url: "https://example.com/3/", removedAt },
{ url: "https://example.com/4/" },
{ url: "https://example.com/5/" },
{ url: "https://example.com/6/", removedAt },
{ url: "https://example.com/7/" },
{ url: "https://example.com/8/" },
{ url: "https://example.com/9/" },
{ url: "https://example.com/8/" },
{ url: "https://example.com/7/" },
{ url: "https://example.com/6/", removedAt },
{ url: "https://example.com/5/" },
{ url: "https://example.com/4/" },
{ url: "https://example.com/3/", removedAt },
{ url: "https://example.com/2/" },
{ url: "https://example.com/1/" },
],
{ includeTombstones: true }
);