@astrosheep/square 0.3.72 → 0.3.73
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/artifact.d.ts +0 -3
- package/dist/artifact.js +5 -59
- package/dist/catch-decisions.d.ts +7 -1
- package/dist/catch-decisions.js +21 -8
- package/dist/claude-delivery.d.ts +1 -1
- package/dist/claude-delivery.js +7 -3
- package/dist/cli/ledger-command.d.ts +12 -0
- package/dist/cli/ledger-command.js +68 -0
- package/dist/cli/registry.js +2 -0
- package/dist/help.js +7 -2
- package/dist/host-ledger-file-adapter.d.ts +4 -10
- package/dist/host-ledger-file-adapter.js +238 -198
- package/dist/host-ledger-migration.d.ts +26 -0
- package/dist/host-ledger-migration.js +227 -0
- package/dist/host-ledger-sqlite.d.ts +62 -0
- package/dist/host-ledger-sqlite.js +341 -0
- package/dist/host-ledger.d.ts +13 -4
- package/dist/ledger-maintenance.d.ts +30 -0
- package/dist/ledger-maintenance.js +110 -0
- package/dist/ports.d.ts +7 -1
- package/dist/runtime.d.ts +2 -1
- package/dist/runtime.js +12 -6
- package/dist/sqlite-transaction.d.ts +5 -0
- package/dist/sqlite-transaction.js +72 -0
- package/dist/square-actions.js +16 -8
- package/dist/square-migration.js +2 -1
- package/dist/square-sqlite.d.ts +2 -2
- package/dist/square-sqlite.js +15 -4
- package/guides/catch-contention-performance.md +157 -0
- package/guides/change-waits.md +1 -1
- package/guides/host-ledger.md +53 -6
- package/guides/indexed-host-ledger-performance.md +39 -0
- package/package.json +1 -1
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/plugins/codex/.codex-plugin/plugin.json +1 -1
- package/plugins/paseo/client/square-message.tsx +35 -19
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { HostLedgerPort } from './host-ledger.js';
|
|
2
|
+
/** The two host-ledger capabilities maintenance uses; no parallel ledger contract is owned here. */
|
|
3
|
+
type MaintenanceLedger = Pick<HostLedgerPort, 'listEvidence' | 'retireEvidenceIfUnchanged'>;
|
|
4
|
+
export interface LedgerMaintenanceOptions {
|
|
5
|
+
/** Prove eligible rows without retiring anything. */
|
|
6
|
+
readonly dryRun?: boolean;
|
|
7
|
+
readonly now?: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Counts are retired rows, not reclaimed bytes: SQLite reuses freed storage without necessarily
|
|
11
|
+
* shrinking its file. Retired Claude correlations leave indefinite recognition tokens; total
|
|
12
|
+
* token count remains unbounded. Maintenance does not run VACUUM.
|
|
13
|
+
*/
|
|
14
|
+
export interface LedgerMaintenanceReport {
|
|
15
|
+
readonly dryRun: boolean;
|
|
16
|
+
/** Old rows whose closure proof passed; exact-record CAS may still refuse. */
|
|
17
|
+
readonly eligible: number;
|
|
18
|
+
/** Rows actually removed by this run; always 0 in a dry run. */
|
|
19
|
+
readonly retired: number;
|
|
20
|
+
/** Eligible rows whose full-record compare-and-swap refused; always 0 in a dry run. */
|
|
21
|
+
readonly casMisses: number;
|
|
22
|
+
/** Canonical locations whose artifact could not be read; their rows were retained. */
|
|
23
|
+
readonly unavailableArtifacts: readonly string[];
|
|
24
|
+
}
|
|
25
|
+
/** Command/application entry: root selection uses the caller's captured environment. */
|
|
26
|
+
export declare function gcHostLedger(options?: LedgerMaintenanceOptions & {
|
|
27
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
28
|
+
}): Promise<LedgerMaintenanceReport>;
|
|
29
|
+
export declare function maintainHostLedger(ledger: MaintenanceLedger, options?: LedgerMaintenanceOptions): Promise<LedgerMaintenanceReport>;
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { createArtifactPort } from './artifact.js';
|
|
2
|
+
import { canonicalPath } from './canonical-path.js';
|
|
3
|
+
import { formatActivityId, parseActivityId } from './square-core.js';
|
|
4
|
+
import { validateName } from './model.js';
|
|
5
|
+
import { hostLedgerForEnv } from './registry.js';
|
|
6
|
+
/**
|
|
7
|
+
* Explicit host-ledger evidence maintenance.
|
|
8
|
+
*
|
|
9
|
+
* Old wake/presentation rows are retired only when a typed artifact snapshot proves the
|
|
10
|
+
* recipient's attention is permanently closed: the activity reached them historically, and
|
|
11
|
+
* either catch recorded a receipt or the recipient joined again past it. Everything else is
|
|
12
|
+
* retained. Artifacts are read directly, one snapshot per canonical location, outside any
|
|
13
|
+
* ledger lock; retirement is the ledger's exact-record compare-and-swap. No SQL lives here.
|
|
14
|
+
*/
|
|
15
|
+
/** A row is old enough to consider once its landing time is at least this far behind now. */
|
|
16
|
+
const RETIREMENT_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
17
|
+
/** The canonical activity coordinate the row names; leading zeros and other spellings are invalid. */
|
|
18
|
+
function activityIndex(activity) {
|
|
19
|
+
const index = parseActivityId(activity);
|
|
20
|
+
return index === undefined || formatActivityId(index) !== activity ? undefined : index;
|
|
21
|
+
}
|
|
22
|
+
/** Old, durably closed-looking wake/presentation rows with usable coordinates. */
|
|
23
|
+
function isCandidate(record, now) {
|
|
24
|
+
if (record.kind !== 'wake' && record.kind !== 'presentation')
|
|
25
|
+
return false;
|
|
26
|
+
if (record.outcome === 'dispatching')
|
|
27
|
+
return false;
|
|
28
|
+
if (typeof record.at !== 'number' || !Number.isFinite(record.at) || record.at > now - RETIREMENT_AGE_MS)
|
|
29
|
+
return false;
|
|
30
|
+
if (typeof record.location !== 'string' || record.location.length === 0)
|
|
31
|
+
return false;
|
|
32
|
+
try {
|
|
33
|
+
validateName(record.participant);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* One artifact snapshot for one location either proves each candidate closed or reports the
|
|
42
|
+
* whole location unavailable. A missing, unreadable, or non-artifact path returns undefined
|
|
43
|
+
* and its rows are retained.
|
|
44
|
+
*/
|
|
45
|
+
async function provenClosable(location, group) {
|
|
46
|
+
const artifact = createArtifactPort(location);
|
|
47
|
+
try {
|
|
48
|
+
return (await artifact.read((facts) => group.filter((candidate) => {
|
|
49
|
+
const participant = candidate.record.participant;
|
|
50
|
+
if (facts.directedAt(participant, candidate.index) === undefined)
|
|
51
|
+
return false;
|
|
52
|
+
if (facts.receipt(participant, candidate.index) !== undefined)
|
|
53
|
+
return true;
|
|
54
|
+
const lastJoin = facts.participant(participant)?.lastJoinIndex;
|
|
55
|
+
return lastJoin !== undefined && lastJoin > candidate.index;
|
|
56
|
+
}))).value;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
await artifact.close();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Command/application entry: root selection uses the caller's captured environment. */
|
|
66
|
+
export function gcHostLedger(options = {}) {
|
|
67
|
+
return maintainHostLedger(hostLedgerForEnv(options.env ?? process.env), options);
|
|
68
|
+
}
|
|
69
|
+
export async function maintainHostLedger(ledger, options = {}) {
|
|
70
|
+
const dryRun = options.dryRun === true;
|
|
71
|
+
const now = options.now ?? Date.now();
|
|
72
|
+
const records = await ledger.listEvidence({ includeReleased: true, includeExpired: true, now });
|
|
73
|
+
const groups = new Map();
|
|
74
|
+
for (const record of records) {
|
|
75
|
+
if (!isCandidate(record, now))
|
|
76
|
+
continue;
|
|
77
|
+
const index = activityIndex(record.activity);
|
|
78
|
+
if (index === undefined)
|
|
79
|
+
continue;
|
|
80
|
+
const location = await canonicalPath(record.location).catch(() => undefined);
|
|
81
|
+
if (location === undefined)
|
|
82
|
+
continue;
|
|
83
|
+
const group = groups.get(location);
|
|
84
|
+
if (group === undefined)
|
|
85
|
+
groups.set(location, [{ record, index }]);
|
|
86
|
+
else
|
|
87
|
+
group.push({ record, index });
|
|
88
|
+
}
|
|
89
|
+
let eligible = 0;
|
|
90
|
+
let retired = 0;
|
|
91
|
+
let casMisses = 0;
|
|
92
|
+
const unavailable = new Set();
|
|
93
|
+
for (const location of [...groups.keys()].sort()) {
|
|
94
|
+
const closable = await provenClosable(location, groups.get(location));
|
|
95
|
+
if (closable === undefined) {
|
|
96
|
+
unavailable.add(location);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
eligible += closable.length;
|
|
100
|
+
if (dryRun)
|
|
101
|
+
continue;
|
|
102
|
+
for (const candidate of closable) {
|
|
103
|
+
if (await ledger.retireEvidenceIfUnchanged(candidate.record))
|
|
104
|
+
retired += 1;
|
|
105
|
+
else
|
|
106
|
+
casMisses += 1;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { dryRun, eligible, retired, casMisses, unavailableArtifacts: [...unavailable].sort() };
|
|
110
|
+
}
|
package/dist/ports.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Act, HardCap, InboxNotification, StoredAct, WakeRoute, WakeRouteKind, WatchLease } from './model.js';
|
|
2
|
-
export type { ClaimResult, EvidenceClaim, EvidenceRelease,
|
|
2
|
+
export type { ClaimResult, EvidenceClaim, EvidenceRelease, EvidenceLookup, EvidenceRecord, NativeTokenLookup, NativeTokenResult, HostLedgerPort, PresenceKey, PresenceLookup, PresenceRecord, PresenceClaimResult, PresenceChannel, PresenceResult, } from './host-ledger.js';
|
|
3
3
|
import type { HostLedgerPort } from './host-ledger.js';
|
|
4
4
|
export type ActivityIndex = number;
|
|
5
5
|
export interface SquareHeader {
|
|
@@ -20,6 +20,11 @@ export interface ParticipantFact {
|
|
|
20
20
|
readonly lastActiveAt?: number;
|
|
21
21
|
readonly lastJoinIndex?: number;
|
|
22
22
|
}
|
|
23
|
+
export interface ParticipantCounts {
|
|
24
|
+
readonly known: number;
|
|
25
|
+
readonly joined: number;
|
|
26
|
+
readonly done: number;
|
|
27
|
+
}
|
|
23
28
|
export interface AttentionFact {
|
|
24
29
|
readonly listener: string;
|
|
25
30
|
readonly target: string;
|
|
@@ -98,6 +103,7 @@ export interface RouteFact {
|
|
|
98
103
|
export interface SquareRead {
|
|
99
104
|
header(): SquareHeader;
|
|
100
105
|
participant(requestedName: string): ParticipantFact | undefined;
|
|
106
|
+
participantCounts(): ParticipantCounts;
|
|
101
107
|
roster(): readonly ParticipantFact[];
|
|
102
108
|
attention(listener: string): readonly AttentionFact[];
|
|
103
109
|
incomingAttention(sender: string): readonly AttentionFact[];
|
package/dist/runtime.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type ActivityId } from './square-core.js';
|
|
2
2
|
import { type StoredAct, type WatchLease } from './model.js';
|
|
3
|
-
import type { SquareRead } from './ports.js';
|
|
3
|
+
import type { ParticipantCounts, ParticipantFact, SquareRead } from './ports.js';
|
|
4
4
|
export declare const WATCH_HEARTBEAT_MS: number;
|
|
5
5
|
export declare const SLEEP_MS: number;
|
|
6
6
|
export declare const STALE_MS: number;
|
|
@@ -13,6 +13,7 @@ export declare function nowMs(): number;
|
|
|
13
13
|
export declare function rosterNames(facts: SquareRead): string[];
|
|
14
14
|
export declare function inSquareCount(facts: SquareRead): number;
|
|
15
15
|
export declare function actId(actOrIndex: StoredAct | number): ActivityId;
|
|
16
|
+
export declare function terminalStatusFor(current: ParticipantFact | undefined, hardCap: number | null, counts: ParticipantCounts): 'capped' | 'quorum' | undefined;
|
|
16
17
|
export declare function watchTerminalStatus(facts: SquareRead, name: string): 'capped' | 'quorum' | undefined;
|
|
17
18
|
/** A continuous prefix stops at the first unseen historical audience edge after join. */
|
|
18
19
|
export declare function readCursor(facts: SquareRead, name: string): number;
|
package/dist/runtime.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { formatActivityId } from './square-core.js';
|
|
2
|
-
import { SquareError
|
|
2
|
+
import { SquareError } from './model.js';
|
|
3
3
|
function parseIntegerEnv(name, fallback) {
|
|
4
4
|
const raw = process.env[name];
|
|
5
5
|
if (raw === undefined)
|
|
@@ -27,14 +27,20 @@ export function nowMs() {
|
|
|
27
27
|
return value;
|
|
28
28
|
}
|
|
29
29
|
export function rosterNames(facts) { return facts.roster().map((participant) => participant.name); }
|
|
30
|
-
export function inSquareCount(facts) { return facts.
|
|
30
|
+
export function inSquareCount(facts) { return facts.participantCounts().joined; }
|
|
31
31
|
export function actId(actOrIndex) { return formatActivityId(typeof actOrIndex === 'number' ? actOrIndex : actOrIndex.index); }
|
|
32
|
+
export function terminalStatusFor(current, hardCap, counts) {
|
|
33
|
+
if (hardCap !== null && (current?.sayCount ?? 0) >= hardCap)
|
|
34
|
+
return 'capped';
|
|
35
|
+
const peers = counts.known - Number(current !== undefined);
|
|
36
|
+
const donePeers = counts.done - Number(current?.done === true);
|
|
37
|
+
return peers > 0 && donePeers === peers ? 'quorum' : undefined;
|
|
38
|
+
}
|
|
32
39
|
export function watchTerminalStatus(facts, name) {
|
|
33
|
-
const cap = facts.header().hardCap;
|
|
34
|
-
if (cap !== null && (
|
|
40
|
+
const cap = facts.header().hardCap, current = facts.participant(name);
|
|
41
|
+
if (cap !== null && (current?.sayCount ?? 0) >= cap)
|
|
35
42
|
return 'capped';
|
|
36
|
-
|
|
37
|
-
return peers.length > 0 && peers.every((peer) => peer.done) ? 'quorum' : undefined;
|
|
43
|
+
return terminalStatusFor(current, cap, facts.participantCounts());
|
|
38
44
|
}
|
|
39
45
|
/** A continuous prefix stops at the first unseen historical audience edge after join. */
|
|
40
46
|
export function readCursor(facts, name) {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
/** Yield rather than blocking the process while another connection owns SQLite. */
|
|
3
|
+
export declare function retrySqliteBusy<T>(operation: () => T, signal?: AbortSignal, retryMs?: number | (() => number)): Promise<T>;
|
|
4
|
+
/** Acquisition may retry; COMMIT retries retain the callback's result and transaction. */
|
|
5
|
+
export declare function inSqliteTransaction<T, V>(db: DatabaseSync, write: boolean, validate: () => V, change: (validated: V) => T, signal?: AbortSignal): Promise<T>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
2
|
+
function abort(signal) {
|
|
3
|
+
if (signal?.aborted)
|
|
4
|
+
throw signal.reason ?? new Error('SQLite operation aborted');
|
|
5
|
+
}
|
|
6
|
+
function isBusy(error) {
|
|
7
|
+
return error instanceof Error && /database is locked|database is busy|SQLITE_BUSY/i.test(error.message);
|
|
8
|
+
}
|
|
9
|
+
/** Yield rather than blocking the process while another connection owns SQLite. */
|
|
10
|
+
export async function retrySqliteBusy(operation, signal, retryMs = 25) {
|
|
11
|
+
for (;;) {
|
|
12
|
+
abort(signal);
|
|
13
|
+
try {
|
|
14
|
+
return operation();
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (!isBusy(error))
|
|
18
|
+
throw error;
|
|
19
|
+
try {
|
|
20
|
+
await sleep(typeof retryMs === 'function' ? retryMs() : retryMs, undefined, { signal });
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
abort(signal);
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Acquisition may retry; COMMIT retries retain the callback's result and transaction. */
|
|
30
|
+
export async function inSqliteTransaction(db, write, validate, change, signal) {
|
|
31
|
+
let begun = false, backoffMs = 25;
|
|
32
|
+
// Spread writer contenders out without delaying a writer that already owns the lock.
|
|
33
|
+
const writerDelay = () => {
|
|
34
|
+
const delay = 1 + Math.floor(Math.random() * backoffMs);
|
|
35
|
+
backoffMs = Math.min(100, backoffMs * 2);
|
|
36
|
+
return delay;
|
|
37
|
+
};
|
|
38
|
+
try {
|
|
39
|
+
const validated = await retrySqliteBusy(() => {
|
|
40
|
+
db.exec(write ? 'BEGIN IMMEDIATE' : 'BEGIN');
|
|
41
|
+
begun = true;
|
|
42
|
+
try {
|
|
43
|
+
return validate();
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
db.exec('ROLLBACK');
|
|
47
|
+
begun = false;
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}, signal, write ? writerDelay : 25);
|
|
51
|
+
abort(signal);
|
|
52
|
+
const result = change(validated);
|
|
53
|
+
if (result !== null && (typeof result === 'object' || typeof result === 'function') && typeof result.then === 'function') {
|
|
54
|
+
void Promise.resolve(result).catch(() => undefined);
|
|
55
|
+
throw new TypeError('SQLite transaction callbacks must be synchronous.');
|
|
56
|
+
}
|
|
57
|
+
// A busy commit still owns the writer lock. Retry promptly once readers leave;
|
|
58
|
+
// acquisition contenders keep the longer backoff above.
|
|
59
|
+
await retrySqliteBusy(() => db.exec('COMMIT'), signal, 1);
|
|
60
|
+
begun = false;
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (begun) {
|
|
65
|
+
try {
|
|
66
|
+
db.exec('ROLLBACK');
|
|
67
|
+
}
|
|
68
|
+
catch { /* retain original failure */ }
|
|
69
|
+
}
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
package/dist/square-actions.js
CHANGED
|
@@ -2,7 +2,7 @@ import { coreDone, coreHold, coreIgnore, coreListen, coreListening, coreResume,
|
|
|
2
2
|
import { isSquareError, nameKey, SquareError, validateName } from './model.js';
|
|
3
3
|
import { participantIdentity } from './participant-identity.js';
|
|
4
4
|
import { deliverPending } from './delivery-operations.js';
|
|
5
|
-
import { decideCatch } from './catch-decisions.js';
|
|
5
|
+
import { decideCatch, observeCatchSelection, selectCatchPage } from './catch-decisions.js';
|
|
6
6
|
import { inSquareCount } from './runtime.js';
|
|
7
7
|
import { claimSessionParticipant, claimSessionTakeover, presenceEpoch, releaseSessionParticipantClaim, readParticipantOwner } from './registry.js';
|
|
8
8
|
import { assertLiveOwner, ensureLocalPresence, identityRouteDraft, processIdentity, publishCommittedIdentity, retireIdentityRoute } from './participant-host.js';
|
|
@@ -14,13 +14,21 @@ export async function catchUp(square, name, options = {}, control, leaseId) {
|
|
|
14
14
|
throwIfAborted(control);
|
|
15
15
|
if (!await assertLiveOwner(square, name))
|
|
16
16
|
throw new SquareError('already_joined', `${participantIdentity(name)} already stands here — another session holds the name`);
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return
|
|
22
|
-
|
|
23
|
-
}
|
|
17
|
+
let selected;
|
|
18
|
+
if (leaseId === undefined && options.id === undefined) {
|
|
19
|
+
selected = (await square.artifact.read((facts) => {
|
|
20
|
+
const page = selectCatchPage(facts, name, options);
|
|
21
|
+
return page.indexes.length === 0 ? observeCatchSelection(facts, page.participant, [], page.filter, square.clock()) : undefined;
|
|
22
|
+
}, control?.signal)).value;
|
|
23
|
+
}
|
|
24
|
+
if (selected === undefined)
|
|
25
|
+
selected = (await square.artifact.transact((facts) => {
|
|
26
|
+
// Replacement and selection share the receipt transaction: an old waiter cannot
|
|
27
|
+
// consume a later activity between its heartbeat and its selection.
|
|
28
|
+
if (leaseId !== undefined && facts.lease(name)?.leaseId !== leaseId)
|
|
29
|
+
return null;
|
|
30
|
+
return decideCatch(facts, name, options, square.clock());
|
|
31
|
+
}, control?.signal)).value;
|
|
24
32
|
if (selected !== null)
|
|
25
33
|
await publishCommittedIdentity(square, selected.participant);
|
|
26
34
|
return selected;
|
package/dist/square-migration.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { openExistingSquareDatabase } from './artifact.js';
|
|
2
|
+
import { inSqliteTransaction } from './sqlite-transaction.js';
|
|
2
3
|
import { canonicalPath } from './canonical-path.js';
|
|
3
4
|
import { isWakeRouteKind } from './model.js';
|
|
4
5
|
import { parseActivityId } from './square-core.js';
|
package/dist/square-sqlite.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type DatabaseSync } from 'node:sqlite';
|
|
2
2
|
import { SquareError, type ActivityObservation, type HardCap, type StoredAct, type WakeRoute, type WatchLease } from './model.js';
|
|
3
|
-
import type { SquareRead, RouteFact } from './ports.js';
|
|
3
|
+
import type { SquareHeader, SquareRead, RouteFact } from './ports.js';
|
|
4
4
|
import { type LandingWriter } from './square-domain.js';
|
|
5
5
|
/** Explicit import/export DTOs, not a runtime transaction model. */
|
|
6
6
|
export interface SquareRuntimeState {
|
|
@@ -43,7 +43,7 @@ export declare function invalidArtifact(detail: string): SquareError;
|
|
|
43
43
|
export declare function validateStoredActivity(value: unknown): StoredAct;
|
|
44
44
|
export declare function validateWatchLease(value: unknown): WatchLease;
|
|
45
45
|
export declare function validateRoute(value: unknown): RouteFact;
|
|
46
|
-
export declare function validateSqliteHeader(db: DatabaseSync, location?: string):
|
|
46
|
+
export declare function validateSqliteHeader(db: DatabaseSync, location?: string): SquareHeader;
|
|
47
47
|
export interface SqliteDriver extends SquareRead, LandingWriter {
|
|
48
48
|
readonly changed: boolean;
|
|
49
49
|
}
|
package/dist/square-sqlite.js
CHANGED
|
@@ -141,6 +141,7 @@ CREATE TABLE landed_audience (
|
|
|
141
141
|
UNIQUE (act_index, recipient_order)
|
|
142
142
|
) WITHOUT ROWID;
|
|
143
143
|
CREATE INDEX audience_recipient ON landed_audience(recipient_key_json, act_index);`;
|
|
144
|
+
const EXPECTED_SCHEMA_SQL = SCHEMA_SQL.split(';').map(s => s.trim()).filter(Boolean).map(normalizedSql).sort();
|
|
144
145
|
const encode = JSON.stringify;
|
|
145
146
|
const key = (name) => encode(nameKey(name));
|
|
146
147
|
export function invalidArtifact(detail) {
|
|
@@ -330,11 +331,10 @@ export function validateSqliteHeader(db, location = '') {
|
|
|
330
331
|
throw new SquareError('invalid_args', `Square artifact needs migration.\n${migrationRecoveryCommand(location)}`, { reason: 'migration_required' });
|
|
331
332
|
}
|
|
332
333
|
requireFact(identity.user_version === 2, 'unsupported SQLite format version.');
|
|
333
|
-
const expected = SCHEMA_SQL.split(';').map(s => s.trim()).filter(Boolean).map(normalizedSql).sort();
|
|
334
334
|
const actual = db.prepare("SELECT sql FROM sqlite_schema WHERE name NOT GLOB 'sqlite_*' AND sql IS NOT NULL").all().map(r => normalizedSql(string(r.sql))).sort();
|
|
335
|
-
requireFact(actual.length ===
|
|
335
|
+
requireFact(actual.length === EXPECTED_SCHEMA_SQL.length && actual.every((sql, i) => sql === EXPECTED_SCHEMA_SQL[i]), 'unsupported row schema.');
|
|
336
336
|
requireFact(db.prepare('PRAGMA journal_mode').get().journal_mode === (location === ':memory:' ? 'memory' : 'delete'), 'unsupported journal mode.');
|
|
337
|
-
createSqliteDriver(db).header();
|
|
337
|
+
return createSqliteDriver(db).header();
|
|
338
338
|
}
|
|
339
339
|
function activitySelection(filter, alias = 'a') {
|
|
340
340
|
const terms = [], args = [];
|
|
@@ -418,6 +418,10 @@ export function createSqliteDriver(db) {
|
|
|
418
418
|
const r = one('SELECT * FROM participants WHERE name_key_json=?', [key(name)]);
|
|
419
419
|
return r ? participant(r) : undefined;
|
|
420
420
|
},
|
|
421
|
+
participantCounts() {
|
|
422
|
+
const r = one('SELECT COUNT(*) AS known, COALESCE(SUM(joined),0) AS joined, COALESCE(SUM(done),0) AS done FROM participants');
|
|
423
|
+
return immutable({ known: integer(r.known), joined: integer(r.joined), done: integer(r.done) });
|
|
424
|
+
},
|
|
421
425
|
roster: () => immutable(all('SELECT * FROM participants ORDER BY first_index').map(participant)),
|
|
422
426
|
attention: name => immutable(attentionRows('t.listener_key_json=?', [key(name)])),
|
|
423
427
|
incomingAttention: name => immutable(attentionRows('t.target_key_json=?', [key(name)])),
|
|
@@ -468,7 +472,14 @@ export function createSqliteDriver(db) {
|
|
|
468
472
|
sayOrdinals(indexes) {
|
|
469
473
|
indexes.forEach(v => integer(v));
|
|
470
474
|
const result = {};
|
|
471
|
-
|
|
475
|
+
// Only an actor's latest say can use its authoritative lifetime count.
|
|
476
|
+
for (const r of all(`SELECT a.act_index, CASE
|
|
477
|
+
WHEN p.say_count IS NOT NULL AND a.act_index=(SELECT MAX(s.act_index) FROM activities s WHERE s.kind='say' AND s.actor_key_json=a.actor_key_json)
|
|
478
|
+
THEN p.say_count
|
|
479
|
+
ELSE (SELECT COUNT(*) FROM activities s WHERE s.kind='say' AND s.actor_key_json=a.actor_key_json AND s.act_index<=a.act_index)
|
|
480
|
+
END AS n
|
|
481
|
+
FROM activities a LEFT JOIN participants p ON p.name_key_json=a.actor_key_json
|
|
482
|
+
WHERE a.kind='say' AND a.act_index IN (SELECT value FROM json_each(?))`, [encode(indexes)]))
|
|
472
483
|
result[integer(r.act_index)] = integer(r.n, 1);
|
|
473
484
|
return immutable(result);
|
|
474
485
|
},
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# Catch contention: commit retries, empty reads, and shorter writes
|
|
2
|
+
|
|
3
|
+
A busy SQLite COMMIT retains the write transaction while waiting for readers to leave. Previously it slept 25 ms between attempts, the same as contenders waiting to acquire a transaction. The retained writer also prevents those contenders from progressing.
|
|
4
|
+
|
|
5
|
+
The change retries COMMIT after 1 ms while retaining the existing 25 ms acquisition backoff. It preserves the transaction, callback result, cancellation and rollback behavior. The shared helper applies to both artifact and host-ledger transactions. No journal mode, receipt semantics, workload, cache or background service changed.
|
|
6
|
+
|
|
7
|
+
## Measurement
|
|
8
|
+
|
|
9
|
+
The benchmark now times opening, recognition, first catch, second catch and closing separately. Both baseline and candidate used this instrumentation and the same command:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
node --import ./test/sandbox-env.js scripts/benchmark-idle-wait.mjs --workers 300 --mode session --active --no-hints
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Three sequential runs per implementation, each covering both history sizes. Baseline production is `f44f359`; candidate changes only COMMIT retry timing. Baselines ran before candidates, not in randomized order. These local measurements show improvement but are not confidence intervals or a universal latency guarantee.
|
|
16
|
+
|
|
17
|
+
Values below are the **median of three run-level measurements**. For latency, each measurement is the p95 across 300 workers.
|
|
18
|
+
|
|
19
|
+
| Metric | Before, 1,800 history | After, 1,800 | Before, 18,000 | After, 18,000 |
|
|
20
|
+
| --- | ---: | ---: | ---: | ---: |
|
|
21
|
+
| First catch API p95 (ms) | 6,596.63 | 4,619.84 | 7,302.29 | 4,781.38 |
|
|
22
|
+
| Second catch API p95 (ms) | 3,620.93 | 2,413.91 | 4,034.01 | 2,613.97 |
|
|
23
|
+
| Whole catch phase p95 (ms) | 7,262.52 | 5,464.51 | 8,126.17 | 5,653.63 |
|
|
24
|
+
| Aggregate phase CPU (ms) | 23,466.80 | 20,205.76 | 27,313.38 | 21,241.62 |
|
|
25
|
+
| Artifact busy observations | 47,578 | 30,824 | 53,418 | 32,265 |
|
|
26
|
+
| Artifact COMMIT busy observations | 95 | 138 | 85 | 117 |
|
|
27
|
+
|
|
28
|
+
First-catch p95 medians fell approximately 30% and 35%; whole-phase medians fell 25% and 30%. Aggregate CPU medians fell 14% and 22%. COMMIT retry counts increased, as expected from polling the retained writer sooner, while total busy observations and CPU medians decreased.
|
|
29
|
+
|
|
30
|
+
Run-level first-catch p95s, in execution order:
|
|
31
|
+
|
|
32
|
+
| History | Before (ms) | After (ms) |
|
|
33
|
+
| --- | --- | --- |
|
|
34
|
+
| 1,800 | 6,119.17 / 6,596.63 / 6,680.30 | 4,619.84 / 4,233.23 / 6,642.77 |
|
|
35
|
+
| 18,000 | 7,785.32 / 7,302.29 / 5,966.01 | 4,781.38 / 4,995.59 / 4,694.29 |
|
|
36
|
+
|
|
37
|
+
The third small-history candidate run was slower than its other two runs and overlapped the baseline range. Its whole-phase p95 was 7,602.57 ms, above all three small-history baseline runs. Contention and machine-load variability remain; this change does not remove multi-second waiting under 300 simultaneous processes.
|
|
38
|
+
|
|
39
|
+
All twelve scenarios discovered 300 participants, consumed one activity each and consumed none again. All stderr files were empty. First-catch timing covers the full awaited API call, including ownership checks and postcommit identity publication. Whole-phase timing additionally includes open, recognize, the second catch and close; percentiles of individual steps must not be added.
|
|
40
|
+
|
|
41
|
+
Raw local reports: `/tmp/square-catch-baseline-{1,2,3}.json` and `/tmp/square-catch-commit-{1,2,3}.json`. These measure isolated local storage, not native delivery or ordinary single-user latency.
|
|
42
|
+
|
|
43
|
+
## Correctness boundaries
|
|
44
|
+
|
|
45
|
+
Existing SQLite lifecycle tests exercise real blocked COMMITs, single callback execution, event-loop progress, cancellation rollback and cross-process receipt exclusion. Cancellation tests cover precommit rollback and postcommit success. The COMMIT-only measurements above retain the empty-catch write transaction. The subsequent optimization below measures its removal separately.
|
|
46
|
+
|
|
47
|
+
## Follow-up: empty catches and repeated observation work
|
|
48
|
+
|
|
49
|
+
Ordinary non-ID, non-leased catches now inspect pending indexes in a read transaction. An empty page returns its complete observation from that snapshot without acquiring the artifact writer or hydrating bodies. A nonempty page is selected again inside the existing receipt transaction; the probe never decides which activities get consumed. Exact-ID and leased catches keep their existing write path. Ownership checks and identity publication remain in place.
|
|
50
|
+
|
|
51
|
+
Catch observations now reuse one roster and header for standing count and terminal status. Schema validation precomputes only the expected SQL constant; actual schema, identity, version, journal mode, and header checks still run inside each transaction. These three changes were measured together, so the results do not isolate each change's contribution.
|
|
52
|
+
|
|
53
|
+
The same benchmark command ran three times on baseline `03623cf`, then three times on the candidate, covering both histories each time. These are fresh baselines, separate from the earlier COMMIT experiment. No workload ran alongside the benchmarks. As above, ordering was sequential, not randomized, and results describe this local 300-process workload.
|
|
54
|
+
|
|
55
|
+
| Metric | Before, 1,800 history | After, 1,800 | Before, 18,000 | After, 18,000 |
|
|
56
|
+
| --- | ---: | ---: | ---: | ---: |
|
|
57
|
+
| First catch API p95 (ms) | 5,681.03 | 3,729.06 | 4,967.29 | 3,654.90 |
|
|
58
|
+
| Second, empty catch API p95 (ms) | 2,868.83 | 79.80 | 2,608.11 | 55.08 |
|
|
59
|
+
| Whole catch phase p95 (ms) | 6,315.96 | 4,111.88 | 5,701.10 | 3,887.74 |
|
|
60
|
+
| Aggregate phase CPU (ms) | 22,441.95 | 17,401.83 | 21,456.88 | 16,184.47 |
|
|
61
|
+
| Artifact busy observations | 34,542 | 18,780 | 32,986 | 18,638 |
|
|
62
|
+
| Artifact COMMIT busy observations | 137 | 323 | 148 | 287 |
|
|
63
|
+
|
|
64
|
+
Each cell is the median of three run-level measurements. Empty-catch p95 medians fell 97% and 98%; first-catch medians fell 34% and 26%; whole-phase medians fell 35% and 32%; CPU medians fell 22% and 25%. Total busy observations fell, while COMMIT busy observations increased. The extra read probe changes contention; fewer writer acquisitions do not imply every busy counter falls.
|
|
65
|
+
|
|
66
|
+
Run-level p95s in execution order:
|
|
67
|
+
|
|
68
|
+
| History | Call | Before (ms) | After (ms) |
|
|
69
|
+
| --- | --- | --- | --- |
|
|
70
|
+
| 1,800 | First | 7,655.42 / 4,857.49 / 5,681.03 | 4,274.17 / 3,729.06 / 3,557.04 |
|
|
71
|
+
| 18,000 | First | 4,967.29 / 5,029.40 / 4,895.89 | 5,005.24 / 3,356.44 / 3,654.90 |
|
|
72
|
+
| 1,800 | Empty | 4,495.46 / 2,348.03 / 2,868.83 | 56.35 / 79.80 / 82.83 |
|
|
73
|
+
| 18,000 | Empty | 3,139.28 / 2,506.64 / 2,608.11 | 53.31 / 55.08 / 56.97 |
|
|
74
|
+
|
|
75
|
+
The first large-history candidate's consuming catch overlaps the baseline range. Consuming catches still wait seconds under this load; this is not a guarantee of millisecond catch latency generally. The second catch measures an empty result immediately after consumption, not a long-running idle lease.
|
|
76
|
+
|
|
77
|
+
All twelve scenarios discovered 300 participants, consumed exactly one activity per worker, and consumed none again: 3,600 successful worker results. Each scenario hydrated 300 bodies and issued 600 artifact SQL writes. Empty baseline transactions already wrote no rows; the improvement removes writer acquisition, not receipt writes. Successful artifact transactions per scenario increase from 1,800 to 2,100 because nonempty catches add a read probe. All stderr files were empty.
|
|
78
|
+
|
|
79
|
+
Focused tests exercise empty snapshot completeness, absence of writer acquisition/body hydration/receipts, a competing consumer between probe and write selection, exact-ID repetition, lease fencing, validation, cancellation, and ownership publication. The existing performance assertion now checks that an empty catch makes no hydration request.
|
|
80
|
+
|
|
81
|
+
Raw local reports: `/tmp/square-catch-empty-before-{1,2,3}.json` and `/tmp/square-catch-empty-after-{1,2,3}.json`. The final cap-first short circuit in `watchTerminalStatus()` restores its earlier read behavior; it is used by lease pulses and lies outside this benchmark's non-idle path.
|
|
82
|
+
|
|
83
|
+
## Follow-up: shortening consuming transactions
|
|
84
|
+
|
|
85
|
+
The next candidate adds transaction-local participant counts instead of decoding the whole roster for catch/terminal summaries. Latest-say ordinals use the existing participant lifetime count when the selected activity is that actor's latest say; older activities retain the indexed prefix count. Artifact acquisition also passes its already-validated header to the transaction callback instead of reading and decoding it again. Schema validation still occurs inside every transaction. There is no persistent schema change or cross-transaction cache.
|
|
86
|
+
|
|
87
|
+
The benchmark now measures explicit transaction boundaries per connection and separates artifact from host-ledger connections:
|
|
88
|
+
|
|
89
|
+
- Acquisition: first BEGIN attempt to successful BEGIN return, including retry gaps.
|
|
90
|
+
- Hold: successful BEGIN return to first COMMIT attempt, including validation, decoding and off-CPU wall time.
|
|
91
|
+
- Commit: first COMMIT attempt to successful COMMIT return, including retries.
|
|
92
|
+
|
|
93
|
+
Only committed transactions contribute timing samples. Rollback resets the sample; unsuccessful read acquisition/validation retries are visible in counts but do not become committed timings. `BEGIN DEFERRED` returning does not imply a read lock has already been acquired. Concurrent read durations cannot be summed into a serial lock-occupancy number. Batched transaction controls without observable inner boundaries are counted separately and excluded from exact timings. Artifact SQL execution durations exclude preparation and JavaScript decoding. Bounded self-tests exercise busy BEGIN, busy COMMIT, rollback/reset and origin separation.
|
|
94
|
+
|
|
95
|
+
Fresh baseline `13d1a36` and candidate each ran three times with the same new instrumentation and the command above, both histories per run. Baselines ran first; no test suite or second benchmark ran alongside the measurements. These changes were measured together, not as independent experiments.
|
|
96
|
+
|
|
97
|
+
| Metric | Before, 1,800 history | After, 1,800 | Before, 18,000 | After, 18,000 |
|
|
98
|
+
| --- | ---: | ---: | ---: | ---: |
|
|
99
|
+
| First catch API p95 (ms) | 3,528.76 | 2,678.86 | 3,685.07 | 2,865.71 |
|
|
100
|
+
| Empty catch API p95 (ms) | 68.23 | 89.97 | 58.31 | 91.11 |
|
|
101
|
+
| Whole catch phase p95 (ms) | 3,831.22 | 2,998.22 | 4,165.47 | 3,177.27 |
|
|
102
|
+
| Write acquisition p95 (ms) | 2,739.46 | 1,950.57 | 2,961.04 | 2,257.01 |
|
|
103
|
+
| Write hold p95 (ms) | 8.33 | 5.32 | 10.05 | 5.88 |
|
|
104
|
+
| Write commit p95 (ms) | 4.15 | 3.81 | 4.36 | 4.24 |
|
|
105
|
+
| Aggregate phase CPU (ms) | 16,348.86 | 13,014.67 | 17,732.24 | 13,499.75 |
|
|
106
|
+
| Artifact busy observations | 16,201 | 13,081 | 20,023 | 12,508 |
|
|
107
|
+
|
|
108
|
+
Cells are medians of three run-level measurements. Each scenario has 300 successful consuming write transactions, so write percentiles span those 300 transactions. First-catch p95 medians fall 24% and 22%; whole-phase medians fall 22% and 24%; CPU falls 20% and 24%. Write acquisition falls 29% and 24%, and hold p95 falls 36% and 42%. Percentiles of these stages must not be added to reconstruct the API percentile.
|
|
109
|
+
|
|
110
|
+
Empty-catch p95 regresses by 22 ms and 33 ms (32% and 56%) in this contended workload. The faster consuming path does not improve every operation. The current measurements do not isolate the cause of this regression; empty catches still use a read transaction, and the 25 ms acquisition retry interval is unchanged. The consuming-call improvement outweighs it in whole-phase time, but the regression remains part of the result.
|
|
111
|
+
|
|
112
|
+
| History | Call | Before p95 runs (ms) | After p95 runs (ms) |
|
|
113
|
+
| --- | --- | --- | --- |
|
|
114
|
+
| 1,800 | First | 3,572.79 / 3,123.90 / 3,528.76 | 2,361.69 / 2,732.76 / 2,678.86 |
|
|
115
|
+
| 18,000 | First | 3,577.88 / 3,685.07 / 4,165.72 | 2,865.71 / 2,975.57 / 2,813.31 |
|
|
116
|
+
| 1,800 | Empty | 68.23 / 85.81 / 61.32 | 100.68 / 89.97 / 87.47 |
|
|
117
|
+
| 18,000 | Empty | 57.38 / 59.12 / 58.31 | 108.55 / 83.11 / 91.11 |
|
|
118
|
+
|
|
119
|
+
Every candidate first-catch p95 is below its history's baseline range, but these sequential local runs are not confidence intervals or a general latency guarantee. Acquisition still takes seconds near the tail under 300 simultaneous processes. Some individual hold/commit samples are much longer than their p95; wall-time instrumentation alone cannot attribute all of that time to SQL or process scheduling.
|
|
120
|
+
|
|
121
|
+
All twelve scenarios discovered 300 participants, consumed one activity each, and consumed none again: 3,600 successful worker results. Each scenario retains 300 hydrated bodies, 600 SQL writes, 300 committed artifact writes and 1,800 committed artifact reads. All stderr files were empty. Tests cover aggregate read-your-writes and rollback, known-but-not-standing peers, missing/done caller quorum, cap precedence, and ordinal numbering across aliases, gaps, rejoins and subsequent writes.
|
|
122
|
+
|
|
123
|
+
Raw local reports: `/tmp/square-catch-positive-before-{1,2,3}.json` and `/tmp/square-catch-positive-after-{1,2,3}.json`. Earlier reports lack these transaction timing fields and should not be mixed into this comparison.
|
|
124
|
+
|
|
125
|
+
## Follow-up: spreading writer acquisition retries
|
|
126
|
+
|
|
127
|
+
Writer acquisition now uses a per-transaction backoff window of 25, 50, then at most 100 ms. Each busy retry independently chooses an integer delay from 1 through that window. Read acquisition and other default retries remain fixed at 25 ms; a busy COMMIT still retries after 1 ms while retaining the writer. The shared helper applies this policy to artifact and host-ledger writes. Cancellation, rollback, synchronous callbacks, receipt selection and storage framing are unchanged.
|
|
128
|
+
|
|
129
|
+
This changes both retry frequency and synchronization, not jitter alone. At the cap the mean requested delay is 50.5 ms rather than 25 ms. The intent is to reduce contender overhead, not to pretend queue waiting is idle time: serial write hold plus commit already accounts for most of the previous catch duration. Wall-time measurements cannot prove how much of the benefit comes from scheduling or filesystem contention. There is no fairness guarantee; an individual contended retry can sleep longer than before. Uncontended attempts do not sleep.
|
|
130
|
+
|
|
131
|
+
Baseline `f004621` and the isolated writer-only candidate each ran three times with the same instrumentation and command above. Execution order was baseline 1, three rejected schema-normalization experiments, baselines 2–3, one broader retry pilot, then writer-only candidates 1–3. No build, test suite or second benchmark ran alongside a benchmark. This was not a randomized comparison; prior tables are separate experiments and should not be used as its baseline. The schema-normalization fast path showed no consistent benefit and was removed. The broader pilot is excluded from the results below.
|
|
132
|
+
|
|
133
|
+
| Metric | Before, 1,800 history | After, 1,800 | Before, 18,000 | After, 18,000 |
|
|
134
|
+
| --- | ---: | ---: | ---: | ---: |
|
|
135
|
+
| First catch API p95 (ms) | 2,942.43 | 2,696.24 | 3,314.33 | 2,703.95 |
|
|
136
|
+
| Empty catch API p95 (ms) | 114.52 | 83.35 | 107.99 | 85.06 |
|
|
137
|
+
| Whole catch phase p95 (ms) | 3,281.04 | 3,089.50 | 3,797.85 | 3,008.00 |
|
|
138
|
+
| Write acquisition p95 (ms) | 2,460.38 | 2,044.42 | 2,441.14 | 2,114.18 |
|
|
139
|
+
| Write hold p95 (ms) | 6.43 | 5.46 | 7.20 | 5.39 |
|
|
140
|
+
| Write commit p95 (ms) | 4.72 | 3.87 | 4.64 | 3.50 |
|
|
141
|
+
| Aggregate phase CPU (ms) | 14,791.25 | 12,797.97 | 16,050.74 | 12,994.48 |
|
|
142
|
+
| Artifact busy observations | 14,311 | 8,943 | 15,203 | 8,966 |
|
|
143
|
+
|
|
144
|
+
Cells are medians of three run-level measurements. First-catch p95 medians improve 8% and 18%, whole-phase medians 6% and 21%, and CPU 13% and 19%. Empty-catch p95 medians improve 27% and 21%; total busy observations fall 38% and 41%. Median run-level maximum write acquisition falls from 2,835.81 to 2,626.85 ms and from 3,064.84 to 2,656.63 ms; this is not a bounded maximum or a starvation guarantee.
|
|
145
|
+
|
|
146
|
+
| History | Call | Before p95 runs (ms) | After p95 runs (ms) |
|
|
147
|
+
| --- | --- | --- | --- |
|
|
148
|
+
| 1,800 | First | 3,518.32 / 2,942.43 / 2,797.10 | 2,479.32 / 2,696.24 / 2,829.63 |
|
|
149
|
+
| 18,000 | First | 2,791.81 / 3,314.33 / 3,457.89 | 2,576.67 / 2,759.20 / 2,703.95 |
|
|
150
|
+
| 1,800 | Empty | 114.52 / 114.76 / 100.27 | 83.35 / 84.59 / 82.22 |
|
|
151
|
+
| 18,000 | Empty | 118.99 / 107.99 / 63.28 | 108.44 / 85.06 / 61.79 |
|
|
152
|
+
|
|
153
|
+
Small-history consuming and whole-phase ranges overlap; large-history consuming ranges do not. Not every tail metric improves: the large-history median of each run's maximum COMMIT duration rises from 506.88 to 920.92 ms, despite the lower COMMIT p95. These local three-run results show lower contention overhead, not a universal latency improvement. Consuming catch still takes seconds near the tail with 300 simultaneous processes.
|
|
154
|
+
|
|
155
|
+
All twelve scenarios discovered 300 participants, consumed exactly one activity each and consumed none again: 3,600 successful worker results. Each retains 300 hydrated bodies, 600 SQL writes, 300 committed artifact writes and 1,800 committed artifact reads. All stderr files were empty. A new real-lock test checks cancellation before writer acquisition and successful single-callback execution on a subsequent transaction; existing tests retain busy COMMIT cancellation, same-result retries and cross-process receipt exclusion.
|
|
156
|
+
|
|
157
|
+
Raw local reports: `/tmp/square-lock-before-{1,2,3}.json` and `/tmp/square-lock-writer-{1,2,3}.json`. Discarded experiments are `/tmp/square-lock-schema-{1,2,3}.json` and `/tmp/square-lock-jitter-1.json`.
|
package/guides/change-waits.md
CHANGED
|
@@ -4,7 +4,7 @@ Square's artifact and host-ledger adapters own change detection. Notifications a
|
|
|
4
4
|
|
|
5
5
|
- A process shares a revision reader per canonical artifact and a filesystem watcher per containing directory. Subscribers have independent baselines, deadlines, and cancellation. The final release closes the watcher, reconciliation timer, and any outstanding read.
|
|
6
6
|
- Directory notifications invalidate lightweight reads. A two-second reconciliation checks revisions/ledger metadata even when notifications are missing. Missing directories, unsupported watches, and watcher failures retain that fallback; directory replacement is detected during reconciliation. Normal notification latency is not a hard guarantee: a missed hint may wait for reconciliation, plus scheduling and lock contention.
|
|
7
|
-
- The artifact revision remains authoritative. No new sidecar, broker, or persistent cache is introduced. Changed projections read typed recipient facts and selected bodies, not the whole historical state. A missing artifact is a stable observation, not a repeated change: reconciliation remains quiet while it stays absent and detects its return without deleting its session bindings. Strict artifact reads still report absence as an error.
|
|
7
|
+
- The artifact revision remains authoritative. No new sidecar, broker, or persistent cache is introduced. Changed projections read typed recipient facts and selected bodies, not the whole historical state. A missing artifact is a stable observation, not a repeated change: reconciliation remains quiet while it stays absent and detects its return without deleting its session bindings. Strict artifact reads still report absence as an error. Host-ledger database metadata invalidates a process-shared read of its separate presence/evidence revisions; unchanged metadata requires no SQLite open. Revisions are read together in one short snapshot and cached against metadata captured before that read, so a concurrent mutation remains detectable. Failed reads are not cached. Missing ledger storage is stable until it appears. Neither metadata nor revisions are delivery evidence.
|
|
8
8
|
- Each Pi session owns a long-lived pending observer, including when it has no bindings yet. Presence and evidence changes update its subscriptions. There is no repeated thirty-second restart and no per-second binding scan. A failed injection waits for a substantive change; its own failed evidence write does not trigger a retry loop.
|
|
9
9
|
- Blocking CLI catch waits for a revision or its next heartbeat/quiet deadline. A held catch keeps its lease alive and pauses quiet expiry. Core catch, NDJSON stream, and delivery-receipt waits use the same change detector. Held express also waits for changes; throttling still waits for its eligibility time.
|
|
10
10
|
- Catch lease expiry, ledger retention/claim expiry, and cancellation are independent wake conditions. No filesystem write is required for a time-based eligibility change.
|