/** * Copyright (c) 2022 EdgerOS Team. * All rights reserved. * * Detailed license information can be found in the LICENSE file. * * Author : xueqiang * Date : 2024-08-09 16:00:16 * LastEditors : xueqiang * LastEditTime : 2024-08-14 18:35:51 */ import { STMConflictError, type DBClient, type Namespace, Isolation, type SoftwareTransaction } from '../' import { createTestClient, createTestKeys, tearDownTestClient } from './util' import { test } from '@edgeros/tapes' import assert from 'assert' const data = [ { namespace: false, name: 'without namespace', }, { namespace: true, name: 'with namespace', }, ] // eslint-disable-next-line @typescript-eslint/require-await -- i async function run() { // eslint-disable-next-line @typescript-eslint/prefer-for-of -- ignore for (let i = 0; i < data.length; i++) { const testcase = data[i] let client: undefined | DBClient = undefined let ns: undefined | Namespace = undefined const before = async function () { client = await createTestClient() ns = testcase.namespace ? client.namespace('ns/') : client await createTestKeys(ns) } const after = async function () { await tearDownTestClient(client) } const expectRetry = async (isolation: Isolation, fn: (tx: SoftwareTransaction, tries: any) => Promise, retries = 2) => { let tries = 0 await ns?.stm({ isolation }).transact(async (tx: SoftwareTransaction) => { await fn(tx, ++tries) }) assert.equal(tries, retries) } const expectRunsCleanTransaction = (isolation: Isolation) => { test('runs transactions when all is good', async (t) => { await before() await ns?.stm({ isolation }).transact(async (tx) => { const value = await tx.get('foo1') await tx.put('foo1').value(value ? value.repeat(3) : '') assert.equal(await ns?.get('foo1'), 'bar1') // should not have changed yet }) assert.equal(await ns?.get('foo1'), 'bar1bar1bar1') t.pass('runs transactions when all is good - ') await after() t.end() }) } const expectRepeatableReads = (isolation: Isolation) => { test('has repeatable reads on existing keys', async (t) => { await before() await expectRetry(isolation, async (tx: SoftwareTransaction, tries: any) => { await tx.get('foo1') if (tries === 1) { // should fail when the key changes before the transaction commits await ns?.put('foo1').value('lol') } }) t.pass('has repeatable reads on existing keys - ') await after() t.end() }) test('has repeatable reads on non-existent', async (t) => { await before() await expectRetry(isolation, async (tx: SoftwareTransaction, tries: any) => { await tx.get('some-key-that-does-not-exist') if (tries === 1) { await ns?.put('some-key-that-does-not-exist').value('lol') } }) t.pass('has repeatable reads on non-existent - ') await after() t.end() }) } const ignoreConflicts = async ( isolation: Isolation, fn: (tx: SoftwareTransaction) => Promise, ) => await ns?.stm({ retries: 0, isolation }) .transact(fn) .catch((err: unknown) => { if (!(err instanceof STMConflictError)) { throw err as Error } }) const expectWriteCaching = (isolation: Isolation) => { test('caches writes in memory (#1)', async (t) => { await before() await ignoreConflicts(isolation, async (tx: SoftwareTransaction) => { // putting and value and getting it should returned the value to be written await tx.put('foo').value('some value') t.equal(await tx.get('foo').string(), 'some value') }) await after() t.end() }) test('caches writes in memory (#2)', async (t) => { await before() await ignoreConflicts(isolation, async (tx: SoftwareTransaction) => { // getting a value, then overwriting it, should return the overwritten value assert.equal(await tx.get('foo1').string(), 'bar1') await tx.put('foo1').value('lol') t.equal(await tx.get('foo1').string(), 'lol') }) await after() t.end() }) test('caches writes in memory (#3)', async (t) => { await before() await ignoreConflicts(isolation, async (tx: SoftwareTransaction) => { // deleting a value should null it await tx.delete().key('foo1') assert.equal(await tx.get('foo1').string(), null) // subsequently writing a key should put it back await tx.put('foo1').value('lol') t.equal(await tx.get('foo1').string(), 'lol') }) await after() t.end() }) test('caches writes in memory (#4)', async (t) => { await before() await ignoreConflicts(isolation, async (tx: SoftwareTransaction) => { // deleting a range should null all keys in that range await tx.delete().prefix('foo') t.equal(await tx.get('foo2').string(), null) }) await after() t.end() }) } const expectReadCaching = (isolation: Isolation) => { test('caches reads in memory', async (t) => { await ns?.stm({ retries: 0, isolation }) .transact(async (tx) => { assert.equal(await tx.get('foo1').string(), 'bar1') await ns?.put('foo1').value('changed!') t.equal(await tx.get('foo1').string(), 'bar1') }) .catch(() => undefined) t.end() }) } expectWriteCaching(Isolation.ReadCommitted) expectRunsCleanTransaction(Isolation.ReadCommitted) expectWriteCaching(Isolation.RepeatableReads) expectRunsCleanTransaction(Isolation.RepeatableReads) expectRepeatableReads(Isolation.RepeatableReads) expectWriteCaching(Isolation.Serializable) expectRunsCleanTransaction(Isolation.Serializable) expectRepeatableReads(Isolation.Serializable) expectReadCaching(Isolation.Serializable) expectWriteCaching(Isolation.SerializableSnapshot) expectRunsCleanTransaction(Isolation.SerializableSnapshot) expectRepeatableReads(Isolation.SerializableSnapshot) expectReadCaching(Isolation.SerializableSnapshot) test('should deny writing ranges if keys are read', async (t) => { await before() try { await ignoreConflicts(Isolation.SerializableSnapshot, async (tx: SoftwareTransaction) => { await tx.get('foo1').string() await tx.delete().prefix('foo') }) } catch (err) { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access -- i t.match(err.message, /You cannot delete ranges/, 'should deny writing ranges if keys are read - ') await after() } t.end() }) test('retries writes on conflicts', async (t) => { await before() await expectRetry(Isolation.SerializableSnapshot, async (tx: SoftwareTransaction, tries: any) => { const value = await tx.get('foo1') console.log('***********************value', value, tries) if (tries === 1) { console.log('*******************tries === 1****value', value, tries) await ns?.put('foo1').value('lol') } await tx.put('foo1').value(value ? value.repeat(3) : '') }) t.equal(await ns?.get('foo1'), 'lollollol', 'retries writes on conflicts - ') await after() t.end() }) test('retries deletes on conflicts', async (t) => { await before() await expectRetry(Isolation.SerializableSnapshot, async (tx: SoftwareTransaction, tries: any) => { await tx.get('foo1') if (tries === 1) { await ns?.put('foo1').value('lol') } await tx.delete().key('foo1') }) t.equal(await ns?.get('foo1'), null) await after() t.end() }) test('aborts transactions on continous failure', async (t) => { await before() try { await ns?.stm({ isolation: Isolation.SerializableSnapshot }) .transact(async (tx) => { const value = await tx.get('foo1') await ns?.put('foo1').value('lol') await tx.put('foo1').value(value ? value.repeat(3) : '') }) .then(() => { throw new Error('expected to throw') }) } catch (error) { t.ok(error instanceof STMConflictError, 'aborts transactions on continous failure - ') await after() } t.end() }) } } // eslint-disable-next-line @typescript-eslint/no-floating-promises -- run run()