/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This software may be used and distributed according to the terms of the
 * GNU General Public License version 2.
 */

//! Tests for the Bookmarks store.

use std::collections::HashMap;

use anyhow::Error;
use anyhow::Result;
use bookmarks::Bookmark;
use bookmarks::BookmarkCategory;
use bookmarks::BookmarkKey;
use bookmarks::BookmarkKind;
use bookmarks::BookmarkPagination;
use bookmarks::BookmarkPrefix;
use bookmarks::BookmarkUpdateLog;
use bookmarks::BookmarkUpdateLogEntry;
use bookmarks::BookmarkUpdateReason;
use bookmarks::Bookmarks;
use bookmarks::Freshness;
use context::CoreContext;
use dbbookmarks::SqlBookmarksBuilder;
use fbinit::FacebookInit;
use futures::stream::TryStreamExt;
use maplit::hashmap;
use mononoke_types::ChangesetId;
use mononoke_types::Timestamp;
use mononoke_types_mocks::changesetid::FIVES_CSID;
use mononoke_types_mocks::changesetid::FOURS_CSID;
use mononoke_types_mocks::changesetid::ONES_CSID;
use mononoke_types_mocks::changesetid::SIXES_CSID;
use mononoke_types_mocks::changesetid::THREES_CSID;
use mononoke_types_mocks::changesetid::TWOS_CSID;
use mononoke_types_mocks::repo::REPO_ONE;
use mononoke_types_mocks::repo::REPO_TWO;
use mononoke_types_mocks::repo::REPO_ZERO;
use quickcheck_arbitrary_derive::Arbitrary;
use sql::mysql_async::prelude::ConvIr;
use sql::mysql_async::Value;
use sql_construct::SqlConstruct;

fn create_bookmark_name(book: &str) -> BookmarkKey {
    BookmarkKey::new(book).unwrap()
}

fn create_prefix(book: &str) -> BookmarkPrefix {
    BookmarkPrefix::new(book).unwrap()
}

fn compare_log_entries(
    expected_entries: Vec<BookmarkUpdateLogEntry>,
    actual_entries: Vec<BookmarkUpdateLogEntry>,
) {
    assert_eq!(expected_entries.len(), actual_entries.len());
    for i in 0..expected_entries.len() {
        let expected = expected_entries.get(i).unwrap();
        let actual = actual_entries.get(i).unwrap();
        assert_eq!(expected.id, actual.id);
        assert_eq!(expected.repo_id, actual.repo_id);
        assert_eq!(expected.bookmark_name, actual.bookmark_name);
        assert_eq!(expected.to_changeset_id, actual.to_changeset_id);
        assert_eq!(expected.from_changeset_id, actual.from_changeset_id);
        assert_eq!(expected.reason, actual.reason);
    }
}

#[fbinit::test]
async fn test_simple_unconditional_set_get(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let bookmarks = SqlBookmarksBuilder::with_sqlite_in_memory()
        .unwrap()
        .with_repo_id(REPO_ZERO);
    let name_correct = create_bookmark_name("book");
    let name_incorrect = create_bookmark_name("book2");

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.force_set(&name_correct, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(txn.commit().await.unwrap());

    assert_eq!(
        bookmarks.get_raw(ctx.clone(), &name_correct).await.unwrap(),
        Some((ONES_CSID, Some(1)))
    );
    assert_eq!(
        bookmarks
            .get_raw(ctx.clone(), &name_incorrect)
            .await
            .unwrap(),
        None
    );

    compare_log_entries(
        bookmarks
            .read_next_bookmark_log_entries(ctx.clone(), 0, 1, Freshness::MostRecent)
            .try_collect::<Vec<_>>()
            .await
            .unwrap(),
        vec![BookmarkUpdateLogEntry {
            id: 1,
            repo_id: REPO_ZERO,
            bookmark_name: name_correct,
            to_changeset_id: Some(ONES_CSID),
            from_changeset_id: None,
            reason: BookmarkUpdateReason::TestMove,
            timestamp: Timestamp::now(),
        }],
    );
}

#[fbinit::test]
async fn test_multi_unconditional_set_get(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let bookmarks = SqlBookmarksBuilder::with_sqlite_in_memory()
        .unwrap()
        .with_repo_id(REPO_ZERO);
    let key_1 = create_bookmark_name("book");
    let key_2 = create_bookmark_name("book2");

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.force_set(&key_1, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    txn.force_set(&key_2, TWOS_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(txn.commit().await.unwrap());

    assert_eq!(
        bookmarks.get_raw(ctx.clone(), &key_1).await.unwrap(),
        Some((ONES_CSID, Some(1)))
    );

    assert_eq!(
        bookmarks.get_raw(ctx.clone(), &key_2).await.unwrap(),
        Some((TWOS_CSID, Some(2)))
    );
}

#[fbinit::test]
async fn test_unconditional_set_same_bookmark(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let bookmarks = SqlBookmarksBuilder::with_sqlite_in_memory()
        .unwrap()
        .with_repo_id(REPO_ZERO);
    let key_1 = create_bookmark_name("book");

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.force_set(&key_1, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(txn.commit().await.unwrap());

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.force_set(&key_1, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(txn.commit().await.unwrap());

    assert_eq!(
        bookmarks.get_raw(ctx.clone(), &key_1).await.unwrap(),
        Some((ONES_CSID, Some(2)))
    );
}

#[fbinit::test]
async fn test_simple_create(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let bookmarks = SqlBookmarksBuilder::with_sqlite_in_memory()
        .unwrap()
        .with_repo_id(REPO_ZERO);
    let key_1 = create_bookmark_name("book");

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.create(&key_1, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(txn.commit().await.unwrap());

    assert_eq!(
        bookmarks.get_raw(ctx.clone(), &key_1).await.unwrap(),
        Some((ONES_CSID, Some(1)))
    );

    compare_log_entries(
        bookmarks
            .read_next_bookmark_log_entries(ctx.clone(), 0, 1, Freshness::MostRecent)
            .try_collect::<Vec<_>>()
            .await
            .unwrap(),
        vec![BookmarkUpdateLogEntry {
            id: 1,
            repo_id: REPO_ZERO,
            bookmark_name: key_1,
            to_changeset_id: Some(ONES_CSID),
            from_changeset_id: None,
            reason: BookmarkUpdateReason::TestMove,
            timestamp: Timestamp::now(),
        }],
    );
}

#[fbinit::test]
async fn test_create_already_existing(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let bookmarks = SqlBookmarksBuilder::with_sqlite_in_memory()
        .unwrap()
        .with_repo_id(REPO_ZERO);
    let key_1 = create_bookmark_name("book");

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.create(&key_1, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(txn.commit().await.unwrap());

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.create(&key_1, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(!txn.commit().await.unwrap());
}

#[fbinit::test]
async fn test_create_change_same_bookmark(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let bookmarks = SqlBookmarksBuilder::with_sqlite_in_memory()
        .unwrap()
        .with_repo_id(REPO_ZERO);
    let key_1 = create_bookmark_name("book");

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.create(&key_1, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(
        txn.force_set(&key_1, ONES_CSID, BookmarkUpdateReason::TestMove)
            .is_err()
    );

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.force_set(&key_1, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(
        txn.create(&key_1, ONES_CSID, BookmarkUpdateReason::TestMove)
            .is_err()
    );

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.force_set(&key_1, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(
        txn.update(&key_1, TWOS_CSID, ONES_CSID, BookmarkUpdateReason::TestMove,)
            .is_err()
    );

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.update(&key_1, TWOS_CSID, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(
        txn.force_set(&key_1, ONES_CSID, BookmarkUpdateReason::TestMove)
            .is_err()
    );

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.update(&key_1, TWOS_CSID, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(
        txn.force_delete(&key_1, BookmarkUpdateReason::TestMove)
            .is_err()
    );

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.force_delete(&key_1, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(
        txn.update(&key_1, TWOS_CSID, ONES_CSID, BookmarkUpdateReason::TestMove,)
            .is_err()
    );

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.delete(&key_1, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(
        txn.update(&key_1, TWOS_CSID, ONES_CSID, BookmarkUpdateReason::TestMove,)
            .is_err()
    );

    let mut txn = bookmarks.create_transaction(ctx);
    txn.update(&key_1, TWOS_CSID, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(
        txn.delete(&key_1, ONES_CSID, BookmarkUpdateReason::TestMove)
            .is_err()
    );
}

#[fbinit::test]
async fn test_simple_update_bookmark(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let bookmarks = SqlBookmarksBuilder::with_sqlite_in_memory()
        .unwrap()
        .with_repo_id(REPO_ZERO);
    let key_1 = create_bookmark_name("book");

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.create(&key_1, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(txn.commit().await.unwrap());

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.update(&key_1, TWOS_CSID, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(txn.commit().await.unwrap());

    assert_eq!(
        bookmarks.get_raw(ctx.clone(), &key_1).await.unwrap(),
        Some((TWOS_CSID, Some(2)))
    );

    compare_log_entries(
        bookmarks
            .read_next_bookmark_log_entries(ctx.clone(), 1, 1, Freshness::MostRecent)
            .try_collect::<Vec<_>>()
            .await
            .unwrap(),
        vec![BookmarkUpdateLogEntry {
            id: 2,
            repo_id: REPO_ZERO,
            bookmark_name: key_1,
            to_changeset_id: Some(TWOS_CSID),
            from_changeset_id: Some(ONES_CSID),
            reason: BookmarkUpdateReason::TestMove,
            timestamp: Timestamp::now(),
        }],
    );
}

#[fbinit::test]
async fn test_noop_update(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let bookmarks = SqlBookmarksBuilder::with_sqlite_in_memory()
        .unwrap()
        .with_repo_id(REPO_ZERO);
    let key_1 = create_bookmark_name("book");

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.create(&key_1, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(txn.commit().await.unwrap());

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.update(&key_1, ONES_CSID, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(txn.commit().await.unwrap());

    assert_eq!(
        bookmarks.get_raw(ctx.clone(), &key_1).await.unwrap(),
        Some((ONES_CSID, Some(2)))
    );
}

#[fbinit::test]
async fn test_scratch_update_bookmark(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let bookmarks = SqlBookmarksBuilder::with_sqlite_in_memory()
        .unwrap()
        .with_repo_id(REPO_ZERO);
    let key_1 = create_bookmark_name("book");

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.create_scratch(&key_1, ONES_CSID).unwrap();
    assert!(txn.commit().await.unwrap());

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.update_scratch(&key_1, TWOS_CSID, ONES_CSID).unwrap();
    assert!(txn.commit().await.unwrap());

    assert_eq!(
        bookmarks.get_raw(ctx.clone(), &key_1).await.unwrap(),
        Some((TWOS_CSID, None))
    );

    compare_log_entries(
        bookmarks
            .read_next_bookmark_log_entries(ctx.clone(), 1, 1, Freshness::MostRecent)
            .try_collect::<Vec<_>>()
            .await
            .unwrap(),
        vec![],
    );
}

#[fbinit::test]
async fn test_update_non_existent_bookmark(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let bookmarks = SqlBookmarksBuilder::with_sqlite_in_memory()
        .unwrap()
        .with_repo_id(REPO_ZERO);
    let key_1 = create_bookmark_name("book");

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.update(&key_1, TWOS_CSID, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(!txn.commit().await.unwrap());
}

#[fbinit::test]
async fn test_update_existing_bookmark_with_incorrect_commit(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let bookmarks = SqlBookmarksBuilder::with_sqlite_in_memory()
        .unwrap()
        .with_repo_id(REPO_ZERO);
    let key_1 = create_bookmark_name("book");

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.create(&key_1, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(txn.commit().await.unwrap());

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.update(&key_1, ONES_CSID, TWOS_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(!txn.commit().await.unwrap());
}

#[fbinit::test]
async fn test_force_delete(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let bookmarks = SqlBookmarksBuilder::with_sqlite_in_memory()
        .unwrap()
        .with_repo_id(REPO_ZERO);
    let key_1 = create_bookmark_name("book");

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.force_delete(&key_1, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(txn.commit().await.unwrap());

    assert_eq!(bookmarks.get_raw(ctx.clone(), &key_1).await.unwrap(), None);

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.create(&key_1, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(txn.commit().await.unwrap());
    assert_eq!(
        bookmarks.get_raw(ctx.clone(), &key_1).await.unwrap(),
        Some((ONES_CSID, Some(2)))
    );

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.force_delete(&key_1, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(txn.commit().await.unwrap());

    assert_eq!(bookmarks.get_raw(ctx.clone(), &key_1).await.unwrap(), None);

    compare_log_entries(
        bookmarks
            .read_next_bookmark_log_entries(ctx.clone(), 2, 1, Freshness::MostRecent)
            .try_collect::<Vec<_>>()
            .await
            .unwrap(),
        vec![BookmarkUpdateLogEntry {
            id: 3,
            repo_id: REPO_ZERO,
            bookmark_name: key_1,
            to_changeset_id: None,
            from_changeset_id: None,
            reason: BookmarkUpdateReason::TestMove,
            timestamp: Timestamp::now(),
        }],
    );
}

#[fbinit::test]
async fn test_delete(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let bookmarks = SqlBookmarksBuilder::with_sqlite_in_memory()
        .unwrap()
        .with_repo_id(REPO_ZERO);
    let key_1 = create_bookmark_name("book");

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.delete(&key_1, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(!txn.commit().await.unwrap());

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.create(&key_1, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(txn.commit().await.unwrap());
    assert_eq!(
        bookmarks.get_raw(ctx.clone(), &key_1).await.unwrap(),
        Some((ONES_CSID, Some(1)))
    );

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.delete(&key_1, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(txn.commit().await.unwrap());

    compare_log_entries(
        bookmarks
            .read_next_bookmark_log_entries(ctx.clone(), 1, 1, Freshness::MostRecent)
            .try_collect::<Vec<_>>()
            .await
            .unwrap(),
        vec![BookmarkUpdateLogEntry {
            id: 2,
            repo_id: REPO_ZERO,
            bookmark_name: key_1,
            to_changeset_id: None,
            from_changeset_id: Some(ONES_CSID),
            reason: BookmarkUpdateReason::TestMove,
            timestamp: Timestamp::now(),
        }],
    );
}

#[fbinit::test]
async fn test_delete_incorrect_hash(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let bookmarks = SqlBookmarksBuilder::with_sqlite_in_memory()
        .unwrap()
        .with_repo_id(REPO_ZERO);
    let key_1 = create_bookmark_name("book");

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.create(&key_1, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(txn.commit().await.unwrap());
    assert_eq!(
        bookmarks.get_raw(ctx.clone(), &key_1).await.unwrap(),
        Some((ONES_CSID, Some(1)))
    );

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.delete(&key_1, TWOS_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(!txn.commit().await.unwrap());
}

#[fbinit::test]
async fn test_list_by_prefix(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let bookmarks = SqlBookmarksBuilder::with_sqlite_in_memory()
        .unwrap()
        .with_repo_id(REPO_ZERO);
    let key_1 = create_bookmark_name("book1");
    let key_2 = create_bookmark_name("book2");

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.create(&key_1, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    txn.create(&key_2, TWOS_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(txn.commit().await.unwrap());

    let prefix = create_prefix("book");
    let key_1_prefix = create_prefix("book1");
    let key_2_prefix = create_prefix("book2");

    assert_eq!(
        bookmarks
            .list(
                ctx.clone(),
                Freshness::MostRecent,
                &prefix,
                BookmarkCategory::ALL,
                BookmarkKind::ALL,
                &BookmarkPagination::FromStart,
                std::u64::MAX
            )
            .try_collect::<HashMap<_, _>>()
            .await
            .unwrap(),
        hashmap! {
            Bookmark::new(key_1.clone(), BookmarkKind::PullDefaultPublishing) => ONES_CSID,
            Bookmark::new(key_2.clone(), BookmarkKind::PullDefaultPublishing) => TWOS_CSID
        }
    );

    assert_eq!(
        bookmarks
            .list(
                ctx.clone(),
                Freshness::MostRecent,
                &key_1_prefix,
                BookmarkCategory::ALL,
                BookmarkKind::ALL,
                &BookmarkPagination::FromStart,
                std::u64::MAX
            )
            .try_collect::<Vec<_>>()
            .await
            .unwrap(),
        vec![(
            Bookmark::new(key_1.clone(), BookmarkKind::PullDefaultPublishing),
            ONES_CSID
        )]
    );

    assert_eq!(
        bookmarks
            .list(
                ctx.clone(),
                Freshness::MostRecent,
                &key_2_prefix,
                BookmarkCategory::ALL,
                BookmarkKind::ALL,
                &BookmarkPagination::FromStart,
                std::u64::MAX
            )
            .try_collect::<Vec<_>>()
            .await
            .unwrap(),
        vec![(
            Bookmark::new(key_2.clone(), BookmarkKind::PullDefaultPublishing),
            TWOS_CSID
        )]
    );
}

#[fbinit::test]
async fn test_create_different_repos(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let builder = SqlBookmarksBuilder::with_sqlite_in_memory().unwrap();
    let bookmarks_0 = builder.clone().with_repo_id(REPO_ZERO);
    let bookmarks_1 = builder.with_repo_id(REPO_ONE);
    let key_1 = create_bookmark_name("book");

    let mut txn = bookmarks_0.create_transaction(ctx.clone());
    txn.force_set(&key_1, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(txn.commit().await.is_ok());

    // Updating value from another repo, should fail
    let mut txn = bookmarks_1.create_transaction(ctx.clone());
    txn.update(&key_1, TWOS_CSID, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(!txn.commit().await.unwrap());

    // Creating value should succeed
    let mut txn = bookmarks_1.create_transaction(ctx.clone());
    txn.create(&key_1, TWOS_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(txn.commit().await.is_ok());

    assert_eq!(
        bookmarks_0.get(ctx.clone(), &key_1).await.unwrap(),
        Some(ONES_CSID)
    );
    assert_eq!(
        bookmarks_1.get(ctx.clone(), &key_1).await.unwrap(),
        Some(TWOS_CSID)
    );

    // Force deleting should delete only from one repo
    let mut txn = bookmarks_1.create_transaction(ctx.clone());
    txn.force_delete(&key_1, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(txn.commit().await.is_ok());
    assert_eq!(
        bookmarks_0.get(ctx.clone(), &key_1).await.unwrap(),
        Some(ONES_CSID)
    );

    // delete should fail for another repo
    let mut txn = bookmarks_1.create_transaction(ctx.clone());
    txn.delete(&key_1, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(!txn.commit().await.unwrap());
}

async fn fetch_single(
    fb: FacebookInit,
    bookmarks: &dyn BookmarkUpdateLog,
    id: u64,
) -> BookmarkUpdateLogEntry {
    let ctx = CoreContext::test_mock(fb);
    bookmarks
        .read_next_bookmark_log_entries(ctx, id, 1, Freshness::MostRecent)
        .try_collect::<Vec<_>>()
        .await
        .unwrap()
        .get(0)
        .unwrap()
        .clone()
}

#[fbinit::test]
async fn test_log_correct_order(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let bookmarks = SqlBookmarksBuilder::with_sqlite_in_memory()
        .unwrap()
        .with_repo_id(REPO_ZERO);
    let key_1 = create_bookmark_name("book");
    let key_2 = create_bookmark_name("book2");

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.force_set(&key_1, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(txn.commit().await.is_ok());

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.update(&key_1, TWOS_CSID, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    txn.commit().await.unwrap();

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.update(
        &key_1,
        THREES_CSID,
        TWOS_CSID,
        BookmarkUpdateReason::TestMove,
    )
    .unwrap();
    txn.commit().await.unwrap();

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.update(
        &key_1,
        FOURS_CSID,
        THREES_CSID,
        BookmarkUpdateReason::TestMove,
    )
    .unwrap();
    txn.commit().await.unwrap();

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.force_set(&key_2, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(txn.commit().await.is_ok());

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.update(
        &key_1,
        FIVES_CSID,
        FOURS_CSID,
        BookmarkUpdateReason::TestMove,
    )
    .unwrap();
    txn.commit().await.unwrap();

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.update(
        &key_1,
        SIXES_CSID,
        FIVES_CSID,
        BookmarkUpdateReason::Pushrebase,
    )
    .unwrap();
    txn.commit().await.unwrap();

    let log_entry = fetch_single(fb, &bookmarks, 0).await;
    assert_eq!(log_entry.to_changeset_id.unwrap(), ONES_CSID);

    let log_entry = fetch_single(fb, &bookmarks, 1).await;
    assert_eq!(log_entry.to_changeset_id.unwrap(), TWOS_CSID);

    let log_entry = fetch_single(fb, &bookmarks, 2).await;
    assert_eq!(log_entry.to_changeset_id.unwrap(), THREES_CSID);

    let log_entry = fetch_single(fb, &bookmarks, 3).await;
    assert_eq!(log_entry.to_changeset_id.unwrap(), FOURS_CSID);

    let log_entry = fetch_single(fb, &bookmarks, 5).await;
    assert_eq!(log_entry.to_changeset_id.unwrap(), FIVES_CSID);

    assert_eq!(
        bookmarks
            .read_next_bookmark_log_entries(ctx.clone(), 0, 4, Freshness::MostRecent)
            .try_collect::<Vec<_>>()
            .await
            .unwrap()
            .len(),
        4
    );

    assert_eq!(
        bookmarks
            .read_next_bookmark_log_entries(ctx.clone(), 0, 8, Freshness::MostRecent)
            .try_collect::<Vec<_>>()
            .await
            .unwrap()
            .len(),
        7
    );

    let entries = bookmarks
        .read_next_bookmark_log_entries(ctx.clone(), 0, 6, Freshness::MostRecent)
        .try_collect::<Vec<_>>()
        .await
        .unwrap();

    let cs_ids: Vec<_> = entries
        .into_iter()
        .map(|entry| entry.to_changeset_id.unwrap())
        .collect();
    assert_eq!(
        cs_ids,
        vec![
            ONES_CSID,
            TWOS_CSID,
            THREES_CSID,
            FOURS_CSID,
            ONES_CSID,
            FIVES_CSID
        ]
    );

    let entries = bookmarks
        .read_next_bookmark_log_entries_same_bookmark_and_reason(ctx.clone(), 0, 6)
        .try_collect::<Vec<_>>()
        .await
        .unwrap();

    // FOURS_CSID -> FIVES_CSID update is missing, because it has a different bookmark
    let cs_ids: Vec<_> = entries
        .into_iter()
        .map(|entry| entry.to_changeset_id.unwrap())
        .collect();
    assert_eq!(cs_ids, vec![ONES_CSID, TWOS_CSID, THREES_CSID, FOURS_CSID]);

    let entries = bookmarks
        .read_next_bookmark_log_entries_same_bookmark_and_reason(ctx.clone(), 5, 6)
        .try_collect::<Vec<_>>()
        .await
        .unwrap();

    // FIVES_CSID -> SIXES_CSID update is missing, because it has a different reason
    let cs_ids: Vec<_> = entries
        .into_iter()
        .map(|entry| entry.to_changeset_id.unwrap())
        .collect();
    assert_eq!(cs_ids, vec![FIVES_CSID]);
}

#[fbinit::test]
async fn test_read_log_entry_many_repos(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let builder = SqlBookmarksBuilder::with_sqlite_in_memory().unwrap();
    let bookmarks_0 = builder.clone().with_repo_id(REPO_ZERO);
    let bookmarks_1 = builder.clone().with_repo_id(REPO_ONE);
    let bookmarks_2 = builder.with_repo_id(REPO_TWO);
    let key_1 = create_bookmark_name("book");

    let mut txn = bookmarks_0.create_transaction(ctx.clone());
    txn.force_set(&key_1, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(txn.commit().await.is_ok());

    let mut txn = bookmarks_1.create_transaction(ctx.clone());
    txn.force_set(&key_1, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(txn.commit().await.is_ok());

    assert_eq!(
        bookmarks_0
            .read_next_bookmark_log_entries(ctx.clone(), 0, 1, Freshness::MostRecent)
            .try_collect::<Vec<_>>()
            .await
            .unwrap()
            .len(),
        1
    );

    assert_eq!(
        bookmarks_1
            .read_next_bookmark_log_entries(ctx.clone(), 0, 1, Freshness::MostRecent)
            .try_collect::<Vec<_>>()
            .await
            .unwrap()
            .len(),
        1
    );

    assert_eq!(
        bookmarks_0
            .read_next_bookmark_log_entries(ctx.clone(), 1, 1, Freshness::MostRecent)
            .try_collect::<Vec<_>>()
            .await
            .unwrap()
            .len(),
        0
    );

    assert_eq!(
        bookmarks_2
            .read_next_bookmark_log_entries(ctx.clone(), 0, 1, Freshness::MostRecent)
            .try_collect::<Vec<_>>()
            .await
            .unwrap()
            .len(),
        0
    );
}

#[test]
fn test_update_reason_conversion() -> Result<(), Error> {
    use BookmarkUpdateReason::*;
    let unusedreason = TestMove;

    // If you are adding a new variant here, please also add a test
    // for the new bookmark reason.
    match unusedreason {
        Backsyncer => {}
        Blobimport => {}
        ManualMove => {}
        Push => {}
        Pushrebase => {}
        TestMove => {}
        XRepoSync => {}
        ApiRequest => {}
    };

    let reasons = vec![
        Backsyncer, Blobimport, ManualMove, Push, Pushrebase, TestMove, XRepoSync, ApiRequest,
    ];

    for reason in reasons {
        let value = Value::from(reason);
        BookmarkUpdateReason::new(value)?;
    }

    Ok(())
}

#[fbinit::test]
async fn test_list_bookmark_log_entries(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let bookmarks = SqlBookmarksBuilder::with_sqlite_in_memory()
        .unwrap()
        .with_repo_id(REPO_ZERO);
    let key_1 = create_bookmark_name("book");

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.force_set(&key_1, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(txn.commit().await.is_ok());

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.update(&key_1, TWOS_CSID, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    txn.commit().await.unwrap();

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.update(
        &key_1,
        THREES_CSID,
        TWOS_CSID,
        BookmarkUpdateReason::TestMove,
    )
    .unwrap();
    txn.commit().await.unwrap();

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.update(
        &key_1,
        FOURS_CSID,
        THREES_CSID,
        BookmarkUpdateReason::TestMove,
    )
    .unwrap();
    txn.commit().await.unwrap();

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.update(
        &key_1,
        FIVES_CSID,
        FOURS_CSID,
        BookmarkUpdateReason::TestMove,
    )
    .unwrap();
    txn.commit().await.unwrap();

    assert_eq!(
        bookmarks
            .list_bookmark_log_entries(ctx.clone(), key_1.clone(), 3, None, Freshness::MostRecent)
            .map_ok(|(_id, cs, rs, _ts)| (cs, rs))
            .try_collect::<Vec<_>>()
            .await
            .unwrap(),
        vec![
            (Some(FIVES_CSID), BookmarkUpdateReason::TestMove),
            (Some(FOURS_CSID), BookmarkUpdateReason::TestMove),
            (Some(THREES_CSID), BookmarkUpdateReason::TestMove),
        ]
    );

    let current_timestamp = Timestamp::now();
    let day_old_timestamp =
        Timestamp::from_timestamp_secs(current_timestamp.timestamp_seconds() - 86400);
    assert_eq!(
        bookmarks
            .list_bookmark_log_entries_ts_in_range(
                ctx.clone(),
                key_1.clone(),
                3,
                day_old_timestamp,
                current_timestamp,
            )
            .map_ok(|(_id, cs, rs, _ts)| (cs, rs))
            .try_collect::<Vec<_>>()
            .await
            .unwrap(),
        vec![
            (Some(FIVES_CSID), BookmarkUpdateReason::TestMove),
            (Some(FOURS_CSID), BookmarkUpdateReason::TestMove),
            (Some(THREES_CSID), BookmarkUpdateReason::TestMove),
        ]
    );

    assert_eq!(
        bookmarks
            .list_bookmark_log_entries(ctx.clone(), key_1, 3, Some(1), Freshness::MostRecent)
            .map_ok(|(_id, cs, rs, _ts)| (cs, rs))
            .try_collect::<Vec<_>>()
            .await
            .unwrap(),
        vec![
            (Some(FOURS_CSID), BookmarkUpdateReason::TestMove),
            (Some(THREES_CSID), BookmarkUpdateReason::TestMove),
            (Some(TWOS_CSID), BookmarkUpdateReason::TestMove),
        ]
    );
}

#[fbinit::test]
async fn test_get_largest_log_id(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let bookmarks = SqlBookmarksBuilder::with_sqlite_in_memory()
        .unwrap()
        .with_repo_id(REPO_ZERO);
    let key_1 = create_bookmark_name("book");

    assert_eq!(
        bookmarks
            .get_largest_log_id(ctx.clone(), Freshness::MostRecent)
            .await
            .unwrap(),
        None
    );
    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.force_set(&key_1, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();

    assert!(txn.commit().await.is_ok());
    assert_eq!(
        bookmarks
            .get_largest_log_id(ctx.clone(), Freshness::MostRecent)
            .await
            .unwrap(),
        Some(1)
    );

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.update(&key_1, TWOS_CSID, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    txn.commit().await.unwrap();

    assert_eq!(
        bookmarks
            .get_largest_log_id(ctx.clone(), Freshness::MostRecent)
            .await
            .unwrap(),
        Some(2)
    );

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.update(
        &key_1,
        THREES_CSID,
        TWOS_CSID,
        BookmarkUpdateReason::TestMove,
    )
    .unwrap();
    txn.commit().await.unwrap();

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.update(
        &key_1,
        FOURS_CSID,
        THREES_CSID,
        BookmarkUpdateReason::TestMove,
    )
    .unwrap();
    txn.commit().await.unwrap();

    assert_eq!(
        bookmarks
            .get_largest_log_id(ctx.clone(), Freshness::MostRecent)
            .await
            .unwrap(),
        Some(4)
    );
}

#[fbinit::test]
async fn test_creating_publishing_bookmarks(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let bookmarks = SqlBookmarksBuilder::with_sqlite_in_memory()
        .unwrap()
        .with_repo_id(REPO_ZERO);
    let key_1 = create_bookmark_name("book");

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.create_publishing(&key_1, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(txn.commit().await.unwrap());
    assert_eq!(
        bookmarks
            .list(
                ctx.clone(),
                Freshness::MostRecent,
                &BookmarkPrefix::empty(),
                BookmarkCategory::ALL,
                BookmarkKind::ALL,
                &BookmarkPagination::FromStart,
                std::u64::MAX
            )
            .try_collect::<HashMap<_, _>>()
            .await
            .unwrap(),
        hashmap! {
            Bookmark::new(key_1.clone(), BookmarkKind::Publishing) => ONES_CSID,
        }
    );

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.update(&key_1, TWOS_CSID, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(txn.commit().await.unwrap());

    assert_eq!(
        bookmarks
            .list(
                ctx.clone(),
                Freshness::MostRecent,
                &BookmarkPrefix::empty(),
                BookmarkCategory::ALL,
                BookmarkKind::ALL,
                &BookmarkPagination::FromStart,
                std::u64::MAX
            )
            .try_collect::<HashMap<_, _>>()
            .await
            .unwrap(),
        hashmap! {
            Bookmark::new(key_1.clone(), BookmarkKind::Publishing) => TWOS_CSID,
        }
    );
}

#[fbinit::test]
async fn test_pagination_ordering(fb: FacebookInit) {
    let ctx = CoreContext::test_mock(fb);
    let bookmarks = SqlBookmarksBuilder::with_sqlite_in_memory()
        .unwrap()
        .with_repo_id(REPO_ZERO);
    let key_1 = create_bookmark_name("book1");
    let key_2 = create_bookmark_name("book2");
    let key_3 = create_bookmark_name("book3");

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.create_publishing(&key_1, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    txn.create_publishing(&key_2, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    txn.create_publishing(&key_3, ONES_CSID, BookmarkUpdateReason::TestMove)
        .unwrap();
    assert!(txn.commit().await.unwrap());

    // If the code breaks and these results become unordered then that will happen non
    // deterministically. Call list() many times to ensure that the bookmarks are actually
    // ordered.
    for _ in 0..10 {
        assert_eq!(
            bookmarks
                .list(
                    ctx.clone(),
                    Freshness::MostRecent,
                    &BookmarkPrefix::empty(),
                    BookmarkCategory::ALL,
                    BookmarkKind::ALL,
                    &BookmarkPagination::FromStart,
                    3
                )
                .try_collect::<Vec<(_, _)>>()
                .await
                .unwrap(),
            vec![
                (
                    Bookmark::new(key_1.clone(), BookmarkKind::Publishing),
                    ONES_CSID
                ),
                (
                    Bookmark::new(key_2.clone(), BookmarkKind::Publishing),
                    ONES_CSID
                ),
                (
                    Bookmark::new(key_3.clone(), BookmarkKind::Publishing),
                    ONES_CSID
                ),
            ]
        );

        assert_eq!(
            bookmarks
                .list(
                    ctx.clone(),
                    Freshness::MostRecent,
                    &BookmarkPrefix::empty(),
                    BookmarkCategory::ALL,
                    BookmarkKind::ALL,
                    &BookmarkPagination::After(key_1.clone().into_name()),
                    1
                )
                .try_collect::<Vec<(_, _)>>()
                .await
                .unwrap()[0],
            (
                Bookmark::new(key_2.clone(), BookmarkKind::Publishing),
                ONES_CSID
            )
        );
    }
}

#[fbinit::test]
async fn bookmark_subscription_initialization(fb: FacebookInit) -> Result<()> {
    let ctx = CoreContext::test_mock(fb);
    let bookmarks = SqlBookmarksBuilder::with_sqlite_in_memory()?.with_repo_id(REPO_ZERO);
    let book1 = create_bookmark_name("book1");
    let book2 = create_bookmark_name("book2");
    let book3 = create_bookmark_name("book3");

    // Create some history we won't care about.

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.create_publishing(&book1, ONES_CSID, BookmarkUpdateReason::TestMove)?;
    assert!(txn.commit().await?);

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.force_delete(&book1, BookmarkUpdateReason::TestMove)?;
    assert!(txn.commit().await?);

    // Create some bookmarks now that we're going to keep.

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.create_publishing(&book1, ONES_CSID, BookmarkUpdateReason::TestMove)?;
    txn.create_publishing(&book2, TWOS_CSID, BookmarkUpdateReason::TestMove)?;
    assert!(txn.commit().await?);

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.create_publishing(&book3, THREES_CSID, BookmarkUpdateReason::TestMove)?;
    assert!(txn.commit().await?);

    let mut sub = bookmarks
        .create_subscription(&ctx, Freshness::MostRecent)
        .await?;

    sub.refresh(&ctx).await?;
    assert_eq!(
        *sub.bookmarks(),
        hashmap! {
            book1.clone() => (ONES_CSID, BookmarkKind::Publishing),
            book2.clone() => (TWOS_CSID, BookmarkKind::Publishing),
            book3.clone() => (THREES_CSID, BookmarkKind::Publishing),
        }
    );

    Ok(())
}

#[fbinit::test]
async fn bookmark_subscription_updates(fb: FacebookInit) -> Result<()> {
    let ctx = CoreContext::test_mock(fb);
    let bookmarks = SqlBookmarksBuilder::with_sqlite_in_memory()?.with_repo_id(REPO_ZERO);
    let book = create_bookmark_name("book");

    let mut sub = bookmarks
        .create_subscription(&ctx, Freshness::MostRecent)
        .await?;

    assert_eq!(*sub.bookmarks(), hashmap! {});

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.create_publishing(&book, ONES_CSID, BookmarkUpdateReason::TestMove)?;
    assert!(txn.commit().await?);

    sub.refresh(&ctx).await?;
    assert_eq!(
        *sub.bookmarks(),
        hashmap! { book.clone() => (ONES_CSID, BookmarkKind::Publishing)}
    );

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.update(&book, TWOS_CSID, ONES_CSID, BookmarkUpdateReason::TestMove)?;
    assert!(txn.commit().await?);

    sub.refresh(&ctx).await?;
    assert_eq!(
        *sub.bookmarks(),
        hashmap! { book.clone() => (TWOS_CSID, BookmarkKind::Publishing)}
    );

    let mut txn = bookmarks.create_transaction(ctx.clone());
    txn.force_delete(&book, BookmarkUpdateReason::TestMove)?;
    assert!(txn.commit().await?);

    sub.refresh(&ctx).await?;
    assert_eq!(*sub.bookmarks(), hashmap! {});

    Ok(())
}

#[derive(Arbitrary, Clone, Copy, Debug)]
enum TestBookmark {
    Book1,
    Book2,
}

#[derive(Arbitrary, Clone, Copy, Debug)]
enum BookmarkOp {
    /// Set this bookmark.
    Set(ChangesetId),
    /// ForceSet this bookmark (this also changes the kind)
    ForceSet(ChangesetId),
    /// Delete this bookmark
    Delete,
}

/// Use Quickcheck to produce a test scenario of bookmark updates.
#[derive(Arbitrary, Clone, Copy, Debug)]
enum TestOp {
    /// Update one of our test bookmarks
    Bookmark(TestBookmark, BookmarkOp),
    /// Do nothing. This allows multiple refreshes to occur in sequence.
    Noop,
    /// Update the BookmarksSubscription and check that it returns the right bookmark values.
    Refresh,
}

/// Verify bookmark subscriptions using Quickcheck. We create a test scenario and verify that the
/// bookmark subscriptions returns the same data it would return if it was freshly created now (we
/// test that this satisfies our assumptions in separate tests for the bookmarks subscription).
#[fbinit::test]
fn bookmark_subscription_quickcheck(_fb: FacebookInit) {
    #[tokio::main(flavor = "current_thread")]
    async fn check(fb: FacebookInit, mut ops: Vec<TestOp>) -> bool {
        async move {
            ops.push(TestOp::Refresh);

            let ctx = CoreContext::test_mock(fb);

            let bookmarks = SqlBookmarksBuilder::with_sqlite_in_memory()?.with_repo_id(REPO_ZERO);

            let book1 = create_bookmark_name("book1");
            let book2 = create_bookmark_name("book2");

            let mut book1_id = None;
            let mut book2_id = None;

            let mut sub = bookmarks
                .create_subscription(&ctx, Freshness::MostRecent)
                .await?;

            for op in ops {
                match op {
                    TestOp::Bookmark(book, op) => {
                        let current_cs_id = match book {
                            TestBookmark::Book1 => &mut book1_id,
                            TestBookmark::Book2 => &mut book2_id,
                        };

                        let book = match book {
                            TestBookmark::Book1 => &book1,
                            TestBookmark::Book2 => &book2,
                        };

                        let mut txn = bookmarks.create_transaction(ctx.clone());

                        match op {
                            BookmarkOp::Set(cs_id) => {
                                match *current_cs_id {
                                    Some(current_cs_id) => {
                                        txn.update(
                                            book,
                                            cs_id,
                                            current_cs_id,
                                            BookmarkUpdateReason::TestMove,
                                        )?;
                                    }
                                    None => {
                                        txn.create_publishing(
                                            book,
                                            cs_id,
                                            BookmarkUpdateReason::TestMove,
                                        )?;
                                    }
                                }

                                *current_cs_id = Some(cs_id);
                            }
                            BookmarkOp::ForceSet(cs_id) => {
                                txn.force_set(book, cs_id, BookmarkUpdateReason::TestMove)?;
                                *current_cs_id = Some(cs_id);
                            }
                            BookmarkOp::Delete => {
                                match *current_cs_id {
                                    Some(current_cs_id) => {
                                        txn.delete(
                                            book,
                                            current_cs_id,
                                            BookmarkUpdateReason::TestMove,
                                        )?;
                                    }
                                    None => {
                                        txn.force_delete(book, BookmarkUpdateReason::TestMove)?;
                                    }
                                }

                                *current_cs_id = None;
                            }
                        };

                        assert!(txn.commit().await?);
                    }
                    TestOp::Noop => {
                        // It's a noop
                    }
                    TestOp::Refresh => {
                        sub.refresh(&ctx).await?;

                        let control = bookmarks
                            .create_subscription(&ctx, Freshness::MostRecent)
                            .await?;

                        if control.bookmarks() != sub.bookmarks() {
                            return Ok(false);
                        }
                    }
                };
            }

            Result::<_, Error>::Ok(true)
        }
        .await
        .unwrap()
    }

    quickcheck::quickcheck(check as fn(FacebookInit, Vec<TestOp>) -> bool);
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This software may be used and distributed according to the terms of the
 * GNU General Public License version 2.
 */

use std::collections::BTreeMap;
use std::sync::Arc;
use std::sync::Mutex;
use std::time::Duration;
use std::time::Instant;

use anyhow::Error;
use anyhow::Result;
use async_trait::async_trait;
use bookmarks_types::Bookmark;
use bookmarks_types::BookmarkCategory;
use bookmarks_types::BookmarkKey;
use bookmarks_types::BookmarkKind;
use bookmarks_types::BookmarkPagination;
use bookmarks_types::BookmarkPrefix;
use bookmarks_types::Freshness;
use context::CoreContext;
use futures::future;
use futures::future::BoxFuture;
use futures::future::FutureExt;
use futures::future::TryFutureExt;
use futures::stream;
use futures::stream::BoxStream;
use futures::stream::StreamExt;
use futures::stream::TryStreamExt;
use mononoke_types::ChangesetId;
use mononoke_types::RepositoryId;
use shared_error::anyhow::IntoSharedError;
use shared_error::anyhow::SharedError;
use stats::prelude::*;
use tunables::tunables;

use crate::log::BookmarkUpdateReason;
use crate::subscription::BookmarksSubscription;
use crate::transaction::BookmarkTransaction;
use crate::transaction::BookmarkTransactionHook;
use crate::Bookmarks;

define_stats! {
    prefix = "mononoke.bookmarks.cache";
    cached_bookmarks_hits: dynamic_timeseries("{}.hit", (repo: String); Rate, Sum),
    cached_bookmarks_misses: dynamic_timeseries("{}.miss", (repo: String); Rate, Sum),
}

type CacheData = BTreeMap<BookmarkKey, (BookmarkKind, ChangesetId)>;

#[derive(Clone)]
struct Cache {
    expires: Instant,
    freshness: Freshness,
    current: future::Shared<BoxFuture<'static, Arc<Result<CacheData, SharedError>>>>,
}

impl Cache {
    // NOTE: this function should be fast, as it is executed under a lock
    fn new(
        ctx: CoreContext,
        bookmarks: Arc<dyn Bookmarks>,
        expires: Instant,
        freshness: Freshness,
    ) -> Self {
        let current = async move {
            Arc::new(
                bookmarks
                    .list(
                        ctx,
                        freshness,
                        &BookmarkPrefix::empty(),
                        BookmarkCategory::ALL,
                        BookmarkKind::ALL_PUBLISHING,
                        &BookmarkPagination::FromStart,
                        std::u64::MAX,
                    )
                    .try_fold(
                        BTreeMap::new(),
                        |mut map, (bookmark, changeset_id)| async move {
                            let Bookmark { key, kind } = bookmark;
                            map.insert(key, (kind, changeset_id));
                            Ok(map)
                        },
                    )
                    .await
                    .shared_error(),
            )
        }
        .boxed()
        .shared();

        Cache {
            expires,
            freshness,
            current,
        }
    }

    /// Checks if current cache contains failed result
    fn is_failed(&self) -> bool {
        match self.current.peek() {
            None => false,
            Some(result) => result.is_err(),
        }
    }
}

#[derive(Clone)]
pub struct CachedBookmarks {
    repo_id: RepositoryId,
    cache: Arc<Mutex<Option<Cache>>>,
    bookmarks: Arc<dyn Bookmarks>,
}

fn ttl() -> Option<Duration> {
    let ttl_ms = match tunables()
        .bookmarks_cache_ttl_ms()
        .unwrap_or_default()
        .try_into()
    {
        Ok(0) => 2000,            // 0 means default.
        Ok(duration) => duration, // Use provided duration.
        Err(_) => return None,    // Negative values mean no cache.
    };

    Some(Duration::from_millis(ttl_ms))
}

impl CachedBookmarks {
    pub fn new(bookmarks: Arc<dyn Bookmarks>, repo_id: RepositoryId) -> Self {
        Self {
            repo_id,
            bookmarks,
            cache: Arc::new(Mutex::new(None)),
        }
    }

    /// Gets or creates the cache
    fn cache(&self, ctx: CoreContext, ttl: Duration) -> Cache {
        let mut cache = self.cache.lock().expect("lock poisoned");
        let now = Instant::now();
        match *cache {
            Some(ref mut cache) => {
                // create new cache if the old one has either expired or failed
                let cache_failed = cache.is_failed();
                let mut cache_hit = true;
                if cache.expires <= now || cache_failed {
                    cache_hit = false;
                    *cache = Cache::new(
                        ctx,
                        self.bookmarks.clone(),
                        now + ttl,
                        // NOTE: We want freshness to behave as follows:
                        //  - if we are asking for maybe-stale bookmarks we
                        //    want to keep asking for this type of bookmarks
                        //  - if we had a write from the current machine,
                        //    `purge` will request bookmarks from the
                        //    master region, but it might fail:
                        //    - if it fails we want to keep asking for fresh bookmarks
                        //    - if it succeeds the next request should go through a
                        //      replica
                        match (cache.freshness, cache_failed) {
                            (Freshness::MostRecent, true) => Freshness::MostRecent,
                            _ => Freshness::MaybeStale,
                        },
                    );
                }

                if cache_hit {
                    STATS::cached_bookmarks_hits.add_value(1, (self.repo_id.id().to_string(),))
                } else {
                    STATS::cached_bookmarks_misses.add_value(1, (self.repo_id.id().to_string(),))
                }

                cache.clone()
            }
            None => {
                // create new cache if there isn't one
                let new_cache = Cache::new(
                    ctx,
                    self.bookmarks.clone(),
                    now + ttl,
                    Freshness::MaybeStale,
                );
                *cache = Some(new_cache.clone());
                new_cache
            }
        }
    }

    /// Removes old cache and replaces with a new one which will go through master region
    fn purge(&self, ctx: CoreContext) -> Cache {
        let ttl = ttl().unwrap_or_else(|| Duration::from_secs(0));

        let new_cache = Cache::new(
            ctx,
            self.bookmarks.clone(),
            Instant::now() + ttl,
            Freshness::MostRecent,
        );
        let mut cache = self.cache.lock().expect("lock poisoned");
        *cache = Some(new_cache.clone());
        new_cache
    }

    /// Answers a bookmark query from cache.
    fn list_from_publishing_cache(
        &self,
        ctx: CoreContext,
        prefix: &BookmarkPrefix,
        categories: &[BookmarkCategory],
        kinds: &[BookmarkKind],
        pagination: &BookmarkPagination,
        limit: u64,
        ttl: Duration,
    ) -> BoxStream<'static, Result<(Bookmark, ChangesetId)>> {
        let range = prefix.to_range().with_pagination(pagination.clone());

        let cache = self.cache(ctx, ttl);

        let filter_categories = if BookmarkCategory::ALL
            .iter()
            .all(|category| categories.iter().any(|c| c == category))
        {
            None
        } else {
            Some(categories.to_vec())
        };

        let filter_kinds = if BookmarkKind::ALL_PUBLISHING
            .iter()
            .all(|kind| kinds.iter().any(|k| k == kind))
        {
            // The request is for all cached kinds, no need to filter.
            None
        } else {
            // The request is for a subset of the cached kinds.
            Some(kinds.to_vec())
        };

        cache
            .current
            .clone()
            .map(move |cache_result| match &*cache_result {
                Ok(bookmarks) => {
                    let result: Vec<_> = bookmarks
                        .range(range)
                        .filter_map(move |(key, (kind, changeset_id))| {
                            let category = key.category();
                            if filter_categories
                                .as_ref()
                                .map_or(true, |categories| categories.iter().any(|c| c == category))
                                && filter_kinds
                                    .as_ref()
                                    .map_or(true, |kinds| kinds.iter().any(|k| k == kind))
                            {
                                let bookmark = Bookmark {
                                    key: key.clone(),
                                    kind: *kind,
                                };
                                Some(Ok((bookmark, *changeset_id)))
                            } else {
                                None
                            }
                        })
                        .take(limit as usize)
                        .collect();
                    Ok(stream::iter(result))
                }
                Err(err) => Err(Error::from(err.clone())),
            })
            .try_flatten_stream()
            .boxed()
    }
}

struct CachedBookmarksTransaction {
    ctx: CoreContext,
    cache: CachedBookmarks,
    transaction: Box<dyn BookmarkTransaction>,
    dirty: bool,
}

impl CachedBookmarksTransaction {
    fn new(
        ctx: CoreContext,
        cache: CachedBookmarks,
        transaction: Box<dyn BookmarkTransaction>,
    ) -> Self {
        Self {
            ctx,
            cache,
            transaction,
            dirty: false,
        }
    }
}

#[async_trait]
impl Bookmarks for CachedBookmarks {
    fn list(
        &self,
        ctx: CoreContext,
        freshness: Freshness,
        prefix: &BookmarkPrefix,
        categories: &[BookmarkCategory],
        kinds: &[BookmarkKind],
        pagination: &BookmarkPagination,
        limit: u64,
    ) -> BoxStream<'static, Result<(Bookmark, ChangesetId)>> {
        if let Some(ttl) = ttl() {
            if freshness == Freshness::MaybeStale {
                if kinds
                    .iter()
                    .all(|kind| BookmarkKind::ALL_PUBLISHING.iter().any(|k| k == kind))
                {
                    // All requested kinds are supported by the cache.
                    return self.list_from_publishing_cache(
                        ctx, prefix, categories, kinds, pagination, limit, ttl,
                    );
                }
            }
        }

        // Bypass the cache as it cannot serve this request.
        self.bookmarks
            .list(ctx, freshness, prefix, categories, kinds, pagination, limit)
    }

    fn create_transaction(&self, ctx: CoreContext) -> Box<dyn BookmarkTransaction> {
        Box::new(CachedBookmarksTransaction::new(
            ctx.clone(),
            self.clone(),
            self.bookmarks.create_transaction(ctx),
        ))
    }

    async fn create_subscription(
        &self,
        ctx: &CoreContext,
        freshness: Freshness,
    ) -> Result<Box<dyn BookmarksSubscription>> {
        self.bookmarks.create_subscription(ctx, freshness).await
    }

    fn get(
        &self,
        ctx: CoreContext,
        bookmark: &BookmarkKey,
    ) -> BoxFuture<'static, Result<Option<ChangesetId>>> {
        // NOTE: If you to implement a Freshness notion here and try to fetch from cache, be
        // mindful that not all bookmarks are cached, so a cache miss here does not necessarily
        // mean that the Bookmark does not exist.
        self.bookmarks.get(ctx, bookmark)
    }

    /// Drop this cache without kicking off a refresh right now.
    fn drop_caches(&self) {
        let mut cache = self.cache.lock().expect("lock poisoned");
        *cache = None;
    }
}

impl BookmarkTransaction for CachedBookmarksTransaction {
    fn update(
        &mut self,
        bookmark: &BookmarkKey,
        new_cs: ChangesetId,
        old_cs: ChangesetId,
        reason: BookmarkUpdateReason,
    ) -> Result<()> {
        self.dirty = true;
        self.transaction.update(bookmark, new_cs, old_cs, reason)
    }

    fn create(
        &mut self,
        bookmark: &BookmarkKey,
        new_cs: ChangesetId,
        reason: BookmarkUpdateReason,
    ) -> Result<()> {
        self.dirty = true;
        self.transaction.create(bookmark, new_cs, reason)
    }

    fn force_set(
        &mut self,
        bookmark: &BookmarkKey,
        new_cs: ChangesetId,
        reason: BookmarkUpdateReason,
    ) -> Result<()> {
        self.dirty = true;
        self.transaction.force_set(bookmark, new_cs, reason)
    }

    fn delete(
        &mut self,
        bookmark: &BookmarkKey,
        old_cs: ChangesetId,
        reason: BookmarkUpdateReason,
    ) -> Result<()> {
        self.dirty = true;
        self.transaction.delete(bookmark, old_cs, reason)
    }

    fn force_delete(&mut self, bookmark: &BookmarkKey, reason: BookmarkUpdateReason) -> Result<()> {
        self.dirty = true;
        self.transaction.force_delete(bookmark, reason)
    }

    fn update_scratch(
        &mut self,
        bookmark: &BookmarkKey,
        new_cs: ChangesetId,
        old_cs: ChangesetId,
    ) -> Result<()> {
        // Scratch bookmarks aren't stored in the cache.
        self.transaction.update_scratch(bookmark, new_cs, old_cs)
    }

    fn create_scratch(&mut self, bookmark: &BookmarkKey, new_cs: ChangesetId) -> Result<()> {
        // Scratch bookmarks aren't stored in the cache.
        self.transaction.create_scratch(bookmark, new_cs)
    }

    fn delete_scratch(&mut self, bookmark: &BookmarkKey, old_cs: ChangesetId) -> Result<()> {
        // Scratch bookmarks aren't stored in the cache.
        self.transaction.delete_scratch(bookmark, old_cs)
    }

    fn create_publishing(
        &mut self,
        bookmark: &BookmarkKey,
        new_cs: ChangesetId,
        reason: BookmarkUpdateReason,
    ) -> Result<()> {
        self.dirty = true;
        self.transaction.create_publishing(bookmark, new_cs, reason)
    }

    fn commit(self: Box<Self>) -> BoxFuture<'static, Result<bool>> {
        let CachedBookmarksTransaction {
            transaction,
            cache,
            ctx,
            dirty,
        } = *self;

        transaction
            .commit()
            .map_ok(move |success| {
                if success && dirty {
                    cache.purge(ctx);
                }
                success
            })
            .boxed()
    }

    fn commit_with_hook(
        self: Box<Self>,
        txn_hook: BookmarkTransactionHook,
    ) -> BoxFuture<'static, Result<bool>> {
        let CachedBookmarksTransaction {
            transaction,
            cache,
            ctx,
            dirty,
        } = *self;

        transaction
            .commit_with_hook(txn_hook)
            .map_ok(move |success| {
                if success && dirty {
                    cache.purge(ctx);
                }
                success
            })
            .boxed()
    }
}

#[cfg(test)]
mod tests {
    use std::collections::HashSet;
    use std::fmt::Debug;

    use ascii::AsciiString;
    use fbinit::FacebookInit;
    use futures::channel::mpsc;
    use futures::channel::oneshot;
    use futures::future::Either;
    use futures::future::Future;
    use futures::stream::Stream;
    use futures::stream::StreamFuture;
    use maplit::hashmap;
    use mononoke_types_mocks::changesetid::ONES_CSID;
    use mononoke_types_mocks::changesetid::THREES_CSID;
    use mononoke_types_mocks::changesetid::TWOS_CSID;
    use quickcheck::quickcheck;
    use tokio::runtime::Runtime;
    use tunables::with_tunables_async;
    use tunables::MononokeTunables;

    use super::*;

    fn bookmark(name: impl AsRef<str>) -> Bookmark {
        Bookmark::new(
            BookmarkKey::new(name).unwrap(),
            BookmarkKind::PullDefaultPublishing,
        )
    }

    #[derive(Debug)]
    struct MockBookmarksRequest {
        response: oneshot::Sender<Result<Vec<(Bookmark, ChangesetId)>>>,
        freshness: Freshness,
        prefix: BookmarkPrefix,
        categories: Vec<BookmarkCategory>,
        kinds: Vec<BookmarkKind>,
        pagination: BookmarkPagination,
        limit: u64,
    }

    struct MockBookmarks {
        sender: mpsc::UnboundedSender<MockBookmarksRequest>,
    }

    impl MockBookmarks {
        fn create() -> (Self, mpsc::UnboundedReceiver<MockBookmarksRequest>) {
            let (sender, receiver) = mpsc::unbounded();
            (Self { sender }, receiver)
        }
    }

    fn create_dirty_transaction(
        bookmarks: &impl Bookmarks,
        ctx: CoreContext,
    ) -> Box<dyn BookmarkTransaction> {
        let mut transaction = bookmarks.create_transaction(ctx);

        // Dirty the transaction.
        transaction
            .force_delete(
                &BookmarkKey::new("").unwrap(),
                BookmarkUpdateReason::TestMove,
            )
            .unwrap();

        transaction
    }

    #[async_trait]
    impl Bookmarks for MockBookmarks {
        fn get(
            &self,
            _ctx: CoreContext,
            _name: &BookmarkKey,
        ) -> BoxFuture<'static, Result<Option<ChangesetId>>> {
            unimplemented!()
        }

        async fn create_subscription(
            &self,
            _: &CoreContext,
            _: Freshness,
        ) -> Result<Box<dyn BookmarksSubscription>> {
            unimplemented!()
        }

        fn list(
            &self,
            _ctx: CoreContext,
            freshness: Freshness,
            prefix: &BookmarkPrefix,
            categories: &[BookmarkCategory],
            kinds: &[BookmarkKind],
            pagination: &BookmarkPagination,
            limit: u64,
        ) -> BoxStream<'static, Result<(Bookmark, ChangesetId)>> {
            let (send, recv) = oneshot::channel();

            self.sender
                .unbounded_send(MockBookmarksRequest {
                    response: send,
                    freshness,
                    prefix: prefix.clone(),
                    categories: categories.to_vec(),
                    kinds: kinds.to_vec(),
                    pagination: pagination.clone(),
                    limit,
                })
                .unwrap();

            recv.map_err(Error::from)
                .and_then(|result| async move { result })
                .map_ok(|bm| stream::iter(bm.into_iter().map(Ok)))
                .try_flatten_stream()
                .boxed()
        }

        fn create_transaction(&self, _ctx: CoreContext) -> Box<dyn BookmarkTransaction> {
            Box::new(MockTransaction)
        }
    }

    struct MockTransaction;

    impl BookmarkTransaction for MockTransaction {
        fn update(
            &mut self,
            _bookmark: &BookmarkKey,
            _new_cs: ChangesetId,
            _old_cs: ChangesetId,
            _reason: BookmarkUpdateReason,
        ) -> Result<()> {
            Ok(())
        }

        fn create(
            &mut self,
            _bookmark: &BookmarkKey,
            _new_cs: ChangesetId,
            _reason: BookmarkUpdateReason,
        ) -> Result<()> {
            Ok(())
        }

        fn force_set(
            &mut self,
            _bookmark: &BookmarkKey,
            _new_cs: ChangesetId,
            _reason: BookmarkUpdateReason,
        ) -> Result<()> {
            Ok(())
        }

        fn delete(
            &mut self,
            _bookmark: &BookmarkKey,
            _old_cs: ChangesetId,
            _reason: BookmarkUpdateReason,
        ) -> Result<()> {
            Ok(())
        }

        fn force_delete(
            &mut self,
            _bookmark: &BookmarkKey,
            _reason: BookmarkUpdateReason,
        ) -> Result<()> {
            Ok(())
        }

        fn update_scratch(
            &mut self,
            _bookmark: &BookmarkKey,
            _new_cs: ChangesetId,
            _old_cs: ChangesetId,
        ) -> Result<()> {
            Ok(())
        }

        fn create_scratch(&mut self, _bookmark: &BookmarkKey, _new_cs: ChangesetId) -> Result<()> {
            Ok(())
        }

        fn delete_scratch(&mut self, _bookmark: &BookmarkKey, _old_cs: ChangesetId) -> Result<()> {
            Ok(())
        }

        fn create_publishing(
            &mut self,
            _bookmark: &BookmarkKey,
            _new_cs: ChangesetId,
            _reason: BookmarkUpdateReason,
        ) -> Result<()> {
            Ok(())
        }

        fn commit(self: Box<Self>) -> BoxFuture<'static, Result<bool>> {
            future::ok(true).boxed()
        }

        fn commit_with_hook(
            self: Box<Self>,
            _txn_hook: BookmarkTransactionHook,
        ) -> BoxFuture<'static, Result<bool>> {
            unimplemented!()
        }
    }

    /// Advance through the stream of requests dispatched by MockBookmarks.
    ///
    /// Returns the next element in the stream and the stream itself, which
    /// can be passed to next_request again to get the next one (and so on).
    ///
    /// Panics if no request arrives within the timeout.
    fn next_request<T, S, F>(requests: F, rt: &Runtime, timeout_ms: u64) -> (T, StreamFuture<S>)
    where
        T: Send + 'static,
        S: Stream<Item = T> + Send + Unpin + 'static,
        F: Future<Output = (Option<T>, S)> + Send + Unpin + 'static,
    {
        rt.block_on(async move {
            let timeout = Duration::from_millis(timeout_ms);
            let delay = tokio::time::sleep(timeout);
            futures::pin_mut!(delay);

            match future::select(delay, requests).await {
                Either::Left((_, _)) => panic!("no request came through!"),
                Either::Right((r, _)) => {
                    let (request, stream) = r;
                    (request.unwrap(), stream.into_future())
                }
            }
        })
    }

    /// Check that there are no pending requests on the stream.
    ///
    /// Waits for `timeout_ms`, and panics if a request arrives during that
    /// time.
    ///
    /// Otherwise, returns the stream.
    fn assert_no_pending_requests<T, F>(fut: F, rt: &Runtime, timeout_ms: u64) -> F
    where
        T: Debug + Send + 'static,
        F: Future<Output = T> + Send + Unpin + 'static,
    {
        #[allow(clippy::async_yields_async)]
        rt.block_on(async move {
            let timeout = Duration::from_millis(timeout_ms);
            let delay = tokio::time::sleep(timeout);
            futures::pin_mut!(delay);

            match future::select(delay, fut).await {
                Either::Left((_, b)) => b,
                Either::Right((r, _)) => panic!("pending request was found: {:?}", r),
            }
        })
    }

    #[fbinit::test]
    fn test_cached_bookmarks(fb: FacebookInit) {
        let rt = tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
            .unwrap();
        let ctx = CoreContext::test_mock(fb);
        let repo_id = RepositoryId::new(0);

        let (mock, requests) = MockBookmarks::create();
        let requests = requests.into_future();

        let bookmarks = CachedBookmarks::new(Arc::new(mock), repo_id);

        let spawn_query = |prefix: &'static str, ttl: Option<i64>, rt: &Runtime| {
            let (sender, receiver) = oneshot::channel();

            // Tunables are read in list(), which is a sync function. We wrap this into a future to
            // make ita little more refactoring friendly.
            let bookmarks = bookmarks.clone();
            let ctx = ctx.clone();

            let fut = async move {
                let res = bookmarks
                    .list(
                        ctx.clone(),
                        Freshness::MaybeStale,
                        &BookmarkPrefix::new(prefix).unwrap(),
                        BookmarkCategory::ALL,
                        BookmarkKind::ALL_PUBLISHING,
                        &BookmarkPagination::FromStart,
                        std::u64::MAX,
                    )
                    .try_collect::<Vec<_>>()
                    .await;

                if let Ok(res) = res {
                    let _ = sender.send(res);
                }
            }
            .boxed();

            let tunables = MononokeTunables::default();
            if let Some(ttl) = ttl {
                tunables.update_ints(&hashmap! {"bookmarks_cache_ttl_ms".to_string() => ttl});
            }

            rt.spawn(with_tunables_async(tunables, fut));

            receiver
        };

        let ttl = Some(3000);

        // multiple requests should create only one underlying request
        let res0 = spawn_query("a", ttl, &rt);
        let res1 = spawn_query("b", ttl, &rt);

        let (request, requests) = next_request(requests, &rt, 100);
        assert_eq!(request.freshness, Freshness::MaybeStale);
        assert_eq!(request.kinds, BookmarkKind::ALL_PUBLISHING.to_vec());

        // We expect no other additional request to show up.
        let requests = assert_no_pending_requests(requests, &rt, 100);

        request
            .response
            .send(Ok(vec![
                (bookmark("a0"), ONES_CSID),
                (bookmark("b0"), TWOS_CSID),
                (bookmark("b1"), THREES_CSID),
            ]))
            .unwrap();

        assert_eq!(
            rt.block_on(res0).unwrap(),
            vec![(bookmark("a0"), ONES_CSID)]
        );

        assert_eq!(
            rt.block_on(res1).unwrap(),
            vec![(bookmark("b0"), TWOS_CSID), (bookmark("b1"), THREES_CSID)]
        );

        // We expect no further request to show up.
        let requests = assert_no_pending_requests(requests, &rt, 100);

        // Create a non dirty transaction and make sure that no requests go to master.
        let transaction = bookmarks.create_transaction(ctx.clone());
        rt.block_on(transaction.commit()).unwrap();

        std::mem::drop(spawn_query("c", ttl, &rt));
        let requests = assert_no_pending_requests(requests, &rt, 100);

        // successfull transaction should redirect further requests to master
        let transaction = create_dirty_transaction(&bookmarks, ctx.clone());
        rt.block_on(transaction.commit()).unwrap();

        let res = spawn_query("a", ttl, &rt);

        let (request, requests) = next_request(requests, &rt, 100);
        assert_eq!(request.freshness, Freshness::MostRecent);
        assert_eq!(request.kinds, BookmarkKind::ALL_PUBLISHING.to_vec());
        request
            .response
            .send(Err(Error::msg("request to master failed")))
            .unwrap();

        rt.block_on(res).expect_err("cache did not bubble up error");

        // If request to master failed, next request should go to master too
        let res = spawn_query("a", ttl, &rt);

        let (request, requests) = next_request(requests, &rt, 100);
        assert_eq!(request.freshness, Freshness::MostRecent);
        assert_eq!(request.kinds, BookmarkKind::ALL_PUBLISHING.to_vec());
        request
            .response
            .send(Ok(vec![
                (bookmark("a"), ONES_CSID),
                (bookmark("b"), TWOS_CSID),
            ]))
            .unwrap();

        assert_eq!(rt.block_on(res).unwrap(), vec![(bookmark("a"), ONES_CSID)]);

        // No further requests should be made.
        let requests = assert_no_pending_requests(requests, &rt, 100);

        // request should be resolved with cache
        let res = spawn_query("b", ttl, &rt);

        assert_eq!(rt.block_on(res).unwrap(), vec![(bookmark("b"), TWOS_CSID)]);

        // No requests should have been made.
        let requests = assert_no_pending_requests(requests, &rt, 100);

        // cache should expire and request go to replica
        std::thread::sleep(Duration::from_secs(3));

        let res = spawn_query("b", ttl, &rt);

        let (request, requests) = next_request(requests, &rt, 100);
        assert_eq!(request.freshness, Freshness::MaybeStale);
        assert_eq!(request.kinds, BookmarkKind::ALL_PUBLISHING.to_vec());
        request
            .response
            .send(Ok(vec![(bookmark("b"), THREES_CSID)]))
            .unwrap();

        assert_eq!(
            rt.block_on(res).unwrap(),
            vec![(bookmark("b"), THREES_CSID)]
        );

        // No further requests should be made.
        let requests = assert_no_pending_requests(requests, &rt, 100);

        // Spawn two queries, but without the cache being turned on. We expect 2 requests.
        std::mem::drop(spawn_query("a", Some(-1), &rt));
        let (request, requests) = next_request(requests, &rt, 100);
        assert_eq!(request.prefix, BookmarkPrefix::new("a").unwrap());

        std::mem::drop(spawn_query("b", Some(-1), &rt));
        let (request, requests) = next_request(requests, &rt, 100);
        assert_eq!(request.prefix, BookmarkPrefix::new("b").unwrap());

        std::mem::drop(requests);
    }

    fn mock_bookmarks_response(
        bookmarks: &BTreeMap<BookmarkKey, (BookmarkKind, ChangesetId)>,
        prefix: &BookmarkPrefix,
        categories: &[BookmarkCategory],
        kinds: &[BookmarkKind],
        pagination: &BookmarkPagination,
        limit: u64,
    ) -> Vec<(Bookmark, ChangesetId)> {
        let range = prefix.to_range().with_pagination(pagination.clone());
        bookmarks
            .range(range)
            .filter_map(|(key, (kind, csid))| {
                let category = key.category();
                if kinds.iter().any(|k| kind == k) && categories.iter().any(|c| category == c) {
                    let bookmark = Bookmark {
                        key: key.clone(),
                        kind: *kind,
                    };
                    Some((bookmark, *csid))
                } else {
                    None
                }
            })
            .take(limit as usize)
            .collect()
    }

    fn mock_then_query(
        fb: FacebookInit,
        bookmarks: &BTreeMap<BookmarkKey, (BookmarkKind, ChangesetId)>,
        query_freshness: Freshness,
        query_prefix: &BookmarkPrefix,
        query_categories: &[BookmarkCategory],
        query_kinds: &[BookmarkKind],
        query_pagination: &BookmarkPagination,
        query_limit: u64,
    ) -> Vec<(Bookmark, ChangesetId)> {
        let rt = tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
            .unwrap();
        let ctx = CoreContext::test_mock(fb);
        let repo_id = RepositoryId::new(0);

        let (mock, requests) = MockBookmarks::create();
        let requests = requests.into_future();

        let store = CachedBookmarks::new(Arc::new(mock), repo_id);

        let (sender, receiver) = oneshot::channel();

        let tunables = MononokeTunables::default();
        tunables.update_ints(&hashmap! {"bookmarks_cache_ttl_ms".to_string() => 100_000});

        // Send the query to our cache.
        let fut = store
            .list(
                ctx,
                query_freshness,
                query_prefix,
                query_categories,
                query_kinds,
                query_pagination,
                query_limit,
            )
            .try_collect()
            .map_ok(|r: Vec<_>| sender.send(r).unwrap());
        rt.spawn(with_tunables_async(tunables, fut));

        // Wait for the underlying MockBookmarks to receive the request. We
        // expect it to have a freshness consistent with the one we send.
        let (request, _) = next_request(requests, &rt, 100);
        assert_eq!(request.freshness, query_freshness);

        // Now dispatch the response from the Bookmarks data we have and the
        // expected downstream request we expect CachedBookmarks to have
        // passed to its underlying MockBookmarks.
        let response = mock_bookmarks_response(
            bookmarks,
            &request.prefix,
            request.categories.as_slice(),
            request.kinds.as_slice(),
            &request.pagination,
            request.limit,
        );
        request.response.send(Ok(response)).unwrap();

        rt.block_on(receiver).expect("query failed")
    }

    quickcheck! {
        fn responses_match(
            fb: FacebookInit,
            bookmarks: BTreeMap<BookmarkKey, (BookmarkKind, ChangesetId)>,
            freshness: Freshness,
            categories: HashSet<BookmarkCategory>,
            kinds: HashSet<BookmarkKind>,
            prefix_char: Option<ascii_ext::AsciiChar>,
            after: Option<BookmarkKey>,
            limit: u64
        ) -> bool {
            // Test that requesting via the cache gives the same result
            // as going directly to the back-end.
            let categories: Vec<_> = categories.into_iter().collect();
            let kinds: Vec<_> = kinds.into_iter().collect();
            let prefix = match prefix_char {
                Some(ch) => BookmarkPrefix::new_ascii(AsciiString::from(&[ch.0][..])),
                None => BookmarkPrefix::empty(),
            };
            let pagination = match after {
                Some(key) => BookmarkPagination::After(key.into_name()),
                None => BookmarkPagination::FromStart,
            };
            let have = mock_then_query(
                fb,
                &bookmarks,
                freshness,
                &prefix,
                categories.as_slice(),
                kinds.as_slice(),
                &pagination,
                limit,
            );
            let want = mock_bookmarks_response(
                &bookmarks,
                &prefix,
                categories.as_slice(),
                kinds.as_slice(),
                &pagination,
                limit,
            );
            have == want
        }
    }
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This software may be used and distributed according to the terms of the
 * GNU General Public License version 2.
 */

#![feature(never_type)]

use std::sync::Arc;

use anyhow::Result;
use async_trait::async_trait;
use context::CoreContext;
use futures::future::BoxFuture;
use futures::future::FutureExt;
use futures::stream::BoxStream;
use futures::stream::TryStreamExt;
use futures::StreamExt;
use mononoke_types::ChangesetId;

mod cache;
mod log;
mod subscription;
mod transaction;

pub use bookmarks_types::Bookmark;
pub use bookmarks_types::BookmarkCategory;
pub use bookmarks_types::BookmarkKey;
pub use bookmarks_types::BookmarkKind;
pub use bookmarks_types::BookmarkName;
pub use bookmarks_types::BookmarkPagination;
pub use bookmarks_types::BookmarkPrefix;
pub use bookmarks_types::BookmarkPrefixRange;
pub use bookmarks_types::Freshness;
pub use cache::CachedBookmarks;
pub use log::ArcBookmarkUpdateLog;
pub use log::BookmarkUpdateLog;
pub use log::BookmarkUpdateLogArc;
pub use log::BookmarkUpdateLogEntry;
pub use log::BookmarkUpdateLogRef;
pub use log::BookmarkUpdateReason;
pub use subscription::BookmarksSubscription;
pub use transaction::BookmarkTransaction;
pub use transaction::BookmarkTransactionError;
pub use transaction::BookmarkTransactionHook;

#[facet::facet]
#[async_trait]
pub trait Bookmarks: Send + Sync + 'static {
    /// Get the current value of a bookmark.
    ///
    /// Returns `Some(ChangesetId)` if the bookmark exists, or `None` if doesn't
    fn get(
        &self,
        ctx: CoreContext,
        name: &BookmarkKey,
    ) -> BoxFuture<'static, Result<Option<ChangesetId>>>;

    /// List bookmarks that match certain parameters.
    ///
    /// `prefix` requires that bookmark names begin with a certain prefix.
    ///
    /// `kinds` requires that the bookmark is of a certain kind.
    ///
    /// `pagination` limits bookmarks to those lexicographically after the
    /// named bookmark for pagination purposes.
    ///
    /// `limit` limits the total number of bookmarks returned.
    ///
    /// Bookmarks are returned in lexicographic order.  If a request
    /// hits the limit, then a subsequent request with `pagination`
    /// set to `BookmarkPagination::After(name)` will allow listing
    /// of the remaining bookmarks.
    fn list(
        &self,
        ctx: CoreContext,
        freshness: Freshness,
        prefix: &BookmarkPrefix,
        categories: &[BookmarkCategory],
        kindsst: &[BookmarkKind],
        pagination: &BookmarkPagination,
        limit: u64,
    ) -> BoxStream<'static, Result<(Bookmark, ChangesetId)>>;

    /// Create a transaction to modify bookmarks.
    fn create_transaction(&self, ctx: CoreContext) -> Box<dyn BookmarkTransaction>;

    /// Create a subscription to efficiently observe changes to publishing & pull default
    /// bookmarks.
    async fn create_subscription(
        &self,
        ctx: &CoreContext,
        freshness: Freshness,
    ) -> Result<Box<dyn BookmarksSubscription>>;

    /// Drop any caches held by this instance of Bookmarks.
    fn drop_caches(&self) {
        // No-op by default.
    }
}

#[async_trait]
pub trait BookmarksMaybeStaleExt: Send + Sync + 'static {
    fn get_heads_maybe_stale(&self, ctx: CoreContext) -> BoxStream<'static, Result<ChangesetId>>;
    fn get_publishing_bookmarks_maybe_stale(
        &self,
        ctx: CoreContext,
    ) -> BoxStream<'static, Result<(Bookmark, ChangesetId)>>;
}

impl<B> BookmarksMaybeStaleExt for B
where
    B: Bookmarks + ?Sized + Send + Sync + 'static,
{
    fn get_heads_maybe_stale(&self, ctx: CoreContext) -> BoxStream<'static, Result<ChangesetId>> {
        self.list(
            ctx,
            Freshness::MaybeStale,
            &BookmarkPrefix::empty(),
            BookmarkCategory::ALL,
            BookmarkKind::ALL_PUBLISHING,
            &BookmarkPagination::FromStart,
            std::u64::MAX,
        )
        .map_ok(|(_, cs_id)| cs_id)
        .boxed()
    }

    fn get_publishing_bookmarks_maybe_stale(
        &self,
        ctx: CoreContext,
    ) -> BoxStream<'static, Result<(Bookmark, ChangesetId)>> {
        self.list(
            ctx,
            Freshness::MaybeStale,
            &BookmarkPrefix::empty(),
            BookmarkCategory::ALL,
            BookmarkKind::ALL_PUBLISHING,
            &BookmarkPagination::FromStart,
            std::u64::MAX,
        )
    }
}

/// Construct a heads fetcher (function that returns all the heads in the
/// repo) that uses the publishing bookmarks as all heads.
pub fn bookmark_heads_fetcher(
    bookmarks: ArcBookmarks,
) -> Arc<dyn Fn(&CoreContext) -> BoxFuture<'static, Result<Vec<ChangesetId>>> + Send + Sync> {
    Arc::new({
        move |ctx: &CoreContext| {
            bookmarks
                .list(
                    ctx.clone(),
                    Freshness::MaybeStale,
                    &BookmarkPrefix::empty(),
                    BookmarkCategory::ALL,
                    BookmarkKind::ALL_PUBLISHING,
                    &BookmarkPagination::FromStart,
                    std::u64::MAX,
                )
                .map_ok(|(_, cs_id)| cs_id)
                .try_collect()
                .boxed()
        }
    })
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This software may be used and distributed according to the terms of the
 * GNU General Public License version 2.
 */

use std::fmt;

use anyhow::Result;
use bookmarks_types::BookmarkKey;
use bookmarks_types::Freshness;
use clap::ValueEnum;
use context::CoreContext;
use futures::future::BoxFuture;
use futures::stream::BoxStream;
use mononoke_types::ChangesetId;
use mononoke_types::RepositoryId;
use mononoke_types::Timestamp;
use sql::mysql;
use sql::mysql_async::prelude::ConvIr;
use sql::mysql_async::prelude::FromValue;
use sql::mysql_async::FromValueError;
use sql::mysql_async::Value;

/// Entry that describes an update to a bookmark
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BookmarkUpdateLogEntry {
    /// Number that sets a total order on single bookmark updates. It can be used to fetch
    /// new log entries
    pub id: i64,
    /// Id of a repo
    pub repo_id: RepositoryId,
    /// Name of the bookmark
    pub bookmark_name: BookmarkKey,
    /// Previous position of bookmark if it's known. It might not be known if a bookmark was
    /// force set or if a bookmark didn't exist
    pub from_changeset_id: Option<ChangesetId>,
    /// New position of a bookmark. It can be None if the bookmark was deleted
    pub to_changeset_id: Option<ChangesetId>,
    /// Reason for a bookmark update
    pub reason: BookmarkUpdateReason,
    /// When update happened
    pub timestamp: Timestamp,
}

#[facet::facet]
pub trait BookmarkUpdateLog: Send + Sync + 'static {
    /// Read the next up to `limit` entries from Bookmark update log. It either returns
    /// new log entries with id bigger than `id` or empty stream if there are no more
    /// log entries with bigger id.
    fn read_next_bookmark_log_entries(
        &self,
        ctx: CoreContext,
        id: u64,
        limit: u64,
        freshness: Freshness,
    ) -> BoxStream<'static, Result<BookmarkUpdateLogEntry>>;

    /// Same as `read_next_bookmark_log_entries`, but limits the stream of returned entries
    /// to all have the same reason and bookmark
    fn read_next_bookmark_log_entries_same_bookmark_and_reason(
        &self,
        ctx: CoreContext,
        id: u64,
        limit: u64,
    ) -> BoxStream<'static, Result<BookmarkUpdateLogEntry>>;

    /// Read the log entry for specific bookmark with specified to changeset id.
    fn list_bookmark_log_entries(
        &self,
        _ctx: CoreContext,
        name: BookmarkKey,
        max_rec: u32,
        offset: Option<u32>,
        freshness: Freshness,
    ) -> BoxStream<'static, Result<(u64, Option<ChangesetId>, BookmarkUpdateReason, Timestamp)>>;

    /// Read the log entry for specific bookmark with specified to changeset id. Filter by ts range.
    fn list_bookmark_log_entries_ts_in_range(
        &self,
        _ctx: CoreContext,
        name: BookmarkKey,
        max_rec: u32,
        min_ts: Timestamp,
        max_ts: Timestamp,
    ) -> BoxStream<'static, Result<(u64, Option<ChangesetId>, BookmarkUpdateReason, Timestamp)>>;

    /// Count the number of BookmarkUpdateLog entries with id greater than the given value,
    /// possibly excluding a given reason.
    fn count_further_bookmark_log_entries(
        &self,
        _ctx: CoreContext,
        id: u64,
        exclude_reason: Option<BookmarkUpdateReason>,
    ) -> BoxFuture<'static, Result<u64>>;

    /// Count the number of BookmarkUpdateLog entries with id greater than the given value
    fn count_further_bookmark_log_entries_by_reason(
        &self,
        _ctx: CoreContext,
        id: u64,
    ) -> BoxFuture<'static, Result<Vec<(BookmarkUpdateReason, u64)>>>;

    /// Find the last contiguous BookmarkUpdateLog entry matching the reason provided.
    fn skip_over_bookmark_log_entries_with_reason(
        &self,
        ctx: CoreContext,
        id: u64,
        reason: BookmarkUpdateReason,
    ) -> BoxFuture<'static, Result<Option<u64>>>;

    fn get_largest_log_id(
        &self,
        ctx: CoreContext,
        freshness: Freshness,
    ) -> BoxFuture<'static, Result<Option<u64>>>;
}

/// Describes why a bookmark was moved
#[derive(
    Clone,
    Copy,
    Debug,
    Eq,
    PartialEq,
    ValueEnum,
    mysql::OptTryFromRowField
)]
pub enum BookmarkUpdateReason {
    /// Bookmark was updated by a pushrebase.
    Pushrebase,

    /// Bookmark was update by a plain push.
    Push,

    /// Bookmark was updated by blobimport.
    Blobimport,

    /// Bookmark was moved manually i.e. via mononoke_admin tool
    ManualMove,

    /// Bookmark was moved by test code.
    ///
    /// Only used for tests, should never be used in production
    TestMove,

    /// Bookmark was moved during a back-sync from a large repo into a small repo.
    Backsyncer,

    /// Bookmark was moved during a sync from a small repo into a large repo.
    XRepoSync,

    /// Bookmark was moved by an API request.
    ApiRequest,
}

impl std::fmt::Display for BookmarkUpdateReason {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        use BookmarkUpdateReason::*;

        let s = match self {
            Pushrebase => "pushrebase",
            Push => "push",
            Blobimport => "blobimport",
            ManualMove => "manualmove",
            TestMove => "testmove",
            Backsyncer => "backsyncer",
            XRepoSync => "xreposync",
            ApiRequest => "apirequest",
        };
        write!(f, "{}", s)
    }
}

impl ConvIr<BookmarkUpdateReason> for BookmarkUpdateReason {
    fn new(v: Value) -> Result<Self, FromValueError> {
        use BookmarkUpdateReason::*;

        match v {
            Value::Bytes(ref b) if b == b"pushrebase" => Ok(Pushrebase),
            Value::Bytes(ref b) if b == b"push" => Ok(Push),
            Value::Bytes(ref b) if b == b"blobimport" => Ok(Blobimport),
            Value::Bytes(ref b) if b == b"manualmove" => Ok(ManualMove),
            Value::Bytes(ref b) if b == b"testmove" => Ok(TestMove),
            Value::Bytes(ref b) if b == b"backsyncer" => Ok(Backsyncer),
            Value::Bytes(ref b) if b == b"xreposync" => Ok(XRepoSync),
            Value::Bytes(ref b) if b == b"apirequest" => Ok(ApiRequest),
            v => Err(FromValueError(v)),
        }
    }

    fn commit(self) -> BookmarkUpdateReason {
        self
    }

    fn rollback(self) -> Value {
        self.into()
    }
}

impl FromValue for BookmarkUpdateReason {
    type Intermediate = BookmarkUpdateReason;
}

impl From<BookmarkUpdateReason> for Value {
    fn from(bookmark_update_reason: BookmarkUpdateReason) -> Self {
        use BookmarkUpdateReason::*;

        match bookmark_update_reason {
            Pushrebase => Value::Bytes(b"pushrebase".to_vec()),
            Push => Value::Bytes(b"push".to_vec()),
            Blobimport => Value::Bytes(b"blobimport".to_vec()),
            ManualMove => Value::Bytes(b"manualmove".to_vec()),
            TestMove => Value::Bytes(b"testmove".to_vec()),
            Backsyncer => Value::Bytes(b"backsyncer".to_vec()),
            XRepoSync => Value::Bytes(b"xreposync".to_vec()),
            ApiRequest => Value::Bytes(b"apirequest".to_vec()),
        }
    }
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This software may be used and distributed according to the terms of the
 * GNU General Public License version 2.
 */

use std::collections::HashMap;

use anyhow::Result;
use async_trait::async_trait;
use bookmarks_types::BookmarkKey;
use bookmarks_types::BookmarkKind;
use context::CoreContext;
use mononoke_types::ChangesetId;

#[async_trait]
pub trait BookmarksSubscription: Send + Sync + 'static {
    /// Refresh this subscription with new updated bookmarks
    async fn refresh(&mut self, ctx: &CoreContext) -> Result<()>;

    /// Get current bookmarks.
    fn bookmarks(&self) -> &HashMap<BookmarkKey, (ChangesetId, BookmarkKind)>;
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This software may be used and distributed according to the terms of the
 * GNU General Public License version 2.
 */

use std::sync::Arc;

use anyhow::Error;
use anyhow::Result;
use bookmarks_types::BookmarkKey;
use context::CoreContext;
use futures::future::BoxFuture;
use mononoke_types::ChangesetId;
use sql::Transaction;
use thiserror::Error;

use crate::log::BookmarkUpdateReason;

#[derive(Debug, Error)]
pub enum BookmarkTransactionError {
    // The transaction modifying bookmarks tables should be retried
    #[error("BookmarkTransactionError::RetryableError")]
    RetryableError(#[source] Error),
    // Transacton was rolled back, we consider this a logic error,
    // which may prompt retry higher in the stack. This can happen
    // for example if some other bookmark update won the race and
    // the entire pushrebase needs to be retried
    #[error("BookmarkTransactionError::LogicError")]
    LogicError,
    // Something unexpected went wrong
    #[error("BookmarkTransactionError::Other")]
    Other(#[from] Error),
}

pub type BookmarkTransactionHook = Arc<
    dyn Fn(
            CoreContext,
            Transaction,
        ) -> BoxFuture<'static, Result<Transaction, BookmarkTransactionError>>
        + Sync
        + Send,
>;

pub trait BookmarkTransaction: Send + Sync + 'static {
    /// Adds set() operation to the transaction set.
    /// Updates a bookmark's value. Bookmark should already exist and point to `old_cs`, otherwise
    /// committing the transaction will fail. The Bookmark should also not be Scratch.
    fn update(
        &mut self,
        bookmark: &BookmarkKey,
        new_cs: ChangesetId,
        old_cs: ChangesetId,
        reason: BookmarkUpdateReason,
    ) -> Result<()>;

    /// Adds create() operation to the transaction set.
    /// Creates a bookmark. BookmarkKey should not already exist, otherwise committing the
    /// transaction will fail. The resulting Bookmark will be PullDefault.
    fn create(
        &mut self,
        bookmark: &BookmarkKey,
        new_cs: ChangesetId,
        reason: BookmarkUpdateReason,
    ) -> Result<()>;

    /// Adds force_set() operation to the transaction set.
    /// Unconditionally sets the new value of the bookmark. Succeeds regardless of whether bookmark
    /// exists or not.
    fn force_set(
        &mut self,
        bookmark: &BookmarkKey,
        new_cs: ChangesetId,
        reason: BookmarkUpdateReason,
    ) -> Result<()>;

    /// Adds delete operation to the transaction set.
    /// Deletes bookmark only if it currently points to `old_cs`.
    fn delete(
        &mut self,
        bookmark: &BookmarkKey,
        old_cs: ChangesetId,
        reason: BookmarkUpdateReason,
    ) -> Result<()>;

    /// Adds force_delete operation to the transaction set.
    /// Deletes bookmark unconditionally.
    fn force_delete(&mut self, bookmark: &BookmarkKey, reason: BookmarkUpdateReason) -> Result<()>;

    /// Adds a scratch bookmark update operation to the transaction set.
    /// Updates the changeset referenced by the bookmark, if it is already a scratch bookmark.
    fn update_scratch(
        &mut self,
        bookmark: &BookmarkKey,
        new_cs: ChangesetId,
        old_cs: ChangesetId,
    ) -> Result<()>;

    /// Adds a scratch bookmark create operation to the transaction set.
    /// Creates a new bookmark, configured as scratch. It should not exist already.
    fn create_scratch(&mut self, bookmark: &BookmarkKey, new_cs: ChangesetId) -> Result<()>;

    /// Adds a scratch bookmark delete operation to the transaction set.
    /// Deletes bookmark only if it currently points to `old_cs`.
    fn delete_scratch(&mut self, bookmark: &BookmarkKey, old_cs: ChangesetId) -> Result<()>;

    /// Adds a publishing bookmark create operation to the transaction set.
    /// Creates a new bookmark, configured as publishing. It should not exist already.
    fn create_publishing(
        &mut self,
        bookmark: &BookmarkKey,
        new_cs: ChangesetId,
        reason: BookmarkUpdateReason,
    ) -> Result<()>;

    /// Commits the transaction. Future succeeds if transaction has been
    /// successful, or errors if transaction has failed. Logical failure is indicated by
    /// returning a successful `false` value; infrastructure failure is reported via an Error.
    fn commit(self: Box<Self>) -> BoxFuture<'static, Result<bool>>;

    /// Commits the bookmarks update along with any changes injected by the BookmarkTransactionHook. The
    /// future returns true if the bookmarks has moved, and false otherwise. Infrastructure errors
    /// are reported via the Error.
    fn commit_with_hook(
        self: Box<Self>,
        txn_hook: BookmarkTransactionHook,
    ) -> BoxFuture<'static, Result<bool>>;
}