OverviewFilesCommitsIssuesDocsReleasesPeers

autobase-doctor / test / multi-writer-edge-cases.js

423 lines · javascript ref:
#!/usr/bin/env bare

/**
 * Edge case tests for Autobase multi-writer over Hyperswarm.
 * These cover scenarios that could break in production.
 */

import test from 'brittle'
import Corestore from 'corestore'
import Autobase from 'autobase'
import Hyperbee from 'hyperbee'
import Hyperswarm from 'hyperswarm'
import createTestnet from '@hyperswarm/testnet'
import os from 'bare-os'
import path from 'bare-path'
import fs from 'bare-fs'

const tmpdir = os.tmpdir()
let idx = 0
function tmpPath (name) {
  return path.join(tmpdir, 'ab-edge-' + name + '-' + (idx++) + '-' + Date.now())
}
async function cleanup (...dirs) {
  for (const d of dirs) await fs.promises.rm(d, { recursive: true }).catch(() => {})
}

function open (store) {
  return new Hyperbee(store.get('view', { valueEncoding: 'json' }), {
    keyEncoding: 'utf-8',
    valueEncoding: 'json'
  })
}

// Mimics pear-git LinkedRepo apply — with probe validation
async function peerGitApply (batch, view, host) {
  const b = view.batch()
  for (const node of batch) {
    const op = node.value
    if (!op || !op.type) continue

    switch (op.type) {
      case 'create-issue': {
        await b.put('issue:' + op.id, {
          id: op.id,
          title: op.title,
          body: op.body || '',
          author: op.author || 'unknown',
          status: 'open',
          createdAt: op.timestamp || Date.now()
        })
        const counter = await b.get('counter:issues')
        const next = counter ? counter.value.next : 0
        if (op.id >= next) await b.put('counter:issues', { next: op.id + 1 })
        break
      }
      case 'update-issue': {
        const entry = await b.get('issue:' + op.id)
        if (entry) {
          const issue = entry.value
          if (op.status) issue.status = op.status
          if (op.title) issue.title = op.title
          await b.put('issue:' + op.id, issue)
        }
        break
      }
      case 'add-writer': {
        if (op.key) {
          try {
            await host.addWriter(Buffer.from(op.key, 'hex'), { indexer: op.indexer !== false })
          } catch {}
        }
        break
      }
    }
  }
  await b.flush()
}

function createBase (store, key) {
  return new Autobase(store.session(), key, {
    open,
    apply: peerGitApply,
    valueEncoding: 'json',
    ackInterval: 0,
    ackThreshold: 0
  })
}

async function swarmReplicate (storeA, storeB, topic, testnet) {
  const { bootstrap } = testnet
  const swarmA = new Hyperswarm({ bootstrap })
  const swarmB = new Hyperswarm({ bootstrap })
  swarmA.on('connection', (conn) => storeA.replicate(conn))
  swarmB.on('connection', (conn) => storeB.replicate(conn))
  await swarmA.join(topic, { server: true, client: false }).flushed()
  swarmB.join(topic, { client: true, server: false })
  await swarmB.flush()
  return { swarmA, swarmB }
}

async function waitForKey (bases, key, maxWait = 10000) {
  const start = Date.now()
  while (Date.now() - start < maxWait) {
    for (const b of bases) await b.update()
    let allHave = true
    for (const b of bases) {
      if (!(await b.view.get(key))) { allHave = false; break }
    }
    if (allHave) return true
    await new Promise(r => setTimeout(r, 300))
  }
  return false
}

// ─────────────────────────────────────────────────────────────
// Test 1: Reboot persistence — close, reopen, verify multi-writer state
// ─────────────────────────────────────────────────────────────

test('reboot: multi-writer state persists after close+reopen', async (t) => {
  t.timeout(30000)
  const testnet = await createTestnet(3)
  const dirA = tmpPath('a')
  const dirB = tmpPath('b')

  const storeA = new Corestore(dirA, { primaryKey: Buffer.alloc(32, 0), unsafe: true })
  const storeB = new Corestore(dirB, { primaryKey: Buffer.alloc(32, 1), unsafe: true })

  // Setup multi-writer
  const a = createBase(storeA, null)
  await a.ready()
  const bootstrapKey = a.local.key

  const b = createBase(storeB, bootstrapKey)
  await b.ready()

  const { swarmA, swarmB } = await swarmReplicate(storeA, storeB, a.discoveryKey, testnet)

  await a.append({ type: 'create-issue', id: 1, title: 'from A before writer add', author: 'serverA' })
  await a.append({ type: 'add-writer', key: b.local.key.toString('hex'), indexer: true })

  await waitForKey([a, b], 'issue:1')

  // Confirm writer set
  for (let i = 0; i < 3; i++) {
    if (a.writable) await a.append(null)
    if (b.writable) await b.append(null)
    for (const base of [a, b]) await base.update()
    await new Promise(r => setTimeout(r, 300))
  }

  t.ok(b.writable, 'B writable before reboot')

  // B writes an issue
  await b.append({ type: 'create-issue', id: 2, title: 'from B before reboot', author: 'serverB' })
  await waitForKey([a, b], 'issue:2')

  // Close everything
  await swarmA.destroy()
  await swarmB.destroy()
  await a.close()
  await b.close()
  await storeA.close()
  await storeB.close()

  // Reopen — simulates server reboot
  const storeA2 = new Corestore(dirA, { primaryKey: Buffer.alloc(32, 0), unsafe: true })
  const storeB2 = new Corestore(dirB, { primaryKey: Buffer.alloc(32, 1), unsafe: true })

  const a2 = createBase(storeA2, bootstrapKey)
  await a2.ready()

  const b2 = createBase(storeB2, bootstrapKey)
  await b2.ready()

  t.comment('A2 writable: ' + a2.writable + ', members: ' + a2.system.members)
  t.comment('B2 writable: ' + b2.writable + ', members: ' + b2.system.members)

  // Reconnect
  const testnet2 = await createTestnet(3)
  const { swarmA: sw2A, swarmB: sw2B } = await swarmReplicate(storeA2, storeB2, a2.discoveryKey, testnet2)

  await waitForKey([a2, b2], 'issue:2')

  // Verify all data survived reboot
  const i1a = await a2.view.get('issue:1')
  const i2a = await a2.view.get('issue:2')
  const i1b = await b2.view.get('issue:1')
  const i2b = await b2.view.get('issue:2')

  t.ok(i1a, 'A2 has issue:1')
  t.ok(i2a, 'A2 has issue:2')
  t.ok(i1b, 'B2 has issue:1')
  t.ok(i2b, 'B2 has issue:2')
  t.is(i1a.value.author, 'serverA')
  t.is(i2a.value.author, 'serverB')

  // B is still writable after reboot
  t.ok(b2.writable, 'B2 still writable after reboot')

  // B can still write new issues after reboot
  await b2.append({ type: 'create-issue', id: 3, title: 'from B after reboot', author: 'serverB' })
  await waitForKey([a2, b2], 'issue:3')

  const i3a = await a2.view.get('issue:3')
  t.ok(i3a, 'A2 sees B2 issue written after reboot')
  t.is(i3a.value.author, 'serverB')

  await sw2A.destroy()
  await sw2B.destroy()
  await a2.close()
  await b2.close()
  await storeA2.close()
  await storeB2.close()
  await testnet.destroy()
  await testnet2.destroy()
  await cleanup(dirA, dirB)
})

// ─────────────────────────────────────────────────────────────
// Test 2: Concurrent writes — both append before syncing
// ─────────────────────────────────────────────────────────────

test('concurrent: both writers append simultaneously, linearization converges', async (t) => {
  t.timeout(30000)
  const testnet = await createTestnet(3)
  const dirA = tmpPath('a')
  const dirB = tmpPath('b')

  const storeA = new Corestore(dirA, { primaryKey: Buffer.alloc(32, 0), unsafe: true })
  const storeB = new Corestore(dirB, { primaryKey: Buffer.alloc(32, 1), unsafe: true })

  const a = createBase(storeA, null)
  await a.ready()
  const b = createBase(storeB, a.local.key)
  await b.ready()

  // Add B as writer via pipe (fast, reliable setup)
  const s1 = a.replicate(true)
  const s2 = b.replicate(false)
  s1.pipe(s2).pipe(s1)

  await a.append({ type: 'add-writer', key: b.local.key.toString('hex'), indexer: true })

  for (let i = 0; i < 5; i++) {
    for (const base of [a, b]) await base.update()
    if (a.writable) await a.append(null)
    if (b.writable) await b.append(null)
    await new Promise(r => setTimeout(r, 200))
  }

  t.ok(b.writable, 'B is writable')

  s1.destroy()
  s2.destroy()
  await new Promise(r => setTimeout(r, 500))

  // Both write issues CONCURRENTLY while disconnected
  for (let i = 1; i <= 5; i++) {
    await a.append({ type: 'create-issue', id: i, title: 'A-issue-' + i, author: 'A' })
  }
  for (let i = 6; i <= 10; i++) {
    await b.append({ type: 'create-issue', id: i, title: 'B-issue-' + i, author: 'B' })
  }

  // Reconnect via Hyperswarm
  const { swarmA, swarmB } = await swarmReplicate(storeA, storeB, a.discoveryKey, testnet)

  // Wait for convergence
  await waitForKey([a, b], 'issue:10')

  // Verify all 10 issues present on both sides
  for (let i = 1; i <= 10; i++) {
    const ia = await a.view.get('issue:' + i)
    const ib = await b.view.get('issue:' + i)
    t.ok(ia, 'A has issue:' + i)
    t.ok(ib, 'B has issue:' + i)
  }

  t.comment('A members: ' + a.system.members)
  t.comment('B members: ' + b.system.members)

  await swarmA.destroy()
  await swarmB.destroy()
  await a.close()
  await b.close()
  await storeA.close()
  await storeB.close()
  await testnet.destroy()
  await cleanup(dirA, dirB)
})

// ─────────────────────────────────────────────────────────────
// Test 3: Disconnect + reconnect — offline writes converge
// ─────────────────────────────────────────────────────────────

test('disconnect: offline writes on both sides converge on reconnect', async (t) => {
  t.timeout(30000)
  const testnet = await createTestnet(3)
  const dirA = tmpPath('a')
  const dirB = tmpPath('b')

  const storeA = new Corestore(dirA, { primaryKey: Buffer.alloc(32, 0), unsafe: true })
  const storeB = new Corestore(dirB, { primaryKey: Buffer.alloc(32, 1), unsafe: true })

  const a = createBase(storeA, null)
  await a.ready()
  const b = createBase(storeB, a.local.key)
  await b.ready()

  // Setup multi-writer and initial sync
  const { swarmA, swarmB } = await swarmReplicate(storeA, storeB, a.discoveryKey, testnet)

  await a.append({ type: 'add-writer', key: b.local.key.toString('hex'), indexer: true })
  await a.append({ type: 'create-issue', id: 1, title: 'initial sync test', author: 'A' })

  await waitForKey([a, b], 'issue:1')

  // Confirm
  for (let i = 0; i < 3; i++) {
    if (a.writable) await a.append(null)
    if (b.writable) await b.append(null)
    for (const base of [a, b]) await base.update()
    await new Promise(r => setTimeout(r, 300))
  }

  t.ok(b.writable, 'B writable')

  // Disconnect
  await swarmA.destroy()
  await swarmB.destroy()
  await new Promise(r => setTimeout(r, 500))

  // Both write while disconnected
  await a.append({ type: 'create-issue', id: 2, title: 'A wrote offline', author: 'A' })
  await a.append({ type: 'create-issue', id: 3, title: 'A also wrote this offline', author: 'A' })
  await b.append({ type: 'create-issue', id: 4, title: 'B wrote offline', author: 'B' })

  // Reconnect
  const testnet2 = await createTestnet(3)
  const { swarmA: sw2A, swarmB: sw2B } = await swarmReplicate(storeA, storeB, a.discoveryKey, testnet2)

  await waitForKey([a, b], 'issue:4')

  // All issues present on both sides
  for (let i = 1; i <= 4; i++) {
    const ia = await a.view.get('issue:' + i)
    const ib = await b.view.get('issue:' + i)
    t.ok(ia, 'A has issue:' + i)
    t.ok(ib, 'B has issue:' + i)
  }

  await sw2A.destroy()
  await sw2B.destroy()
  await a.close()
  await b.close()
  await storeA.close()
  await storeB.close()
  await testnet.destroy()
  await testnet2.destroy()
  await cleanup(dirA, dirB)
})

// ─────────────────────────────────────────────────────────────
// Test 4: Probe validation blocks bogus key in real apply
// ─────────────────────────────────────────────────────────────

test('probe validation: bogus key skipped in pear-git apply, real key works', async (t) => {
  t.timeout(20000)
  const testnet = await createTestnet(3)
  const dirA = tmpPath('a')
  const dirB = tmpPath('b')

  const storeA = new Corestore(dirA, { primaryKey: Buffer.alloc(32, 0), unsafe: true })
  const storeB = new Corestore(dirB, { primaryKey: Buffer.alloc(32, 1), unsafe: true })

  const a = createBase(storeA, null)
  await a.ready()
  const b = createBase(storeB, a.local.key)
  await b.ready()

  const { swarmA, swarmB } = await swarmReplicate(storeA, storeB, a.discoveryKey, testnet)

  // First: add bogus key (simulates the old bug)
  const bogusKey = Buffer.alloc(32, 0xde).toString('hex')
  await a.append({ type: 'add-writer', key: bogusKey, indexer: true })

  // Then: add correct key
  await a.append({ type: 'add-writer', key: b.local.key.toString('hex'), indexer: true })

  // Issue before and after
  await a.append({ type: 'create-issue', id: 1, title: 'survives bogus writer', author: 'A' })

  // Sync
  await waitForKey([a, b], 'issue:1')

  // Confirm
  for (let i = 0; i < 3; i++) {
    if (a.writable) await a.append(null)
    if (b.writable) await b.append(null)
    for (const base of [a, b]) await base.update()
    await new Promise(r => setTimeout(r, 300))
  }

  t.ok(b.writable, 'B writable (correct key worked despite bogus key in log)')
  t.comment('A members: ' + a.system.members)

  // B can write
  await b.append({ type: 'create-issue', id: 2, title: 'B writes after bogus+correct', author: 'B' })
  await waitForKey([a, b], 'issue:2')

  const i2 = await a.view.get('issue:2')
  t.ok(i2, 'A sees B issue written after bogus key was skipped')

  await swarmA.destroy()
  await swarmB.destroy()
  await a.close()
  await b.close()
  await storeA.close()
  await storeB.close()
  await testnet.destroy()
  await cleanup(dirA, dirB)
})