Skip to content

Commit 58afc9d

Browse files
committed
refactor: use ordered publisher list
This change brings down the average cost of a price update without aggregation from 5k to 2k because binary search requires much less lookups. There are mechanims implemented in the code to make sure that upon the upgrade we sort the array without adding overhead to the happy path (working binary search). Also add_publisher now does an insertion sort to keep the list sorted and del_publisher won't change the order of the list.
1 parent ceb4a32 commit 58afc9d

File tree

3 files changed

+223
-14
lines changed

3 files changed

+223
-14
lines changed

program/rust/src/processor/add_publisher.rs

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,7 @@ pub fn add_publisher(
4141
let cmd_args = load::<AddPublisherArgs>(instruction_data)?;
4242

4343
pyth_assert(
44-
instruction_data.len() == size_of::<AddPublisherArgs>()
45-
&& cmd_args.publisher != Pubkey::default(),
44+
instruction_data.len() == size_of::<AddPublisherArgs>(),
4645
ProgramError::InvalidArgument,
4746
)?;
4847

@@ -67,19 +66,38 @@ pub fn add_publisher(
6766
return Err(ProgramError::InvalidArgument);
6867
}
6968

69+
// Use the call with the default pubkey (000..) as a trigger to sort the publishers as a
70+
// migration step from unsorted list to sorted list.
71+
if cmd_args.publisher == Pubkey::default() {
72+
// Using sort_by_cached_key because it sorts the keys first
73+
// and performs swaps on the values in the end which results
74+
// in linear swaps of comp_ values.
75+
price_data.comp_.sort_by_cached_key(|x| x.pub_);
76+
return Ok(());
77+
}
78+
7079
for i in 0..(try_convert::<u32, usize>(price_data.num_)?) {
7180
if cmd_args.publisher == price_data.comp_[i].pub_ {
7281
return Err(ProgramError::InvalidArgument);
7382
}
7483
}
7584

76-
let current_index: usize = try_convert(price_data.num_)?;
85+
let mut current_index: usize = try_convert(price_data.num_)?;
7786
sol_memset(
7887
bytes_of_mut(&mut price_data.comp_[current_index]),
7988
0,
8089
size_of::<PriceComponent>(),
8190
);
8291
price_data.comp_[current_index].pub_ = cmd_args.publisher;
92+
93+
// Shift the element back to keep the publishers components sorted.
94+
while current_index > 0
95+
&& price_data.comp_[current_index].pub_ < price_data.comp_[current_index - 1].pub_
96+
{
97+
price_data.comp_.swap(current_index, current_index - 1);
98+
current_index -= 1;
99+
}
100+
83101
price_data.num_ += 1;
84102
price_data.header.size = try_convert::<_, u32>(PriceAccount::INITIAL_SIZE)?;
85103
Ok(())

program/rust/src/processor/upd_price.rs

Lines changed: 121 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use {
22
crate::{
33
accounts::{
44
PriceAccount,
5+
PriceComponent,
56
PriceInfo,
67
PythOracleSerialize,
78
UPD_PRICE_WRITE_SEED,
@@ -127,7 +128,7 @@ pub fn upd_price(
127128
// Check clock
128129
let clock = Clock::from_account_info(clock_account)?;
129130

130-
let mut publisher_index: usize = 0;
131+
let publisher_index: usize;
131132
let latest_aggregate_price: PriceInfo;
132133

133134
// The price_data borrow happens in a scope because it must be
@@ -137,17 +138,15 @@ pub fn upd_price(
137138
// Verify that symbol account is initialized
138139
let price_data = load_checked::<PriceAccount>(price_account, cmd_args.header.version)?;
139140

140-
// Verify that publisher is authorized
141-
while publisher_index < try_convert::<u32, usize>(price_data.num_)? {
142-
if price_data.comp_[publisher_index].pub_ == *funding_account.key {
143-
break;
141+
publisher_index = match find_publisher_index(
142+
&price_data.comp_[..try_convert::<u32, usize>(price_data.num_)?],
143+
funding_account.key,
144+
) {
145+
Some(index) => index,
146+
None => {
147+
return Err(OracleError::PermissionViolation.into());
144148
}
145-
publisher_index += 1;
146-
}
147-
pyth_assert(
148-
publisher_index < try_convert::<u32, usize>(price_data.num_)?,
149-
OracleError::PermissionViolation.into(),
150-
)?;
149+
};
151150

152151
latest_aggregate_price = price_data.agg_;
153152
let latest_publisher_price = price_data.comp_[publisher_index].latest_;
@@ -281,6 +280,62 @@ pub fn upd_price(
281280
Ok(())
282281
}
283282

283+
/// Find the index of the publisher in the list of components.
284+
///
285+
/// This method first tries to binary search for the publisher's key in the list of components
286+
/// to get the result faster if the list is sorted. If the list is not sorted, it falls back to
287+
/// a linear search.
288+
#[inline]
289+
fn find_publisher_index(comps: &[PriceComponent], key: &Pubkey) -> Option<usize> {
290+
// Verify that publisher is authorized by initially binary searching
291+
// for the publisher's component in the price account. The binary
292+
// search might not work if the publisher list is not sorted; therefore
293+
// we fall back to a linear search.
294+
let mut binary_search_result = None;
295+
296+
{
297+
// Binary search to find the publisher key. Rust std binary search is not used because
298+
// they guarantee valid outcome only if the array is sorted whereas we want to rely on
299+
// a Equal match if it is a result on an unsorted array. Currently the rust
300+
// implementation behaves the same but we do not want to rely on api internals.
301+
let mut left = 0;
302+
let mut right = comps.len();
303+
while left < right {
304+
let mid = left + (right - left) / 2;
305+
match comps[mid].pub_.cmp(key) {
306+
std::cmp::Ordering::Less => {
307+
left = mid + 1;
308+
}
309+
std::cmp::Ordering::Greater => {
310+
right = mid;
311+
}
312+
std::cmp::Ordering::Equal => {
313+
binary_search_result = Some(mid);
314+
break;
315+
}
316+
}
317+
}
318+
}
319+
320+
match binary_search_result {
321+
Some(index) => Some(index),
322+
None => {
323+
let mut index = 0;
324+
while index < comps.len() {
325+
if comps[index].pub_ == *key {
326+
break;
327+
}
328+
index += 1;
329+
}
330+
if index == comps.len() {
331+
None
332+
} else {
333+
Some(index)
334+
}
335+
}
336+
}
337+
}
338+
284339
#[allow(dead_code)]
285340
// Wrapper struct for the accounts required to add data to the accumulator program.
286341
struct MessageBufferAccounts<'a, 'b: 'a> {
@@ -289,3 +344,58 @@ struct MessageBufferAccounts<'a, 'b: 'a> {
289344
oracle_auth_pda: &'a AccountInfo<'b>,
290345
message_buffer_data: &'a AccountInfo<'b>,
291346
}
347+
348+
#[cfg(test)]
349+
mod test {
350+
use {
351+
super::*,
352+
crate::accounts::PriceComponent,
353+
solana_program::pubkey::Pubkey,
354+
};
355+
356+
fn dummy_price_component(pub_: Pubkey) -> PriceComponent {
357+
let dummy_price_info = PriceInfo {
358+
price_: 0,
359+
conf_: 0,
360+
status_: 0,
361+
pub_slot_: 0,
362+
corp_act_status_: 0,
363+
};
364+
365+
PriceComponent {
366+
latest_: dummy_price_info,
367+
agg_: dummy_price_info,
368+
pub_,
369+
}
370+
}
371+
372+
/// Test the find_publisher_index method works with an unordered list of components.
373+
#[test]
374+
pub fn test_find_publisher_index_unordered_comp() {
375+
let comps = (0..64)
376+
.map(|_| dummy_price_component(Pubkey::new_unique()))
377+
.collect::<Vec<_>>();
378+
379+
comps.iter().enumerate().for_each(|(idx, comp)| {
380+
assert_eq!(find_publisher_index(&comps, &comp.pub_), Some(idx));
381+
});
382+
383+
assert_eq!(find_publisher_index(&comps, &Pubkey::new_unique()), None);
384+
}
385+
386+
/// Test the find_publisher_index method works with a sorted list of components.
387+
#[test]
388+
pub fn test_find_publisher_index_ordered_comp() {
389+
let mut comps = (0..64)
390+
.map(|_| dummy_price_component(Pubkey::new_unique()))
391+
.collect::<Vec<_>>();
392+
393+
comps.sort_by_key(|comp| comp.pub_);
394+
395+
comps.iter().enumerate().for_each(|(idx, comp)| {
396+
assert_eq!(find_publisher_index(&comps, &comp.pub_), Some(idx));
397+
});
398+
399+
assert_eq!(find_publisher_index(&comps, &Pubkey::new_unique()), None);
400+
}
401+
}

program/rust/src/tests/test_upd_price_v2.rs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,87 @@ fn test_upd_price_v2() -> Result<(), Box<dyn std::error::Error>> {
444444
Ok(())
445445
}
446446

447+
#[test]
448+
fn test_upd_works_with_unordered_publisher_set() -> Result<(), Box<dyn std::error::Error>> {
449+
let mut instruction_data = [0u8; size_of::<UpdPriceArgs>()];
450+
451+
let program_id = Pubkey::new_unique();
452+
453+
let mut price_setup = AccountSetup::new::<PriceAccount>(&program_id);
454+
let mut price_account = price_setup.as_account_info();
455+
price_account.is_signer = false;
456+
PriceAccount::initialize(&price_account, PC_VERSION).unwrap();
457+
458+
let mut publishers_setup: Vec<_> = (0..20).map(|_| AccountSetup::new_funding()).collect();
459+
let mut publishers: Vec<_> = publishers_setup
460+
.iter_mut()
461+
.map(|s| s.as_account_info())
462+
.collect();
463+
464+
publishers.sort_by_key(|x| x.key);
465+
466+
{
467+
let mut price_data = load_checked::<PriceAccount>(&price_account, PC_VERSION).unwrap();
468+
price_data.num_ = 20;
469+
// Store the publishers in reverse order
470+
publishers
471+
.iter()
472+
.rev()
473+
.enumerate()
474+
.for_each(|(i, account)| {
475+
price_data.comp_[i].pub_ = *account.key;
476+
});
477+
}
478+
479+
let mut clock_setup = AccountSetup::new_clock();
480+
let mut clock_account = clock_setup.as_account_info();
481+
clock_account.is_signer = false;
482+
clock_account.is_writable = false;
483+
484+
update_clock_slot(&mut clock_account, 1);
485+
486+
for (i, publisher) in publishers.iter().enumerate() {
487+
populate_instruction(&mut instruction_data, (i + 100) as i64, 10, 1);
488+
process_instruction(
489+
&program_id,
490+
&[
491+
publisher.clone(),
492+
price_account.clone(),
493+
clock_account.clone(),
494+
],
495+
&instruction_data,
496+
)?;
497+
}
498+
499+
update_clock_slot(&mut clock_account, 2);
500+
501+
// Trigger the aggregate calculation by sending another price
502+
// update
503+
populate_instruction(&mut instruction_data, 100, 10, 2);
504+
process_instruction(
505+
&program_id,
506+
&[
507+
publishers[0].clone(),
508+
price_account.clone(),
509+
clock_account.clone(),
510+
],
511+
&instruction_data,
512+
)?;
513+
514+
515+
let price_data = load_checked::<PriceAccount>(&price_account, PC_VERSION).unwrap();
516+
517+
// The result will be the following only if all the
518+
// publishers prices are included in the aggregate.
519+
assert_eq!(price_data.valid_slot_, 1);
520+
assert_eq!(price_data.agg_.pub_slot_, 2);
521+
assert_eq!(price_data.agg_.price_, 109);
522+
assert_eq!(price_data.agg_.conf_, 8);
523+
assert_eq!(price_data.agg_.status_, PC_STATUS_TRADING);
524+
525+
Ok(())
526+
}
527+
447528
// Create an upd_price instruction with the provided parameters
448529
fn populate_instruction(instruction_data: &mut [u8], price: i64, conf: u64, pub_slot: u64) {
449530
let mut cmd = load_mut::<UpdPriceArgs>(instruction_data).unwrap();

0 commit comments

Comments
 (0)