Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,7 @@ function folderView(overrides: Partial<SnapshotDescriptor> = {}): SnapshotDescri
children: [],
ancestors: [],
deadLetters: [],
blocked: null,
settingsHold: null,
binIndexHold: null,
queueHold: null,
retainedRecords: 0,
staleness: 'fresh',
...overrides,
Expand Down Expand Up @@ -1364,7 +1362,7 @@ describe('the queue overlay', () => {
engine,
folderView({
children: [file(NOTE, 'notes.txt')],
binIndexHold: { opId: 8n, node: NOTE, check: 'timed-out' },
queueHold: { reason: 'bin-index', opId: 8n, node: NOTE, check: 'timed-out' },
})
);

Expand Down
21 changes: 13 additions & 8 deletions apps/web/src/components/file-browser/QueueHoldNotice.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ describe('the queue hold notice', () => {
render(
<QueueHoldNotice
view={listing({
settingsHold: { opId: 4n, node: NODE, check: 'byo-provider-missing' },
queueHold: { reason: 'settings', opId: 4n, node: NODE, check: 'byo-provider-missing' },
})}
/>
);
Expand All @@ -34,7 +34,9 @@ describe('the queue hold notice', () => {
it('names why the bin index did not resolve, and clears when the hold clears', () => {
const { rerender } = render(
<QueueHoldNotice
view={listing({ binIndexHold: { opId: 5n, node: NODE, check: 'suppressed' } })}
view={listing({
queueHold: { reason: 'bin-index', opId: 5n, node: NODE, check: 'suppressed' },
})}
/>
);
expect(screen.getByTestId('queue-hold-notice').textContent).toContain(
Expand All @@ -49,7 +51,12 @@ describe('the queue hold notice', () => {
render(
<QueueHoldNotice
view={listing({
binIndexHold: { opId: 6n, node: new Uint8Array(16).fill(9), check: 'timed-out' },
queueHold: {
reason: 'bin-index',
opId: 6n,
node: new Uint8Array(16).fill(9),
check: 'timed-out',
},
})}
/>
);
Expand All @@ -59,17 +66,15 @@ describe('the queue hold notice', () => {
expect(notice.textContent).not.toContain('child-0');
});

it('reports both holds at once', () => {
it('leaves the over-quota hold to the upload panel that renders its figure', () => {
render(
<QueueHoldNotice
view={listing({
settingsHold: { opId: 4n, node: NODE, check: 'byo-endpoint-insecure' },
binIndexHold: { opId: 5n, node: NODE, check: 'floor-unreadable' },
queueHold: { reason: 'quota', opId: 7n, node: NODE, neededBytes: 900n },
})}
/>
);

expect(screen.getByTestId('queue-hold-notice').textContent).toContain('2 changes are waiting');
expect(screen.getAllByRole('listitem')).toHaveLength(2);
expect(screen.queryByTestId('queue-hold-notice')).toBeNull();
});
});
38 changes: 12 additions & 26 deletions apps/web/src/components/file-browser/QueueHoldNotice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,38 +24,24 @@ const BIN_INDEX_CAUSES: Record<BinIndexHoldCheck, string> = {
};

/**
* The two held queue heads the engine reports beside the over-quota hold: the
* member's own settings refused the head, or the owner's bin index did not
* resolve for it. Both clear, so the notice follows the snapshot and goes when
* the hold does.
* The held queue head, when the member's own settings refused it or the owner's
* bin index did not resolve for it. The over-quota hold is the upload panel's,
* which renders the figure it carries. A hold clears, so the notice follows the
* snapshot and goes when the hold does.
*/
export function QueueHoldNotice({ view }: { view: SnapshotDescriptor | null }) {
const holds: { key: string; text: string }[] = [];
if (view?.settingsHold != null) {
const { node, check } = view.settingsHold;
holds.push({
key: 'settings',
text: `${held(view, node)} waits on your settings: ${SETTINGS_CAUSES[check]}.`,
});
}
if (view?.binIndexHold != null) {
const { node, check } = view.binIndexHold;
holds.push({
key: 'bin-index',
text: `${held(view, node)} waits on your bin: ${BIN_INDEX_CAUSES[check]}.`,
});
}
if (holds.length === 0) return null;
const hold = view?.queueHold ?? null;
if (view == null || hold === null || hold.reason === 'quota') return null;
const text =
hold.reason === 'settings'
? `${held(view, hold.node)} waits on your settings: ${SETTINGS_CAUSES[hold.check]}.`
: `${held(view, hold.node)} waits on your bin: ${BIN_INDEX_CAUSES[hold.check]}.`;

return (
<div className="queue-hold-notice" role="status" data-testid="queue-hold-notice">
<p className="queue-hold-notice-title">
{`[!] ${holds.length === 1 ? 'a change is' : `${holds.length} changes are`} waiting`}
</p>
<p className="queue-hold-notice-title">[!] a change is waiting</p>
<ul className="queue-hold-notice-list">
{holds.map((hold) => (
<li key={hold.key}>{hold.text}</li>
))}
<li>{text}</li>
</ul>
</div>
);
Expand Down
12 changes: 9 additions & 3 deletions apps/web/src/components/file-browser/UploadPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ describe('the upload panel', () => {
await act(async () => {
engine.publish({
...view(),
blocked: { opId: 1n, node: ROOT_ID, neededBytes: 900n },
queueHold: { reason: 'quota', opId: 1n, node: ROOT_ID, neededBytes: 900n },
});
});
await waitFor(() =>
Expand All @@ -146,14 +146,20 @@ describe('the upload panel', () => {
draw(engine.client, FOLDER);
await queueOne(engine);
await act(async () => {
engine.publish({ ...view(), blocked: { opId: 1n, node: ROOT_ID, neededBytes: 900n } });
engine.publish({
...view(),
queueHold: { reason: 'quota', opId: 1n, node: ROOT_ID, neededBytes: 900n },
});
});
await waitFor(() => expect(screen.getByTestId('upload-row-hold')).toBeTruthy());

// A hold on another session's op charges the same budget but is not this
// row's business.
await act(async () => {
engine.publish({ ...view(), blocked: { opId: 99n, node: ROOT_ID, neededBytes: 900n } });
engine.publish({
...view(),
queueHold: { reason: 'quota', opId: 99n, node: ROOT_ID, neededBytes: 900n },
});
});

await waitFor(() => expect(screen.queryByTestId('upload-row-hold')).toBeNull());
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/engine/introspection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ async function digest(bytes: Uint8Array<ArrayBuffer>): Promise<string> {
function settled(view: SnapshotDescriptor): boolean {
return (
view.staleness === 'fresh' &&
view.blocked === null &&
view.queueHold?.reason !== 'quota' &&
view.children.every((child) => child.pending === 'none')
);
}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/engine/snapshotStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ describe('failure classification', () => {
describe("the drain's over-budget hold", () => {
const HELD = 7n;
const held = (opId: bigint, neededBytes: bigint) => ({
view: { ...view(), blocked: { opId, node: ROOT_ID, neededBytes } },
view: { ...view(), queueHold: { reason: 'quota' as const, opId, node: ROOT_ID, neededBytes } },
error: null,
});

Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/engine/snapshotStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@ export function isRecoverable(error: SnapshotError): boolean {
* it a snapshot field rather than an event.
*/
export function heldBytes(state: SnapshotState, opId: bigint | null): bigint | null {
const blocked = state.view?.blocked;
if (blocked == null || opId === null || blocked.opId !== opId) return null;
return blocked.neededBytes;
const hold = state.view?.queueHold;
if (hold == null || hold.reason !== 'quota' || opId === null || hold.opId !== opId) return null;
return hold.neededBytes;
}

/** Durable queue entries this session cannot read but whose bytes it is charged for. */
Expand Down
4 changes: 1 addition & 3 deletions apps/web/src/engine/testFakes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,7 @@ export function view(
})),
ancestors: [],
deadLetters: [],
blocked: null,
settingsHold: null,
binIndexHold: null,
queueHold: null,
retainedRecords: 0,
staleness,
};
Expand Down
4 changes: 1 addition & 3 deletions apps/web/src/vault/useFolderNavigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,7 @@ function folderView(overrides: Partial<SnapshotDescriptor> = {}): SnapshotDescri
children: [],
ancestors: [],
deadLetters: [],
blocked: null,
settingsHold: null,
binIndexHold: null,
queueHold: null,
retainedRecords: 0,
staleness: 'fresh',
...overrides,
Expand Down
78 changes: 23 additions & 55 deletions crates/engine/src/facade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ use crate::sync::render::{BaseSnapshot, RenderKey, RenderMemo};
use crate::sync::scope_exit_debt::SCOPE_EXIT_DEBT_PREFIX;
use cipherbox_core::hex::lower as hex_lower;

pub use crate::sync::drain::{BinIndexHold, BlockedOp, SettingsHold};
pub use crate::sync::drain::{QueueHold, QueueHoldReason};
pub use crate::sync::rebase::DeadLetterReason;
use crate::sync::record::{RecordReader, RecordSeal};
pub use crate::sync::refresh::ForcedPass;
Expand Down Expand Up @@ -710,18 +710,11 @@ pub struct DeadLetter {
pub struct SessionStatus {
/// Every retained dead-lettered op, with its reason.
pub dead_letters: Vec<DeadLetter>,
/// The over-quota hold, if the drain has one. Read rather than evented:
/// The held queue head, if the drain has one. Read rather than evented:
/// this is a state that *clears*, and a lost "resumed" would strand a host
/// on a blockage that is gone.
pub blocked: Option<BlockedOp>,
/// The settings-refused hold, if the drain has one. Read for the same
/// reason as `blocked`, and it names the rule so a host can tell the member
/// which part of their own provider config to fix.
pub settings_hold: Option<SettingsHold>,
/// The bin-index-refused hold, if the drain has one. Read for the same
/// reason as `blocked`, and it names the reason so a withheld record is a
/// cause the member can see rather than a silently stalled queue.
pub bin_index_hold: Option<BinIndexHold>,
/// on a hold that is gone. It names its reason, so a stall is a cause the
/// member can see rather than a silent queue.
pub queue_hold: Option<QueueHold>,
/// How many durable queue entries this session holds but cannot read
/// (CONTEXT.md "Retained record"). Deliberately unattributed — it says the
/// device is not empty, never whose work it holds — and it exists so an
Expand Down Expand Up @@ -751,12 +744,8 @@ pub struct SnapshotView {
pub ancestors: Vec<Breadcrumb>,
/// See [`SessionStatus::dead_letters`].
pub dead_letters: Vec<DeadLetter>,
/// See [`SessionStatus::blocked`].
pub blocked: Option<BlockedOp>,
/// See [`SessionStatus::settings_hold`].
pub settings_hold: Option<SettingsHold>,
/// See [`SessionStatus::bin_index_hold`].
pub bin_index_hold: Option<BinIndexHold>,
/// See [`SessionStatus::queue_hold`].
pub queue_hold: Option<QueueHold>,
/// See [`SessionStatus::retained_records`].
pub retained_records: usize,
/// See [`SessionStatus::staleness`].
Expand All @@ -772,9 +761,7 @@ impl fmt::Debug for SnapshotView {
.field("children", &self.children)
.field("ancestors", &self.ancestors)
.field("dead_letters", &self.dead_letters)
.field("blocked", &self.blocked)
.field("settings_hold", &self.settings_hold)
.field("bin_index_hold", &self.bin_index_hold)
.field("queue_hold", &self.queue_hold)
.field("retained_records", &self.retained_records)
.field("staleness", &self.staleness)
.finish()
Expand Down Expand Up @@ -4613,17 +4600,10 @@ pub struct Engine<T: SeamTypes> {
/// Memo of the durable queue scan every read renders through
/// ([`scan_queue`](Self::scan_queue)).
queue_scan: Rc<RefCell<QueueScanMemo>>,
/// The drain's over-quota hold, written by the drain tick and read by
/// The drain's held queue head, written by the drain tick and read by
/// [`snapshot`](Self::snapshot). In-memory: a restart re-derives it from the
/// next drain attempt's own 413 rather than trusting a stale verdict.
blocked: Rc<RefCell<Option<BlockedOp>>>,
/// The drain's settings-refused hold, on the same in-memory terms as
/// [`blocked`](Self::blocked): a restart re-derives it from the next drain
/// attempt's own verdict.
settings_hold: Rc<RefCell<Option<SettingsHold>>>,
/// The drain's bin-index-refused hold, on the same in-memory terms as
/// [`blocked`](Self::blocked).
bin_index_hold: Rc<RefCell<Option<BinIndexHold>>>,
/// next drain attempt's own verdict rather than trusting a stale one.
queue_hold: Rc<RefCell<Option<QueueHold>>>,
/// Pinned bytes a published prune still owes the registry, written by the
/// drain tick and read by [`pending_reclaim_bytes`](Self::pending_reclaim_bytes).
/// In-memory: the durable record is the retire ledger, which every pass re-reads.
Expand Down Expand Up @@ -4794,9 +4774,7 @@ impl<T: SeamTypes> Engine<T> {
focus_hinted: Cell::new(None),
dead_letters: Rc::new(RefCell::new(BTreeMap::new())),
queue_scan: Rc::new(RefCell::new(QueueScanMemo::default())),
blocked: Rc::new(RefCell::new(None)),
settings_hold: Rc::new(RefCell::new(None)),
bin_index_hold: Rc::new(RefCell::new(None)),
queue_hold: Rc::new(RefCell::new(None)),
pending_reclaim: Rc::new(Cell::new(0)),
reclaim_stalls: Rc::new(RefCell::new(Vec::new())),
bookkeeping: Rc::new(RefCell::new(BookkeepingCursors::default())),
Expand Down Expand Up @@ -5873,9 +5851,7 @@ where {
let entropy = self.entropy.clone();
let scope_write_seeds = self.scope_write_seeds.clone();
let dead_letters = self.dead_letters.clone();
let blocked = self.blocked.clone();
let settings_hold = self.settings_hold.clone();
let bin_index_hold = self.bin_index_hold.clone();
let queue_hold = self.queue_hold.clone();
let pending_reclaim = self.pending_reclaim.clone();
let reclaim_stalls = self.reclaim_stalls.clone();
let bookkeeping = self.bookkeeping.clone();
Expand Down Expand Up @@ -6507,8 +6483,7 @@ where {
entropy: &entropy,
base: &base,
held: &held,
blocked: &blocked,
settings_hold: &settings_hold,
hold: &queue_hold,
pending_reclaim: &pending_reclaim,
reclaim_stalls: &reclaim_stalls,
bookkeeping: &bookkeeping,
Expand All @@ -6520,7 +6495,6 @@ where {
bin_retention_days: owner_bin_retention_days(&tick_settings),
dead_letters: &dead_letters,
bin_index_record: &bin_index_record,
bin_index_hold: &bin_index_hold,
established_bin_index: RefCell::new(None),
observed_unlinks: &observed_unlinks,
pending_scope_exits: &pending_scope_exits,
Expand Down Expand Up @@ -9470,9 +9444,7 @@ where {
let retained_records = self.scan_queue().await?.retained;
Ok(SessionStatus {
dead_letters: self.retained_dead_letters(),
blocked: *self.blocked.borrow(),
settings_hold: *self.settings_hold.borrow(),
bin_index_hold: *self.bin_index_hold.borrow(),
queue_hold: *self.queue_hold.borrow(),
retained_records,
staleness: self.staleness_now(),
})
Expand Down Expand Up @@ -9563,9 +9535,7 @@ where {
children,
ancestors,
dead_letters: self.retained_dead_letters(),
blocked: *self.blocked.borrow(),
settings_hold: *self.settings_hold.borrow(),
bin_index_hold: *self.bin_index_hold.borrow(),
queue_hold: *self.queue_hold.borrow(),
retained_records: scan.retained,
staleness: self.staleness_now(),
})
Expand Down Expand Up @@ -11466,9 +11436,7 @@ mod tests {
name: FOLDER.to_string(),
}],
dead_letters: Vec::new(),
blocked: None,
settings_hold: None,
bin_index_hold: None,
queue_hold: None,
retained_records: 0,
staleness: Staleness::Fresh,
};
Expand Down Expand Up @@ -13597,20 +13565,20 @@ mod tests {
fn a_settings_refused_hold_reaches_both_read_surfaces() {
let (engine, _events) = started();
let root = engine.root();
let hold = SettingsHold {
let hold = QueueHold {
op_id: OpId(1),
node: root,
refusal: crate::settings::SettingsRefusal::Byo(
reason: QueueHoldReason::Settings(crate::settings::SettingsRefusal::Byo(
crate::content::ProviderError::InsecureTransport,
),
)),
};
*engine.settings_hold.borrow_mut() = Some(hold);
*engine.queue_hold.borrow_mut() = Some(hold);

assert_eq!(
block_on(engine.snapshot(root)).unwrap().settings_hold,
block_on(engine.snapshot(root)).unwrap().queue_hold,
Some(hold)
);
assert_eq!(block_on(engine.status()).unwrap().settings_hold, Some(hold));
assert_eq!(block_on(engine.status()).unwrap().queue_hold, Some(hold));
}

#[test]
Expand Down
Loading