Skip to content

feat(argus): internal interfaces and actor model #2659

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

Closed
wants to merge 9 commits into from

Conversation

tejasbadadare
Copy link
Contributor

@tejasbadadare tejasbadadare commented May 7, 2025

Summary

Skeleton for Argus. Defines internal interfaces and sets up the actor model using ractor framework.

Note to reviewer: the interesting stuff is in actors and adapters, see the types.rs in each for the big picture.

Actors

  • In a nutshell, actors have independent responsibilities and can communicate via message-passing. Each actor specifies the messages it can receive and reply to.
    • For example, the PythPriceListener actor is responsible for tracking off-chain prices. It can handle a GetLatestPrice(feed_ids) message, which it serves from its internal store of prices that are being polled/streamed from Hermes in the background. It can also handle a UpdateFeedIdSet(feed_ids) message, which can be used to adjust the feeds being tracked.
    • Similar actors exist for the other parallel tasks that happen in the keeper: keeping active subscriptions up to date, tracking on-chain prices, etc. The orchestration is handled by the Controller actor.
  • See actors/types.rs for the message definitions, these enums describe the actors' interfaces.
  • The actors are currently spawned in keeper.rs, but this will eventually be done using ractor's Factory concept, which provides durability and lifecycle mgmt for actors.

Adapters

  • Interactions with external services like Hermes and the blockchain are modeled by the traits in adapters/types.rs. These traits are used by the actors, and are implemented by hermes.rs, contract.rs, etc.
    • For example, the SubscriptionListener actor expects an implementor of ReadChainSubscriptions. This trait specifies get_active_subscriptions() and subscribe_to_subscription_events(), which are impl'd by PythPulse<M> in contract.rs.

How has this been tested?

Not tested yet. Next up is writing the Controller's main update loop, wiring it up to mock implementations, and ensuring the actor communication works. Then, we will implement individual actor logic, and eventually wire it up to the blockchain instead of mock adapters.

Copy link

vercel bot commented May 7, 2025

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
api-reference ✅ Ready (Inspect) Visit Preview 💬 Add feedback May 7, 2025 9:34pm
component-library ✅ Ready (Inspect) Visit Preview 💬 Add feedback May 7, 2025 9:34pm
entropy-debugger ✅ Ready (Inspect) Visit Preview 💬 Add feedback May 7, 2025 9:34pm
insights ✅ Ready (Inspect) Visit Preview 💬 Add feedback May 7, 2025 9:34pm
proposals ✅ Ready (Inspect) Visit Preview 💬 Add feedback May 7, 2025 9:34pm
staking ✅ Ready (Inspect) Visit Preview 💬 Add feedback May 7, 2025 9:34pm

Copy link
Collaborator

@ali-behjati ali-behjati left a comment

Choose a reason for hiding this comment

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

I like the design. I recommend adding module docs to each of your actors now that you’re establishing the structure and design of the service.

#[allow(dead_code)]
pub struct ChainPriceListenerState {
chain_id: String,
contract: Arc<dyn GetChainPrices + Send + Sync>,
Copy link
Collaborator

Choose a reason for hiding this comment

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

maybe we make a type alias for this?

Copy link
Contributor

Choose a reason for hiding this comment

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

(and for ChainId also)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We've got one actually, let me use that here


#[derive(Debug)]
pub enum PythPriceListenerMessage {
GetLatestPrice(PriceId, RpcReplyPort<Option<Price>>),
Copy link
Collaborator

Choose a reason for hiding this comment

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

I would advice against using reply between actors because on the sender side it results in an extra async blocker (other than the main message queue) and it defeats the concurrency benefits of the actors (not getting into livelocks and deadlocks). This is mostly useful if an external observer outside the actors wants to get a reply from the system.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I kind of expected to have a good construct for a reply-based mechanism using cast but couldn't find it. probably you need to pass a reference to the requesting actor (and track the request/...)

Copy link
Contributor

Choose a reason for hiding this comment

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

I think we had a bunch of issues with two-way communication between actors in pyth-agent. I don't know if we had a good solution to that though. maybe you simply use shared memory with a lock for these kinds of read requests?

Copy link
Collaborator

Choose a reason for hiding this comment

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

yeah i think we should either do full actor model or full shared memory model (like hermes/agent/..)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah interesting. Do either of you remember why 2-way communication didn't work so well in pyth-agent?

Re @ali-bahjati about the reply-based mechanisms in ractor using cast:

  • cast supports fully async replies -- you can pass an ActorRef in the message, the receiver can use it to send a reply message to the sender at a later time.
  • call supports awaitable replies -- the receiver gets an RpcReplyPort that it can use to reply while handling the incoming message. the sender can await this reply. the receiver gets messages serially, so it doesn't need to track requests.

I find call semantics more ergonomic for certain things like fetching the latest price in the Controller, not sure how we would get it to work with cast, since we'd need the latest price in the middle of the Controller's update loop. Lmk if you guys have ideas here.

So the tradeoff of using call with GetLatestPrice operations would be that we're slightly compromising the fully async nature of the actors (but prices would still get polled in an unblocked background thread), but we still get the isolation benefits. I'm inclined to stick with call here for simplicity, but I hear you guys saying this might have been a regret in the past :) Let me know what you think.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Did some thinking, and yeah we are indeed in between paradigms here. By introducing synchronous dependencies between actors (using call,) we trade away the high concurrency of the actor model for simplicity. From what I gather, this became an issue in pyth-agent as the code evolved and added implicit dependencies between actors resulting in dead/livelocks that were hard to diagnose and fix. In Argus, our actor design is a DAG, so it felt okay to introduce these sync dependencies, but ultimately I settled on going the full shared memory model route here: #2682

Copy link
Contributor

@jayantk jayantk left a comment

Choose a reason for hiding this comment

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

seems generally reasonable to me. I do think that revisiting the pyth-agent experience with actors is worthwhile, as that system got pretty janky over time. Probably some lessons there about how (not) to use Rust actors.

chain_id: String,
contract: Arc<dyn GetChainPrices + Send + Sync>,
feed_ids: HashSet<PriceId>,
latest_prices: Arc<RwLock<HashMap<PriceId, Price>>>,
Copy link
Contributor

Choose a reason for hiding this comment

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

consider using DashMap as a thread-safe hashmap (instead of RwLock<HashMap<...>>)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oo very cool, thanks for sharing

backoff_policy: ExponentialBackoff,
}

pub struct PricePusher;
Copy link
Contributor

Choose a reason for hiding this comment

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

I think you're going to get in trouble with this Actor for two reasons: (1) the controller is running on a separate frequency, and so its checks for when to push an update are not synchronized with the actions of PricePusher. That is, the PricePusher can be trying to push an update (possibly stuck in backoff) and meanwhile the Controller is repeatedly detecting that the update needs to occur and is sending more PushPriceUpdate messages. (2) this approach serializes the updates for all subscriptions on a chain, so if we can't update subscription 1 for some reason, that blocks subscription 2 as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah good point. We can definitely handle this better in PricePusher. I'm thinking we can spawn a task to attempt the update with backoff and track the handles. If a newer push for a subscription comes in while the last one is stuck in backoff, we can cancel the outstanding task and spawn a new one. This also means the actor's handle function doesn't block, and we can multiple push attempts for different subscriptions active at a time.


#[derive(Debug)]
pub enum PythPriceListenerMessage {
GetLatestPrice(PriceId, RpcReplyPort<Option<Price>>),
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we had a bunch of issues with two-way communication between actors in pyth-agent. I don't know if we had a good solution to that though. maybe you simply use shared memory with a lock for these kinds of read requests?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants