Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add block sync time estimate #600

Merged
merged 2 commits into from
Feb 26, 2024
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
43 changes: 3 additions & 40 deletions apps/extension/src/routes/popup/home/block-sync.tsx
Original file line number Diff line number Diff line change
@@ -1,46 +1,9 @@
import { Progress } from '@penumbra-zone/ui';
import { motion } from 'framer-motion';
import { CheckIcon } from '@radix-ui/react-icons';
import { BlockSyncStatus } from '@penumbra-zone/ui';
import { useSyncProgress } from '../../../hooks/last-block-synced';

export const BlockSync = () => {
const { lastBlockHeight, lastBlockSynced } = useSyncProgress();
if (!lastBlockHeight) return <></>;

return (
<motion.div
className='flex select-none flex-col items-center gap-1 leading-[30px]'
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { duration: 0.5, ease: 'easeOut' } }}
exit={{ opacity: 0 }}
>
{lastBlockHeight ? (
// Is syncing ⏳
lastBlockHeight - lastBlockSynced > 10 ? (
<>
<div className='flex gap-2'>
<p className='font-headline text-xl font-semibold text-sand'>Syncing blocks...</p>
</div>
<Progress variant='in-progress' value={(lastBlockSynced / lastBlockHeight) * 100} />
<p className='font-mono text-sand'>
{lastBlockSynced}/{lastBlockHeight}
</p>
</>
) : (
// Is synced ✅
<>
<div className='flex gap-2'>
<div className='flex items-center'>
<p className='font-headline text-xl font-semibold text-teal'>Blocks synced</p>
<CheckIcon className='size-6 text-teal' />
</div>
</div>
<Progress variant='done' value={(lastBlockSynced / lastBlockHeight) * 100} />
<p className='font-mono text-teal'>
{lastBlockSynced}/{lastBlockHeight}
</p>
</>
)
) : null}
</motion.div>
);
return <BlockSyncStatus lastBlockSynced={lastBlockSynced} lastBlockHeight={lastBlockHeight} />;
};
99 changes: 99 additions & 0 deletions packages/ui/components/ui/block-sync-status/ewma.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { describe, expect, it, vi } from 'vitest';
import { EWMA } from './ewma';

describe('EWMA', () => {
describe('Constructor Tests', () => {
it('initializes with default parameters correctly', () => {
const ewma = new EWMA();
expect(ewma.value()).toBe(0);
});

it('initializes with custom parameters correctly', () => {
const halfLifeMs = 20000;
const initialValue = 10;
const ewma = new EWMA({ halfLifeMs, initialValue });
expect(ewma.value()).toBe(initialValue);
});
});

// Copied from original test suite: https://github.com/rsocket/ewma/blob/master/test/index.js
describe('half life', () => {
it('work properly', () => {
let NOW = 10000000;
const clock = {
now: function () {
return NOW;
},
};
let e = new EWMA({ halfLifeMs: 1, initialValue: 10, clock });
let shouldBe = 10;
expect(e.value()).toBe(shouldBe);
NOW++;

for (let i = 1; i < 100; i++, NOW++) {
shouldBe = shouldBe * 0.5 + i * 0.5;
e.insert(i);
expect(e.value()).toBe(shouldBe);
}

e.reset(0);
shouldBe = 0;
expect(e.value()).toBe(shouldBe);
NOW += 1;

for (let i = 1; i < 100; i++, NOW += 1) {
shouldBe = shouldBe * 0.5 + i * 0.5;
e.insert(i);
expect(e.value()).toBe(shouldBe);
}

e = new EWMA({ halfLifeMs: 2, clock });
shouldBe = 1;
e.insert(1);
expect(e.value()).toBe(shouldBe);
NOW += 2;

for (let i = 2; i < 100; i++, NOW += 2) {
shouldBe = shouldBe * 0.5 + i * 0.5;
e.insert(i);
expect(e.value()).toBe(shouldBe);
}
});
});

describe('Decay Calculation', () => {
it('affects EWMA value correctly over time', () => {
const clock = { now: vi.fn(() => 1000) }; // Mock time starts at 1000
const ewma = new EWMA({ halfLifeMs: 5000, initialValue: 50, clock });

clock.now.mockReturnValue(2000); // Advance time by 1000ms
ewma.insert(100); // Insert a higher value to see decay effect
expect(ewma.value()).toBeLessThan(75); // Expect some decay towards the new value

clock.now.mockReturnValue(7000); // Advance time significantly to test decay
expect(ewma.value()).toBeLessThan(100); // Ensure value continues to decay towards most recent insert
});
});

describe('Custom Clock Functionality', () => {
it('uses custom clock correctly', () => {
let mockTime = 1000;
const clock = { now: () => mockTime };
const ewma = new EWMA({ halfLifeMs: 10000, clock });
ewma.insert(10);
mockTime += 5000; // Simulate passing of time
ewma.insert(20);
expect(ewma.value()).toBeCloseTo(6.33, 2);
});
});

describe('Reset Functionality', () => {
it('correctly restarts calculation with various values', () => {
const ewma = new EWMA();
ewma.reset(100);
expect(ewma.value()).toBe(100);
ewma.reset(-50);
expect(ewma.value()).toBe(-50);
});
});
});
84 changes: 84 additions & 0 deletions packages/ui/components/ui/block-sync-status/ewma.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* Inspired from: https://github.com/rsocket/ewma/blob/master/index.js
* However, the reason why we have our own code for this is because this library
* was missing types and can only run in nodejs, not a browser environment.
*
* Ewma (Exponential Weighted Moving Average) is used to calculate a smoothed average of a series
* of numbers, where more recent values have a greater weight than older values. This class is
* particularly useful for smoothing out time-series data or calculating an average that needs to
* adapt quickly to changes. The half-life parameter controls how quickly the weights decrease for
* older values.
*/

interface EWMAProps {
halfLifeMs?: number;
initialValue?: number | undefined;
clock?: Clock;
}

interface Clock {
now: () => number;
}

export class EWMA {
grod220 marked this conversation as resolved.
Show resolved Hide resolved
private readonly decay: number;
private ewma: number;
private lastUpdate: number;
private clock: Clock;

/**
* Initializes a new instance of the Ewma class.
*
* @param halfLifeMs The half-life in milliseconds, indicating how quickly the influence of a value
* decreases to half its original weight. This parameter directly impacts the speed of convergence
* towards recent values, with lower values making the average more sensitive to recent changes.
* @param initialValue The initial value of the EWMA, defaulted to 0 if not provided.
* @param clock A function with a now() method. Defaults to Date. Helpful with testing.
*/

constructor({ halfLifeMs = 10_000, initialValue = undefined, clock = Date }: EWMAProps = {}) {
this.decay = halfLifeMs;
this.ewma = initialValue ?? 0;
this.clock = clock;
this.lastUpdate = initialValue ? clock.now() : 0;
}

/**
* Inserts a new value into the EWMA calculation, updating the average based on the elapsed time
* since the last update and the new value's weight.
*/
insert(x: number): void {
const now = this.clock.now();
const elapsed = now - this.lastUpdate;
this.lastUpdate = now;

// This seemingly magic equation is derived from the fact that we are
// defining a half life for each value. A half life is the amount of time
// that it takes for a value V to decay to .5V or V/2. Elapsed is the time
// delta between this value being reported and the previous value being
// reported. Given the half life, and the amount of time since the last
// reported value, this equation determines how much the new value should
// be represented in the ewma.
// For a detailed proof read:
// A Framework for the Analysis of Unevenly Spaced Time Series Data
// Eckner, 2014
const weight = Math.pow(2, -elapsed / this.decay);
this.ewma = weight * this.ewma + (1 - weight) * x;
}

/**
* Resets the EWMA to a specified new value, effectively restarting the calculation as if this
* new value was the first and only value provided.
*/
reset(x: number): void {
this.lastUpdate = this.clock.now();
this.ewma = x;
}

/**
* Returns the current value of the EWMA.
*/
value(): number {
return this.ewma;
}
}
72 changes: 72 additions & 0 deletions packages/ui/components/ui/block-sync-status/index.tsx
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Largely copied over from the old code

Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { motion } from 'framer-motion';
import { CheckIcon } from '@radix-ui/react-icons';
import { Progress } from '../progress';
import { useSyncProgress } from './sync-progress-hook';
import { cn } from '../../../lib/utils';

interface BlockSyncProps {
lastBlockHeight: number;
lastBlockSynced: number;
}

export const BlockSyncStatus = ({ lastBlockHeight, lastBlockSynced }: BlockSyncProps) => {
if (!lastBlockHeight) return <div></div>;

const isSyncing = lastBlockHeight - lastBlockSynced > 10;

return (
<motion.div
className='flex select-none flex-col items-center gap-1 leading-[30px]'
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { duration: 0.5, ease: 'easeOut' } }}
exit={{ opacity: 0 }}
>
{isSyncing ? (
<SyncingState lastBlockSynced={lastBlockSynced} lastBlockHeight={lastBlockHeight} />
) : (
<FullySyncedState lastBlockSynced={lastBlockSynced} lastBlockHeight={lastBlockHeight} />
)}
</motion.div>
);
};

const SyncingState = ({ lastBlockHeight, lastBlockSynced }: BlockSyncProps) => {
const { formattedTimeRemaining, confident } = useSyncProgress(lastBlockSynced, lastBlockHeight);

return (
<>
<div className='flex gap-2'>
<p className='font-headline text-xl font-semibold text-sand'>Syncing blocks...</p>
</div>
<Progress variant='in-progress' value={(lastBlockSynced / lastBlockHeight) * 100} />
<p className='font-mono text-sand'>
{lastBlockSynced}/{lastBlockHeight}
</p>
<p
className={cn(
'-mt-1 font-mono text-sm text-sand transition-all duration-300',
confident ? 'opacity-100' : 'opacity-0',
)}
>
{formattedTimeRemaining}
</p>
</>
);
};

const FullySyncedState = ({ lastBlockHeight, lastBlockSynced }: BlockSyncProps) => {
return (
<>
<div className='flex gap-2'>
<div className='flex items-center'>
<p className='font-headline text-xl font-semibold text-teal'>Blocks synced</p>
<CheckIcon className='size-6 text-teal' />
</div>
</div>
<Progress variant='done' value={(lastBlockSynced / lastBlockHeight) * 100} />
<p className='font-mono text-teal'>
{lastBlockSynced}/{lastBlockHeight}
</p>
</>
);
};
54 changes: 54 additions & 0 deletions packages/ui/components/ui/block-sync-status/sync-progress-hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { useEffect, useRef, useState } from 'react';
import { EWMA } from './ewma';
import humanizeDuration from 'humanize-duration';

/**
* Custom hook to calculate synchronization speed and estimate the time remaining
* for a synchronization process to complete, using the Exponential Weighted Moving Average (EWMA)
* to smooth the speed calculation.
*
* @returns An object containing:
* - formattedTimeRemaining: Human-readable string representation (e.g., "13 min, 49 sec").
* - confident: A boolean flag indicating whether the speed calculation is considered reliable.
*/
export const useSyncProgress = (
lastBlockSynced: number,
lastBlockHeight: number,
syncUpdatesThreshold = 10, // The number of synchronization updates required before the speed calculation is considered reliable
) => {
const ewmaSpeedRef = useRef(new EWMA());

const [speed, setSpeed] = useState<number>(0);
const lastSyncedRef = useRef<number>(lastBlockSynced);
const lastUpdateTimeRef = useRef<number>(Date.now());
const [confident, setConfident] = useState<boolean>(false); // Tracks confidence in the speed calculation
const [syncUpdates, setSyncUpdates] = useState<number>(0); // Tracks the number of synchronization updates

useEffect(() => {
const now = Date.now();
const timeElapsedMs = now - lastUpdateTimeRef.current;
const blocksSynced = lastBlockSynced - lastSyncedRef.current;

if (timeElapsedMs > 0 && blocksSynced >= 0) {
const instantSpeed = (blocksSynced / timeElapsedMs) * 1000; // Calculate speed in blocks per second
ewmaSpeedRef.current.insert(instantSpeed);
setSpeed(ewmaSpeedRef.current.value());
setSyncUpdates(prev => prev + 1); // Increment the number of sync updates
}

lastSyncedRef.current = lastBlockSynced;
lastUpdateTimeRef.current = now;

// Update confident flag based on the number of sync updates
if (syncUpdates >= syncUpdatesThreshold && !confident) {
setConfident(true);
}
}, [lastBlockSynced, syncUpdates, syncUpdatesThreshold, confident]);

const blocksRemaining = lastBlockHeight - lastBlockSynced;
const timeRemaining = speed > 0 ? blocksRemaining / speed : Infinity;
const formattedTimeRemaining =
timeRemaining === Infinity ? '' : humanizeDuration(timeRemaining * 1000, { round: true });

return { formattedTimeRemaining, confident };
};
1 change: 1 addition & 0 deletions packages/ui/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './components/ui/back-icon';
export * from './components/ui/block-sync-status';
export * from './components/ui/button';
export * from './components/ui/card';
export * from './components/ui/copy-to-clipboard';
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"clsx": "^2.1.0",
"djb2a": "^2.0.0",
"framer-motion": "^11.0.5",
"humanize-duration": "^3.31.0",
"lucide-react": "^0.331.0",
"react-dom": "^18.2.0",
"react-json-view": "^1.21.3",
Expand All @@ -47,6 +48,7 @@
"@storybook/react": "^7.6.16",
"@storybook/react-vite": "^7.6.17",
"@testing-library/react": "^14.2.1",
"@types/humanize-duration": "^3.27.4",
"@types/node": "^20.11.19",
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
Expand Down
Loading
Loading