mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
* NIP-13 proof-of-work for geohash channels: mine on send, relax rate limits for PoW senders Outgoing kind-20000 geohash messages mine a NIP-13 nonce tag (8 leading zero bits, ~256 hashes, typically <1 ms) off the main actor before signing. Mining is hard-capped at 2 s and cancellable (newer send or channel switch): on cap/cancel the committed target steps down so the message still ships promptly with an honest commitment - sending is never blocked and nothing is dropped. The hot loop serializes the canonical event once and rewrites only the fixed-width nonce bytes. Inbound kind-20000 events are scored per NIP-13 commitment semantics (committed target counts; the ID must actually meet it, extra work earns nothing) and never hard-rejected: validated PoW >= 8 bits skips the per-sender rate-limit bucket while the per-content flood bucket still applies, so old non-mining clients keep working under today's strict limits while bulk spam gets expensive. Presence heartbeats (kind 20001), kind-1 notes, and DMs are unchanged; no UI beyond a pow= field in an existing sampled debug log. Reimplemented from scratch rather than cherry-picking the stale feature/pow-geohash-mining-ui branch (unbounded loop, hard receive filtering, mining UI, XCTest, force unwraps). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Geohash: serialize PoW sends so order matches send order Two location-channel sends back-to-back only cancelled the previous mining task and started a new one. Cancellation merely *expedites* NIP-13 mining (the target is polled and steps down; it never aborts the send), so the cancelled task still appended + relayed once mining returned. Both tasks ran concurrently and the second (shorter to mine) could finish first, reordering messages in the timeline and on relays. Chain the mining tasks: each geohash send captures the previous send's task, cancels it (to expedite, so delays never stack), and awaits its completion before it echoes and relays. Order is now always send order. The >2s mining cap is preserved: cancellation expedites the awaited task, so a send is never blocked beyond NostrPoW.miningTimeCap. Test: two rapid sends where the first mines longer (larger content) still land in send order for both the local echo and the relayed events. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
818 lines
36 KiB
Swift
818 lines
36 KiB
Swift
//
|
|
// PerformanceBaselineTests.swift
|
|
// bitchatTests
|
|
//
|
|
// Performance baselines for hot paths so regressions are measured, not
|
|
// guessed. Uses XCTest `measure {}` (swift-testing has no equivalent) with
|
|
// NO recorded baselines — these tests record timing in CI logs but never
|
|
// fail on timing, so they cannot flake.
|
|
//
|
|
// Each benchmark builds its fixtures OUTSIDE the measure block with fixed
|
|
// seeds/content so every iteration does deterministic work: no network,
|
|
// no Tor, no sleeps.
|
|
//
|
|
// Skippable via the BITCHAT_SKIP_PERF_BASELINES=1 environment variable;
|
|
// runs by default.
|
|
//
|
|
|
|
import XCTest
|
|
import SwiftUI
|
|
import BitFoundation
|
|
@testable import bitchat
|
|
|
|
@MainActor
|
|
final class PerformanceBaselineTests: XCTestCase {
|
|
|
|
override func setUpWithError() throws {
|
|
try XCTSkipIf(
|
|
ProcessInfo.processInfo.environment["BITCHAT_SKIP_PERF_BASELINES"] == "1",
|
|
"Performance baselines skipped via BITCHAT_SKIP_PERF_BASELINES"
|
|
)
|
|
}
|
|
|
|
override func tearDown() {
|
|
// Drain main-queue tasks spawned by coordinators (handlePublicMessage
|
|
// hops etc.) so they don't bleed into the next test's measure block.
|
|
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
|
super.tearDown()
|
|
}
|
|
|
|
/// Reports one human-readable throughput line per benchmark so CI logs
|
|
/// are readable without parsing XCTest's measure output. The same line is
|
|
/// appended to the file named by `BITCHAT_PERF_LOG` (if set): under
|
|
/// `swift test --parallel` the runner swallows stdout of passing tests,
|
|
/// so the CI floor gate (scripts/check-perf-floors.sh) reads the file.
|
|
private func reportThroughput(_ name: String, samples: [TimeInterval], operations: Int, unit: String) {
|
|
guard !samples.isEmpty else { return }
|
|
let avg = samples.reduce(0, +) / Double(samples.count)
|
|
let opsPerSec = avg > 0 ? Double(operations) / avg : .infinity
|
|
let line = String(
|
|
format: "PERF[%@]: %.0f %@/sec (avg %.3f ms per pass of %d, %d passes)",
|
|
name, opsPerSec, unit, avg * 1000, operations, samples.count
|
|
)
|
|
print(line)
|
|
Self.appendToPerfLog(line)
|
|
}
|
|
|
|
private static var perfLogPath: String? {
|
|
let path = ProcessInfo.processInfo.environment["BITCHAT_PERF_LOG"]
|
|
return (path?.isEmpty ?? true) ? nil : path
|
|
}
|
|
|
|
/// Appends with `O_APPEND` because `swift test --parallel` may split this
|
|
/// class across worker processes that write concurrently. The file is
|
|
/// append-only (CI workspaces start fresh); delete it between local runs
|
|
/// if you reuse a path.
|
|
private static func appendToPerfLog(_ line: String) {
|
|
guard let path = perfLogPath else { return }
|
|
let fd = open(path, O_WRONLY | O_APPEND | O_CREAT, 0o644)
|
|
guard fd >= 0 else { return }
|
|
defer { close(fd) }
|
|
let bytes = Array((line + "\n").utf8)
|
|
bytes.withUnsafeBufferPointer { buffer in
|
|
_ = write(fd, buffer.baseAddress, buffer.count)
|
|
}
|
|
}
|
|
|
|
// MARK: - 1a. Nostr inbound event handling (fresh events)
|
|
|
|
/// `NostrInboundPipeline.handleNostrEvent` for never-seen geo events
|
|
/// (kind 20000): signature verification, dedup record, presence/nickname
|
|
/// bookkeeping, and public-message ingest scheduling.
|
|
func testNostrInboundEventHandling_freshEvents() throws {
|
|
let events = try Self.makeSignedGeohashEvents(count: 500)
|
|
// A fresh context per measure pass so every event takes the
|
|
// first-seen path on every iteration. Kept alive so the weakly
|
|
// captured Task hops stay valid.
|
|
var keepAlive: [(PerfNostrContext, ChatNostrCoordinator)] = []
|
|
var samples: [TimeInterval] = []
|
|
|
|
measure {
|
|
let context = PerfNostrContext()
|
|
let coordinator = ChatNostrCoordinator(context: context)
|
|
keepAlive.append((context, coordinator))
|
|
let start = Date()
|
|
for event in events {
|
|
coordinator.inbound.handleNostrEvent(event)
|
|
}
|
|
samples.append(Date().timeIntervalSince(start))
|
|
XCTAssertEqual(context.processedEventCount, events.count)
|
|
}
|
|
|
|
reportThroughput("nostrInbound.fresh", samples: samples, operations: events.count, unit: "events")
|
|
}
|
|
|
|
// MARK: - 1b. Nostr inbound event handling (duplicate events)
|
|
|
|
/// The dedup-hit path: identical events replayed. Duplicates dominate
|
|
/// real relay traffic (the same event arrives from several relays), so
|
|
/// this path runs hundreds of times a minute in busy geohashes. Note it
|
|
/// still pays full Schnorr signature verification before the dedup check.
|
|
func testNostrInboundEventHandling_duplicateEvents() throws {
|
|
let events = try Self.makeSignedGeohashEvents(count: 500)
|
|
let context = PerfNostrContext()
|
|
let coordinator = ChatNostrCoordinator(context: context)
|
|
// Pre-warm: every event is now recorded as processed.
|
|
for event in events {
|
|
coordinator.inbound.handleNostrEvent(event)
|
|
}
|
|
XCTAssertEqual(context.processedEventCount, events.count)
|
|
var samples: [TimeInterval] = []
|
|
|
|
measure {
|
|
let start = Date()
|
|
for event in events {
|
|
coordinator.inbound.handleNostrEvent(event)
|
|
}
|
|
samples.append(Date().timeIntervalSince(start))
|
|
}
|
|
|
|
// Dedup held: nothing was re-processed.
|
|
XCTAssertEqual(context.processedEventCount, events.count)
|
|
reportThroughput("nostrInbound.duplicate", samples: samples, operations: events.count, unit: "events")
|
|
}
|
|
|
|
// MARK: - 2. BLE inbound packet pipeline (decode + dedup)
|
|
|
|
/// Binary encode/decode round trip plus `MessageDeduplicator` at
|
|
/// realistic mesh sizes: 1000 packets with 100-300 byte payloads, each
|
|
/// dedup-checked twice (first-seen insert, then duplicate hit) the way
|
|
/// relayed packets arrive on multiple links.
|
|
func testBLEInboundPacketPipeline() throws {
|
|
var rng = SeededGenerator(seed: 0xB17C4A7)
|
|
let baseTimestamp = UInt64(1_700_000_000_000)
|
|
var packets: [BitchatPacket] = []
|
|
var dedupIDs: [String] = []
|
|
packets.reserveCapacity(1000)
|
|
dedupIDs.reserveCapacity(1000)
|
|
for index in 0..<1000 {
|
|
let senderID = Data((0..<8).map { _ in UInt8.random(in: 0...255, using: &rng) })
|
|
let payloadSize = Int.random(in: 100...300, using: &rng)
|
|
let payload = Data((0..<payloadSize).map { _ in UInt8.random(in: 0...255, using: &rng) })
|
|
let packet = BitchatPacket(
|
|
type: MessageType.message.rawValue,
|
|
senderID: senderID,
|
|
recipientID: nil,
|
|
timestamp: baseTimestamp + UInt64(index),
|
|
payload: payload,
|
|
signature: nil,
|
|
ttl: 7
|
|
)
|
|
packets.append(packet)
|
|
// Same dedup key shape BLEService uses: sender-timestamp-type.
|
|
dedupIDs.append("\(senderID.hexEncodedString())-\(packet.timestamp)-\(packet.type)")
|
|
}
|
|
let deduplicator = MessageDeduplicator()
|
|
var samples: [TimeInterval] = []
|
|
|
|
measure {
|
|
deduplicator.reset() // identical work every pass
|
|
let start = Date()
|
|
var decodedCount = 0
|
|
var duplicateCount = 0
|
|
for (index, packet) in packets.enumerated() {
|
|
guard let data = packet.toBinaryData(),
|
|
let decoded = BitchatPacket.from(data) else { continue }
|
|
decodedCount += 1
|
|
_ = decoded.payload.count
|
|
if deduplicator.isDuplicate(dedupIDs[index]) { duplicateCount += 1 }
|
|
}
|
|
for id in dedupIDs where deduplicator.isDuplicate(id) {
|
|
duplicateCount += 1
|
|
}
|
|
samples.append(Date().timeIntervalSince(start))
|
|
XCTAssertEqual(decodedCount, packets.count)
|
|
XCTAssertEqual(duplicateCount, packets.count)
|
|
}
|
|
|
|
reportThroughput("bleInbound.roundTripAndDedup", samples: samples, operations: packets.count, unit: "packets")
|
|
}
|
|
|
|
// MARK: - 3. GCS sync filters
|
|
|
|
/// `GCSFilter.buildFilter` + `decodeToSortedSet` at the production gossip
|
|
/// config (`TransportConfig.syncGCSMaxBytes` / `syncGCSTargetFpr`) with
|
|
/// 1000 candidate packet IDs (the filter caps to its byte budget).
|
|
func testGCSFilterBuildAndDecode() {
|
|
var rng = SeededGenerator(seed: 0x6C5F11)
|
|
let ids: [Data] = (0..<1000).map { _ in
|
|
Data((0..<16).map { _ in UInt8.random(in: 0...255, using: &rng) })
|
|
}
|
|
let maxBytes = TransportConfig.syncGCSMaxBytes
|
|
let targetFpr = TransportConfig.syncGCSTargetFpr
|
|
let repsPerPass = 20
|
|
var samples: [TimeInterval] = []
|
|
|
|
measure {
|
|
let start = Date()
|
|
var decodedTotal = 0
|
|
for _ in 0..<repsPerPass {
|
|
let params = GCSFilter.buildFilter(ids: ids, maxBytes: maxBytes, targetFpr: targetFpr)
|
|
let decoded = GCSFilter.decodeToSortedSet(p: params.p, m: params.m, data: params.data)
|
|
decodedTotal += decoded.count
|
|
XCTAssertLessThanOrEqual(params.data.count, maxBytes)
|
|
}
|
|
samples.append(Date().timeIntervalSince(start))
|
|
XCTAssertGreaterThan(decodedTotal, 0)
|
|
}
|
|
|
|
reportThroughput("gcs.buildAndDecode", samples: samples, operations: repsPerPass, unit: "filters")
|
|
}
|
|
|
|
// MARK: - 4a. Delivery status updates through the coordinator (store path)
|
|
|
|
/// `ChatDeliveryCoordinator.updateMessageDeliveryStatus` over the
|
|
/// `ConversationStore`'s message-ID → conversation map: 2000 public
|
|
/// (split mesh + geohash to stay under the per-conversation cap) + 50x40
|
|
/// private messages, 500 status updates per pass. Statuses alternate
|
|
/// sent <-> delivered so every call performs a real update (never the
|
|
/// skip path).
|
|
func testDeliveryStatusIncrementalUpdates() {
|
|
let context = PerfDeliveryContext.makeCorpus(publicCount: 2000, peerCount: 50, messagesPerPeer: 40)
|
|
let coordinator = ChatDeliveryCoordinator(context: context)
|
|
let targetIDs = context.makeTargetIDs(publicTargets: 250, privateTargets: 250)
|
|
XCTAssertEqual(targetIDs.count, 500)
|
|
|
|
let fixedDate = Date(timeIntervalSince1970: 1_700_000_000)
|
|
var toggle = false
|
|
var samples: [TimeInterval] = []
|
|
|
|
measure {
|
|
toggle.toggle()
|
|
let status: DeliveryStatus = toggle ? .delivered(to: "peer", at: fixedDate) : .sent
|
|
let start = Date()
|
|
var updated = 0
|
|
for id in targetIDs where coordinator.updateMessageDeliveryStatus(id, status: status) {
|
|
updated += 1
|
|
}
|
|
samples.append(Date().timeIntervalSince(start))
|
|
XCTAssertEqual(updated, targetIDs.count)
|
|
}
|
|
|
|
reportThroughput("delivery.incrementalUpdate", samples: samples, operations: targetIDs.count, unit: "updates")
|
|
}
|
|
|
|
// MARK: - 4b. Delivery status updates against the store directly
|
|
|
|
/// `ConversationStore.setDeliveryStatus(_:forMessageID:)` at the same
|
|
/// scale as 4a, without the coordinator/context wrapping — the store-side
|
|
/// cost of an ID-only delivery update (map lookup + per-conversation
|
|
/// ID-index apply + change emission). Replaces the deleted
|
|
/// `delivery.indexRebuild` benchmark: the positional location index and
|
|
/// its rebuild path no longer exist; the store's ID indexes are
|
|
/// maintained inside each mutation, so there is no rebuild to measure.
|
|
func testDeliveryStatusStoreUpdates() {
|
|
let context = PerfDeliveryContext.makeCorpus(publicCount: 2000, peerCount: 50, messagesPerPeer: 40)
|
|
let store = context.store
|
|
let targetIDs = context.makeTargetIDs(publicTargets: 250, privateTargets: 250)
|
|
XCTAssertEqual(targetIDs.count, 500)
|
|
|
|
let fixedDate = Date(timeIntervalSince1970: 1_700_000_000)
|
|
var toggle = false
|
|
var samples: [TimeInterval] = []
|
|
|
|
measure {
|
|
toggle.toggle()
|
|
let status: DeliveryStatus = toggle ? .delivered(to: "peer", at: fixedDate) : .sent
|
|
let start = Date()
|
|
var updated = 0
|
|
for id in targetIDs where store.setDeliveryStatus(status, forMessageID: id) {
|
|
updated += 1
|
|
}
|
|
samples.append(Date().timeIntervalSince(start))
|
|
XCTAssertEqual(updated, targetIDs.count)
|
|
}
|
|
|
|
reportThroughput("delivery.storeUpdate", samples: samples, operations: targetIDs.count, unit: "updates")
|
|
}
|
|
|
|
// MARK: - 5. Message formatting
|
|
|
|
/// `MessageFormattingEngine.formatMessage` over 200 messages with
|
|
/// mentions, hashtags, and URLs. Formatting caches per message, so each
|
|
/// measure pass consumes a fresh pre-built batch (cache-miss path, which
|
|
/// is the cost paid when messages first render).
|
|
func testMessageFormatting() {
|
|
let context = PerfFormattingContext(nickname: "carol")
|
|
let batchCount = 16 // > XCTest's default 10 measure iterations
|
|
let batchSize = 200
|
|
let contents: [(String, [String]?)] = [
|
|
("hello mesh, anyone around tonight?", nil),
|
|
("@carol#a1b2 did you see this? https://example.com/threads/42", ["carol"]),
|
|
("checking in from the harbor #bitchat #mesh", nil),
|
|
("@bob#0042 ping me when you get this", ["bob#0042"]),
|
|
("long form update with a link https://news.example.org/articles/2026/06/mesh-networks and a tag #geohash", nil)
|
|
]
|
|
let batches: [[BitchatMessage]] = (0..<batchCount).map { batch in
|
|
(0..<batchSize).map { i in
|
|
let (content, mentions) = contents[i % contents.count]
|
|
return BitchatMessage(
|
|
id: "fmt-\(batch)-\(i)",
|
|
sender: "alice#a1b\(i % 10)",
|
|
content: content,
|
|
timestamp: Date(timeIntervalSince1970: 1_700_000_000 + Double(i)),
|
|
isRelay: false,
|
|
senderPeerID: PeerID(str: "abcdef123456789\(i % 10)"),
|
|
mentions: mentions
|
|
)
|
|
}
|
|
}
|
|
var pass = 0
|
|
var samples: [TimeInterval] = []
|
|
|
|
measure {
|
|
precondition(pass < batches.count, "add more pre-built batches")
|
|
let batch = batches[pass]
|
|
pass += 1
|
|
let start = Date()
|
|
var formattedCharacters = 0
|
|
for message in batch {
|
|
let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light)
|
|
formattedCharacters += formatted.characters.count
|
|
}
|
|
samples.append(Date().timeIntervalSince(start))
|
|
XCTAssertGreaterThan(formattedCharacters, 0)
|
|
}
|
|
|
|
reportThroughput("formatting.formatMessage", samples: samples, operations: batchSize, unit: "messages")
|
|
}
|
|
|
|
// MARK: - 6a. End-to-end private ingest pipeline (current architecture)
|
|
|
|
/// Baseline for the full private-message ingest cycle through a real
|
|
/// `ChatViewModel`: `handlePrivateMessage` → `ConversationStore.append`
|
|
/// intent (ordered insert + ID-index dedup) → per-conversation publish +
|
|
/// `changes` emission → `PrivateInboxModel` (direct store reads).
|
|
/// Measures wall time from first ingest until the store AND the feature
|
|
/// model both reflect every message. The peer is not mesh-active and no
|
|
/// chat is selected, so notification/read-receipt side paths stay cold
|
|
/// (notifications are no-ops under test anyway).
|
|
func testPipelinePrivateIngest() {
|
|
let messageCount = 200
|
|
// 64-hex (stable Noise key) peer ID: skips the short-ID consolidation
|
|
// and ephemeral-mirror paths so every pass does identical work.
|
|
let peerID = PeerID(str: String(repeating: "ab", count: 32))
|
|
let base = Date(timeIntervalSince1970: 1_700_000_000)
|
|
let messages: [BitchatMessage] = (0..<messageCount).map { i in
|
|
BitchatMessage(
|
|
id: "perf-dm-\(i)",
|
|
sender: "perfsender",
|
|
content: "private pipeline message \(i)",
|
|
timestamp: base.addingTimeInterval(Double(i)),
|
|
isRelay: false,
|
|
isPrivate: true,
|
|
recipientNickname: "me",
|
|
senderPeerID: peerID
|
|
)
|
|
}
|
|
// Fresh fixture per pass so dedup scans and store syncs start from the
|
|
// same empty state every iteration. Kept alive so weakly captured
|
|
// coordinator Task hops stay valid.
|
|
var keepAlive: [PerfPipelineFixture] = []
|
|
var samples: [TimeInterval] = []
|
|
|
|
measure {
|
|
let fixture = PerfPipelineFixture()
|
|
keepAlive.append(fixture)
|
|
let start = Date()
|
|
for message in messages {
|
|
fixture.viewModel.handlePrivateMessage(message)
|
|
}
|
|
// Reads are synchronous against the single-writer store; the
|
|
// spin covers any coordinator main-actor hops.
|
|
let consistent = spinMainRunLoop(timeout: 10) {
|
|
fixture.conversations.conversationsByID[.directPeer(peerID)]?.messages.count == messageCount
|
|
&& fixture.privateInbox.messages(for: peerID).count == messageCount
|
|
}
|
|
samples.append(Date().timeIntervalSince(start))
|
|
XCTAssertTrue(consistent, "ConversationStore/PrivateInboxModel never converged")
|
|
XCTAssertEqual(fixture.viewModel.privateChats[peerID]?.count, messageCount)
|
|
}
|
|
|
|
reportThroughput("pipeline.privateIngest", samples: samples, operations: messageCount, unit: "messages")
|
|
}
|
|
|
|
// MARK: - 6b. End-to-end public ingest pipeline (current architecture)
|
|
|
|
/// Baseline for the full public-message ingest cycle through a real
|
|
/// `ChatViewModel`: `didReceivePublicMessage` (transport delegate entry,
|
|
/// main-actor Task hop per message) → `handlePublicMessage` (rate limit,
|
|
/// pipeline enqueue) → `PublicMessagePipeline` timer-batched flush into
|
|
/// the `ConversationStore` (derived `ChatViewModel.messages` view;
|
|
/// `PublicChatModel` observes the active `Conversation` directly).
|
|
/// Measures until `messages` and the feature model reflect every message,
|
|
/// so the pipeline's flush latency is part of the cycle. Senders are
|
|
/// spread 4-per-peer to stay under the 5-token sender rate bucket.
|
|
func testPipelinePublicIngest() {
|
|
let messageCount = 200
|
|
let senderCount = 50
|
|
let base = Date(timeIntervalSince1970: 1_700_000_000)
|
|
struct InboundPublic {
|
|
let peerID: PeerID
|
|
let nickname: String
|
|
let content: String
|
|
let timestamp: Date
|
|
let messageID: String
|
|
}
|
|
let items: [InboundPublic] = (0..<messageCount).map { i in
|
|
let sender = i % senderCount
|
|
return InboundPublic(
|
|
peerID: PeerID(str: String(format: "%016x", 0xC0DE_0000 + sender)),
|
|
nickname: "perfpeer\(sender)",
|
|
content: "public pipeline message \(i) from sender \(sender)",
|
|
timestamp: base.addingTimeInterval(Double(i)),
|
|
messageID: "perf-pub-\(i)"
|
|
)
|
|
}
|
|
let expectedIDs = Set(items.map(\.messageID))
|
|
var keepAlive: [PerfPipelineFixture] = []
|
|
var samples: [TimeInterval] = []
|
|
|
|
measure {
|
|
let fixture = PerfPipelineFixture()
|
|
keepAlive.append(fixture)
|
|
let start = Date()
|
|
for item in items {
|
|
fixture.viewModel.didReceivePublicMessage(
|
|
from: item.peerID,
|
|
nickname: item.nickname,
|
|
content: item.content,
|
|
timestamp: item.timestamp,
|
|
messageID: item.messageID
|
|
)
|
|
}
|
|
// Drain the per-message main-actor hops and whichever surfacing
|
|
// path wins (the pipeline's batched timer flush or the startup
|
|
// channel apply's `refreshVisibleMessages`); `PublicChatModel`
|
|
// reads the same conversation synchronously.
|
|
let consistent = spinMainRunLoop(timeout: 10) {
|
|
fixture.viewModel.messages.count >= messageCount
|
|
&& fixture.publicChat.messages.count >= messageCount
|
|
}
|
|
samples.append(Date().timeIntervalSince(start))
|
|
XCTAssertTrue(consistent, "messages/PublicChatModel never converged")
|
|
let ingested = fixture.viewModel.messages.filter { expectedIDs.contains($0.id) }
|
|
XCTAssertEqual(ingested.count, messageCount)
|
|
}
|
|
|
|
reportThroughput("pipeline.publicIngest", samples: samples, operations: messageCount, unit: "messages")
|
|
}
|
|
|
|
// MARK: - 7. ConversationStore append (to-be architecture)
|
|
|
|
/// Core op of the new single-source-of-truth `ConversationStore`
|
|
/// (docs/CONVERSATION-STORE-DESIGN.md): 1000 appends into one
|
|
/// conversation, every 10th arriving out of order so the binary-search
|
|
/// insert path (plus suffix reindex) is part of the measured work.
|
|
/// Includes per-message ID-index dedup and the per-conversation
|
|
/// `@Published` array write.
|
|
func testConversationStoreAppend() {
|
|
let messageCount = 1000
|
|
let base = Date(timeIntervalSince1970: 1_700_000_000)
|
|
let messages: [BitchatMessage] = (0..<messageCount).map { i in
|
|
// Every 10th message is "late": its timestamp predates already
|
|
// appended messages, forcing the ordered-insert slow path.
|
|
let offset = (i % 10 == 9) ? Double(i) - 5.5 : Double(i)
|
|
return BitchatMessage(
|
|
id: "perf-store-\(i)",
|
|
sender: "perfsender",
|
|
content: "store append message \(i)",
|
|
timestamp: base.addingTimeInterval(offset),
|
|
isRelay: false
|
|
)
|
|
}
|
|
var samples: [TimeInterval] = []
|
|
|
|
measure {
|
|
let store = ConversationStore()
|
|
let start = Date()
|
|
for message in messages {
|
|
store.append(message, to: .mesh)
|
|
}
|
|
samples.append(Date().timeIntervalSince(start))
|
|
XCTAssertEqual(store.conversation(for: .mesh).messages.count, messageCount)
|
|
}
|
|
|
|
reportThroughput("store.append", samples: samples, operations: messageCount, unit: "messages")
|
|
}
|
|
|
|
// MARK: - 8. ConversationStore invariant audit (field observability)
|
|
|
|
/// `ConversationStore.auditInvariants()` over a realistic 5k-message
|
|
/// corpus (mesh + geohash + 75 private chats). The audit runs in the
|
|
/// field on the read-receipt cleanup cadence
|
|
/// (`ChatViewModel.auditConversationStore`), so this measures the
|
|
/// per-audit cost that piggybacks on peer-list updates — it must stay
|
|
/// trivially cheap relative to that cadence.
|
|
func testConversationStoreAudit() {
|
|
let context = PerfDeliveryContext.makeCorpus(publicCount: 2000, peerCount: 75, messagesPerPeer: 40)
|
|
let store = context.store
|
|
XCTAssertEqual(store.totalMessageCount, 5000)
|
|
let repsPerPass = 20
|
|
var samples: [TimeInterval] = []
|
|
|
|
measure {
|
|
let start = Date()
|
|
var violationCount = 0
|
|
for _ in 0..<repsPerPass {
|
|
violationCount += store.auditInvariants().count
|
|
}
|
|
samples.append(Date().timeIntervalSince(start))
|
|
XCTAssertEqual(violationCount, 0, "healthy corpus must audit clean")
|
|
}
|
|
|
|
reportThroughput("store.audit", samples: samples, operations: repsPerPass, unit: "audits")
|
|
}
|
|
|
|
/// Spins the main run loop in small slices (draining main-queue tasks and
|
|
/// timers) until `condition` holds or `timeout` elapses.
|
|
private func spinMainRunLoop(timeout: TimeInterval, until condition: () -> Bool) -> Bool {
|
|
let deadline = Date().addingTimeInterval(timeout)
|
|
while !condition() {
|
|
guard Date() < deadline else { return false }
|
|
RunLoop.current.run(until: Date().addingTimeInterval(0.005))
|
|
}
|
|
return true
|
|
}
|
|
|
|
// MARK: - Fixtures
|
|
|
|
/// Builds deterministic signed kind-20000 geohash events. Content cycles
|
|
/// through realistic chat lines; a handful of sender identities mimics a
|
|
/// busy channel.
|
|
private static func makeSignedGeohashEvents(count: Int) throws -> [NostrEvent] {
|
|
let senders = try (0..<8).map { _ in try NostrIdentity.generate() }
|
|
let lines = [
|
|
"hello from the geohash",
|
|
"anyone near the station?",
|
|
"@bob#0042 are you on mesh too?",
|
|
"check this out https://example.com/p/123 #bitchat",
|
|
"teleport check, who's local?"
|
|
]
|
|
return try (0..<count).map { i in
|
|
try NostrProtocol.createEphemeralGeohashEvent(
|
|
content: "\(lines[i % lines.count]) [\(i)]",
|
|
geohash: "u4pruyd",
|
|
senderIdentity: senders[i % senders.count],
|
|
nickname: "peer\(i % senders.count)",
|
|
teleported: i % 25 == 0
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Deterministic RNG
|
|
|
|
private struct SeededGenerator: RandomNumberGenerator {
|
|
private var state: UInt64
|
|
init(seed: UInt64) { state = seed }
|
|
mutating func next() -> UInt64 {
|
|
state &+= 0x9E37_79B9_7F4A_7C15
|
|
var z = state
|
|
z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9
|
|
z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB
|
|
return z ^ (z >> 31)
|
|
}
|
|
}
|
|
|
|
// MARK: - Mock ChatNostrContext
|
|
|
|
/// Minimal `ChatNostrContext` for benchmarking the `ChatNostrCoordinator`
|
|
/// stack (`NostrInboundPipeline` in particular) without a `ChatViewModel`
|
|
/// (mirrors `ChatNostrCoordinatorContextTests`).
|
|
/// Callbacks are cheap dictionary/array operations so the measured cost is
|
|
/// the coordinator's own pipeline.
|
|
@MainActor
|
|
private final class PerfNostrContext: ChatNostrContext {
|
|
var activeChannel: ChannelID = .location(GeohashChannel(level: .neighborhood, geohash: "u4pruyd"))
|
|
var currentGeohash: String? = "u4pruyd"
|
|
var geoSubscriptionID: String?
|
|
var geoDmSubscriptionID: String?
|
|
var geoSamplingSubs: [String: String] = [:]
|
|
var lastGeoNotificationAt: [String: Date] = [:]
|
|
var nostrRelayManager: NostrRelayManager? { nil }
|
|
|
|
func setGeoChatSubscriptionID(_ id: String?) { geoSubscriptionID = id }
|
|
func setGeoDmSubscriptionID(_ id: String?) { geoDmSubscriptionID = id }
|
|
func addGeoSamplingSub(_ subID: String, forGeohash geohash: String) { geoSamplingSubs[subID] = geohash }
|
|
func removeGeoSamplingSub(_ subID: String) { geoSamplingSubs.removeValue(forKey: subID) }
|
|
|
|
func clearGeoSamplingSubs() -> [String] {
|
|
defer { geoSamplingSubs.removeAll() }
|
|
return Array(geoSamplingSubs.keys)
|
|
}
|
|
|
|
var messages: [BitchatMessage] = []
|
|
func flushPublicMessagePipeline() {}
|
|
func refreshVisibleMessages(from channel: ChannelID?) {}
|
|
func addPublicSystemMessage(_ content: String) {}
|
|
func drainPendingGeohashSystemMessages() -> [String] { [] }
|
|
func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool { true }
|
|
|
|
private(set) var handledPublicMessageCount = 0
|
|
func handlePublicMessage(_ message: BitchatMessage) { handledPublicMessageCount += 1 }
|
|
func handlePublicMessage(_ message: BitchatMessage, powBits: Int) { handledPublicMessageCount += 1 }
|
|
func checkForMentions(_ message: BitchatMessage) {}
|
|
func sendHapticFeedback(for message: BitchatMessage) {}
|
|
func parseMentions(from content: String) -> [String] {
|
|
MessageFormattingEngine.extractMentions(from: content)
|
|
}
|
|
|
|
var selectedPrivateChatPeer: PeerID?
|
|
var nostrKeyMapping: [PeerID: String] = [:]
|
|
func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) { nostrKeyMapping[peerID] = pubkey }
|
|
func handlePrivateMessage(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID, id: NostrIdentity, messageTimestamp: Date) {}
|
|
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {}
|
|
func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {}
|
|
func startPrivateChat(with peerID: PeerID) {}
|
|
|
|
private struct NoIdentity: Error {}
|
|
var geohashIdentities: [String: NostrIdentity] = [:]
|
|
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
|
guard let identity = geohashIdentities[geohash] else { throw NoIdentity() }
|
|
return identity
|
|
}
|
|
func currentNostrIdentity() -> NostrIdentity? { nil }
|
|
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool { false }
|
|
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String { "anon#\(pubkeyHex.prefix(4))" }
|
|
|
|
private var processedNostrEventIDs: Set<String> = []
|
|
var processedEventCount: Int { processedNostrEventIDs.count }
|
|
func hasProcessedNostrEvent(_ eventID: String) -> Bool { processedNostrEventIDs.contains(eventID) }
|
|
func recordProcessedNostrEvent(_ eventID: String) { processedNostrEventIDs.insert(eventID) }
|
|
func clearProcessedNostrEvents() { processedNostrEventIDs.removeAll() }
|
|
|
|
var geoNicknames: [String: String] = [:]
|
|
private var teleportedKeys: Set<String> = []
|
|
var teleportedGeoCount: Int { teleportedKeys.count }
|
|
func startGeoParticipantRefreshTimer() {}
|
|
func stopGeoParticipantRefreshTimer() {}
|
|
func setActiveParticipantGeohash(_ geohash: String?) {}
|
|
private(set) var participantRecords = 0
|
|
func recordGeoParticipant(pubkeyHex: String) { participantRecords += 1 }
|
|
func recordGeoParticipant(pubkeyHex: String, geohash: String) { participantRecords += 1 }
|
|
func geoParticipantCount(for geohash: String) -> Int { 0 }
|
|
func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) { geoNicknames[pubkeyHex.lowercased()] = nickname }
|
|
func markGeoTeleported(_ pubkeyHexLowercased: String) { teleportedKeys.insert(pubkeyHexLowercased) }
|
|
func clearGeoTeleported(_ pubkeyHexLowercased: String) { teleportedKeys.remove(pubkeyHexLowercased) }
|
|
func clearTeleportedGeo() { teleportedKeys.removeAll() }
|
|
func clearGeoNicknames() { geoNicknames.removeAll() }
|
|
func visibleGeohashPeople() -> [GeoPerson] { [] }
|
|
|
|
var isTeleported = false
|
|
func isGeohashOutsideRegionalChannels(_ geohash: String) -> Bool { false }
|
|
|
|
func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {}
|
|
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {}
|
|
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {}
|
|
|
|
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? { nil }
|
|
func allFavoriteRelationships() -> [FavoritesPersistenceService.FavoriteRelationship] { [] }
|
|
func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String) {}
|
|
func postLocalNotification(title: String, body: String, identifier: String) {}
|
|
func notifyGeohashActivity(geohash: String, bodyPreview: String) {}
|
|
}
|
|
|
|
// MARK: - Mock ChatDeliveryContext
|
|
|
|
/// Minimal `ChatDeliveryContext` over a real `ConversationStore` (the
|
|
/// coordinator is a thin mapper onto store intents, so the measured cost is
|
|
/// the store's ID-map delivery path).
|
|
@MainActor
|
|
private final class PerfDeliveryContext: ChatDeliveryContext {
|
|
let store = ConversationStore()
|
|
var sentReadReceipts: Set<String> = []
|
|
var isStartupPhase: Bool { false }
|
|
private var publicIDs: [String] = []
|
|
private var privateIDsByPeer: [(peerID: PeerID, messageIDs: [String])] = []
|
|
|
|
func notifyUIChanged() {}
|
|
func markMessageDelivered(_ messageID: String) {}
|
|
|
|
@discardableResult
|
|
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
|
store.setDeliveryStatus(status, forMessageID: messageID)
|
|
}
|
|
|
|
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
|
|
store.deliveryStatus(forMessageID: messageID)
|
|
}
|
|
|
|
func privateMessageIDs() -> Set<String> {
|
|
store.directMessageIDs()
|
|
}
|
|
|
|
func pruneSentReadReceipts(keeping validMessageIDs: Set<String>) -> Int {
|
|
let oldCount = sentReadReceipts.count
|
|
sentReadReceipts = sentReadReceipts.intersection(validMessageIDs)
|
|
return oldCount - sentReadReceipts.count
|
|
}
|
|
|
|
/// `publicCount` public messages (split evenly between mesh and one
|
|
/// geohash conversation so the corpus stays under the per-conversation
|
|
/// cap) + `peerCount` x `messagesPerPeer` private messages, seeded into
|
|
/// the store with deterministic IDs and timestamps.
|
|
static func makeCorpus(publicCount: Int, peerCount: Int, messagesPerPeer: Int) -> PerfDeliveryContext {
|
|
let context = PerfDeliveryContext()
|
|
let base = Date(timeIntervalSince1970: 1_700_000_000)
|
|
let geohashID = ConversationID.geohash("u4pruyd")
|
|
for i in 0..<publicCount {
|
|
let message = BitchatMessage(
|
|
id: "pub-\(i)",
|
|
sender: "peer\(i % 20)",
|
|
content: "public message number \(i)",
|
|
timestamp: base.addingTimeInterval(Double(i)),
|
|
isRelay: false
|
|
)
|
|
context.store.append(message, to: i % 2 == 0 ? .mesh : geohashID)
|
|
context.publicIDs.append(message.id)
|
|
}
|
|
for p in 0..<peerCount {
|
|
let peerID = PeerID(str: String(format: "%016x", 0xA000_0000 + p))
|
|
var messageIDs: [String] = []
|
|
for i in 0..<messagesPerPeer {
|
|
let message = BitchatMessage(
|
|
id: "dm-\(p)-\(i)",
|
|
sender: "peer\(p)",
|
|
content: "private message \(i) for peer \(p)",
|
|
timestamp: base.addingTimeInterval(Double(i)),
|
|
isRelay: false,
|
|
isPrivate: true,
|
|
recipientNickname: "me",
|
|
senderPeerID: peerID
|
|
)
|
|
context.store.append(message, to: .directPeer(peerID))
|
|
messageIDs.append(message.id)
|
|
}
|
|
context.privateIDsByPeer.append((peerID, messageIDs))
|
|
}
|
|
return context
|
|
}
|
|
|
|
/// Deterministic update targets spread across the corpus: `publicTargets`
|
|
/// public IDs plus `privateTargets` private IDs.
|
|
func makeTargetIDs(publicTargets: Int, privateTargets: Int) -> [String] {
|
|
var targetIDs: [String] = []
|
|
let publicStride = max(1, publicIDs.count / publicTargets)
|
|
for i in stride(from: 0, to: publicIDs.count, by: publicStride) where targetIDs.count < publicTargets {
|
|
targetIDs.append(publicIDs[i])
|
|
}
|
|
var privateCount = 0
|
|
outer: for (_, messageIDs) in privateIDsByPeer {
|
|
for i in stride(from: 0, to: messageIDs.count, by: 4) {
|
|
guard privateCount < privateTargets else { break outer }
|
|
targetIDs.append(messageIDs[i])
|
|
privateCount += 1
|
|
}
|
|
}
|
|
return targetIDs
|
|
}
|
|
}
|
|
|
|
// MARK: - End-to-end pipeline fixture
|
|
|
|
/// A real `ChatViewModel` over `MockTransport` plus the AppRuntime-style
|
|
/// feature models (`PrivateInboxModel` / `PublicChatModel`) bound to the same
|
|
/// single-writer `ConversationStore`, so end-to-end ingest benchmarks cover
|
|
/// the full ingest-to-feature-model chain. Mirrors the construction used by
|
|
/// `ChatViewModelExtensionsTests`.
|
|
@MainActor
|
|
private final class PerfPipelineFixture {
|
|
let viewModel: ChatViewModel
|
|
let transport: MockTransport
|
|
let conversations: ConversationStore
|
|
let privateInbox: PrivateInboxModel
|
|
let publicChat: PublicChatModel
|
|
|
|
init() {
|
|
let keychain = MockKeychain()
|
|
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
|
let identityManager = MockIdentityManager(keychain)
|
|
let transport = MockTransport()
|
|
let conversations = ConversationStore()
|
|
|
|
self.transport = transport
|
|
self.conversations = conversations
|
|
self.viewModel = ChatViewModel(
|
|
keychain: keychain,
|
|
idBridge: idBridge,
|
|
identityManager: identityManager,
|
|
transport: transport,
|
|
conversations: conversations
|
|
)
|
|
self.privateInbox = PrivateInboxModel(conversations: conversations)
|
|
self.publicChat = PublicChatModel(conversations: conversations)
|
|
}
|
|
}
|
|
|
|
// MARK: - Mock MessageFormattingContext
|
|
|
|
@MainActor
|
|
private final class PerfFormattingContext: MessageFormattingContext {
|
|
let nickname: String
|
|
init(nickname: String) { self.nickname = nickname }
|
|
func isSelfMessage(_ message: BitchatMessage) -> Bool { false }
|
|
func senderColor(for message: BitchatMessage, isDark: Bool) -> Color { .blue }
|
|
func peerURL(for peerID: PeerID) -> URL? { URL(string: "bitchat://peer/\(peerID.id)") }
|
|
}
|