Skip to main content

linera_service/
node_service.rs

1// Copyright (c) Zefchain Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::{
5    borrow::Cow,
6    future::IntoFuture,
7    iter,
8    net::SocketAddr,
9    num::NonZeroU16,
10    sync::{Arc, Mutex as StdMutex},
11};
12
13use async_graphql::{
14    futures_util::Stream,
15    registry::{MetaType, MetaTypeId, Registry},
16    resolver_utils::ContainerType,
17    EmptyMutation, Error, MergedObject, OutputType, Positioned, Request, Response, ScalarType,
18    Schema, SimpleObject, Subscription,
19};
20use async_graphql_axum::{GraphQLRequest, GraphQLResponse, GraphQLSubscription};
21use axum::{extract::Path, http::StatusCode, response, response::IntoResponse, Extension, Router};
22use futures::{lock::Mutex, Future, FutureExt as _, StreamExt as _, TryStreamExt as _};
23use linera_base::{
24    crypto::{CryptoError, CryptoHash},
25    data_types::{
26        Amount, ApplicationDescription, ApplicationPermissions, BlockHeight, Bytecode, Epoch,
27        TimeDelta,
28    },
29    identifiers::{
30        Account, AccountOwner, ApplicationId, ChainId, IndexAndEvent, ModuleId, StreamId,
31    },
32    ownership::{ChainOwnership, TimeoutConfig},
33    vm::VmRuntime,
34    BcsHexParseError,
35};
36use linera_chain::{
37    types::{ConfirmedBlock, GenericCertificate},
38    ChainStateView,
39};
40use linera_client::chain_listener::{
41    ChainListener, ChainListenerConfig, ClientContext, ListenerCommand,
42};
43use linera_core::{
44    client::{chain_client, ChainClient},
45    data_types::ClientOutcome,
46    wallet::Wallet as _,
47    worker::{ChainStateViewReadGuard, Notification, Reason},
48};
49use linera_execution::{
50    committee::Committee, system::AdminOperation, Operation, Query, QueryOutcome, QueryResponse,
51    SystemOperation,
52};
53#[cfg(with_metrics)]
54use linera_metrics::monitoring_server;
55use linera_sdk::linera_base_types::BlobContent;
56use linera_storage::Storage;
57use lru::LruCache;
58use serde::{Deserialize, Serialize};
59use serde_json::json;
60use tokio::sync::mpsc::UnboundedReceiver;
61use tokio_util::sync::CancellationToken;
62use tower_http::cors::CorsLayer;
63use tracing::{debug, error, info, instrument, trace};
64
65use crate::util;
66
67/// A pre-serialized JSON string that implements [`OutputType`] as the `JSON` scalar.
68///
69/// When the `raw_value` feature of `async-graphql` is enabled, the string is
70/// emitted directly into the GraphQL response without any parsing or
71/// intermediate tree construction.
72#[derive(Clone)]
73struct RawJson(String);
74
75impl OutputType for RawJson {
76    fn type_name() -> Cow<'static, str> {
77        Cow::Borrowed("JSON")
78    }
79
80    fn create_type_info(registry: &mut Registry) -> String {
81        registry.create_output_type::<Self, _>(MetaTypeId::Scalar, |_| MetaType::Scalar {
82            name: "JSON".to_string(),
83            description: Some("A scalar that can represent any JSON value.".to_string()),
84            is_valid: None,
85            visible: None,
86            inaccessible: false,
87            tags: Default::default(),
88            specified_by_url: None,
89            directive_invocations: Default::default(),
90            requires_scopes: Default::default(),
91        })
92    }
93
94    async fn resolve(
95        &self,
96        _ctx: &async_graphql::ContextSelectionSet<'_>,
97        _field: &Positioned<async_graphql::parser::types::Field>,
98    ) -> async_graphql::ServerResult<async_graphql::Value> {
99        // Wrap the raw JSON string with the magic token that async-graphql's
100        // ConstValue serializer recognises (with feature `raw_value`).
101        // When the response is serialised to JSON the raw string is emitted
102        // verbatim, avoiding any parsing or tree conversion.
103        //
104        Ok(async_graphql::Value::Object(
105            std::iter::once((
106                async_graphql::Name::new(async_graphql_value::RAW_VALUE_TOKEN),
107                async_graphql::Value::String(self.0.clone()),
108            ))
109            .collect(),
110        ))
111    }
112}
113
114/// The set of chains tracked by the wallet.
115#[derive(SimpleObject, Serialize, Deserialize, Clone)]
116pub struct Chains {
117    /// The IDs of the tracked chains.
118    pub list: Vec<ChainId>,
119    /// The default chain of the wallet, if one is set.
120    pub default: Option<ChainId>,
121}
122
123/// Our root GraphQL query type.
124pub struct QueryRoot<C> {
125    context: Arc<Mutex<C>>,
126    port: NonZeroU16,
127    default_chain: Option<ChainId>,
128}
129
130/// Our root GraphQL subscription type.
131pub struct SubscriptionRoot<C> {
132    context: Arc<Mutex<C>>,
133    query_subscriptions: Option<Arc<crate::query_subscription::QuerySubscriptionManager>>,
134    cancellation_token: CancellationToken,
135}
136
137/// Our root GraphQL mutation type.
138pub struct MutationRoot<C> {
139    context: Arc<Mutex<C>>,
140}
141
142#[derive(Debug, thiserror::Error)]
143enum NodeServiceError {
144    #[error(transparent)]
145    ChainClient(#[from] chain_client::Error),
146    #[error(transparent)]
147    BcsHex(#[from] BcsHexParseError),
148    #[error(transparent)]
149    Json(#[from] serde_json::Error),
150    #[error("malformed chain ID: {0}")]
151    InvalidChainId(CryptoError),
152    #[error(transparent)]
153    Client(#[from] linera_client::Error),
154    #[error("scheduling operations from queries is disabled in read-only mode")]
155    ReadOnlyModeOperationsNotAllowed,
156}
157
158impl IntoResponse for NodeServiceError {
159    fn into_response(self) -> response::Response {
160        let status = match self {
161            NodeServiceError::InvalidChainId(_) | NodeServiceError::BcsHex(_) => {
162                StatusCode::BAD_REQUEST
163            }
164            NodeServiceError::ReadOnlyModeOperationsNotAllowed => StatusCode::FORBIDDEN,
165            _ => StatusCode::INTERNAL_SERVER_ERROR,
166        };
167        let body = json!({"error": self.to_string()}).to_string();
168        (status, body).into_response()
169    }
170}
171
172#[Subscription]
173impl<C> SubscriptionRoot<C>
174where
175    C: ClientContext + 'static,
176{
177    /// Subscribes to notifications from the specified chain.
178    async fn notifications(
179        &self,
180        chain_id: ChainId,
181    ) -> Result<impl Stream<Item = Notification>, Error> {
182        let client = self
183            .context
184            .lock()
185            .await
186            .make_chain_client(chain_id)
187            .await?;
188        Ok(client.subscribe()?)
189    }
190
191    /// Subscribes to the result of a pre-registered GraphQL query.
192    /// Re-executes the query on every new block and pushes changed results.
193    async fn query_result(
194        &self,
195        #[graphql(desc = "Name of the registered subscription query.")] name: String,
196        #[graphql(desc = "The chain to watch.")] chain_id: ChainId,
197        #[graphql(desc = "The application to query.")] application_id: ApplicationId,
198    ) -> Result<impl Stream<Item = RawJson>, Error> {
199        let manager = self
200            .query_subscriptions
201            .as_ref()
202            .ok_or_else(|| Error::new("no subscription queries registered"))?;
203
204        let key = crate::query_subscription::SubscriptionKey {
205            name,
206            chain_id,
207            application_id,
208        };
209
210        let receiver = manager
211            .subscribe(
212                &key,
213                Arc::clone(&self.context),
214                self.cancellation_token.clone(),
215            )
216            .map_err(|e| Error::new(e.to_string()))?;
217
218        // `sender.subscribe()` marks the current value as "already seen", so
219        // `WatchStream` would skip it and wait for the next change.  Grab the
220        // current snapshot first and prepend it to the stream so that every new
221        // subscriber gets the latest cached result immediately.
222        let current = receiver.borrow().clone();
223        let changes = tokio_stream::wrappers::WatchStream::from_changes(receiver)
224            .filter_map(|value| async move { value });
225        Ok(futures::stream::iter(current).chain(changes).map(RawJson))
226    }
227}
228
229impl<C> MutationRoot<C>
230where
231    C: ClientContext,
232{
233    async fn execute_system_operation(
234        &self,
235        system_operation: SystemOperation,
236        chain_id: ChainId,
237    ) -> Result<CryptoHash, Error> {
238        let certificate = self
239            .apply_client_command(&chain_id, move |client| {
240                let operation = Operation::system(system_operation.clone());
241                async move {
242                    let result = client
243                        .execute_operation(operation)
244                        .await
245                        .map_err(Error::from);
246                    (result, client)
247                }
248            })
249            .await?;
250        Ok(certificate.hash())
251    }
252
253    /// Applies the given function to the chain client.
254    /// Updates the wallet regardless of the outcome. As long as the function returns a round
255    /// timeout, it will wait and retry.
256    async fn apply_client_command<F, Fut, T>(
257        &self,
258        chain_id: &ChainId,
259        mut f: F,
260    ) -> Result<T, Error>
261    where
262        F: FnMut(ChainClient<C::Environment>) -> Fut,
263        Fut: Future<Output = (Result<ClientOutcome<T>, Error>, ChainClient<C::Environment>)>,
264    {
265        loop {
266            let client = self
267                .context
268                .lock()
269                .await
270                .make_chain_client(*chain_id)
271                .await?;
272            let mut stream = client.subscribe()?;
273            let (result, client) = f(client).await;
274            self.context.lock().await.update_wallet(&client).await?;
275            let timeout = match result? {
276                ClientOutcome::Committed(t) => return Ok(t),
277                ClientOutcome::Conflict(certificate) => {
278                    return Err(chain_client::Error::Conflict(certificate.hash()).into());
279                }
280                ClientOutcome::WaitForTimeout(timeout) => timeout,
281            };
282            drop(client);
283            util::wait_for_next_round(&mut stream, timeout).await;
284        }
285    }
286}
287
288#[async_graphql::Object(cache_control(no_cache))]
289impl<C> MutationRoot<C>
290where
291    C: ClientContext + 'static,
292{
293    /// Processes the inbox and returns the lists of certificate hashes that were created, if any.
294    async fn process_inbox(&self, chain_id: ChainId) -> Result<Vec<CryptoHash>, Error> {
295        let mut hashes = Vec::new();
296        loop {
297            let client = self
298                .context
299                .lock()
300                .await
301                .make_chain_client(chain_id)
302                .await?;
303            let result = client.process_inbox().await;
304            self.context.lock().await.update_wallet(&client).await?;
305            let (certificates, maybe_timeout) = result?;
306            hashes.extend(certificates.into_iter().map(|cert| cert.hash()));
307            match maybe_timeout {
308                None => return Ok(hashes),
309                Some(timestamp) => {
310                    let mut stream = client.subscribe()?;
311                    drop(client);
312                    util::wait_for_next_round(&mut stream, timestamp).await;
313                }
314            }
315        }
316    }
317
318    /// Synchronizes the chain with the validators. Returns the chain's length.
319    ///
320    /// This is only used for testing, to make sure that a client is up to date.
321    // TODO(#4718): Remove this mutation.
322    async fn sync(
323        &self,
324        #[graphql(desc = "The chain being synchronized.")] chain_id: ChainId,
325    ) -> Result<u64, Error> {
326        let client = self
327            .context
328            .lock()
329            .await
330            .make_chain_client(chain_id)
331            .await?;
332        let info = client.synchronize_from_validators().await?;
333        self.context.lock().await.update_wallet(&client).await?;
334        Ok(info.next_block_height.0)
335    }
336
337    /// Retries the pending block that was unsuccessfully proposed earlier.
338    async fn retry_pending_block(
339        &self,
340        #[graphql(desc = "The chain on whose block is being retried.")] chain_id: ChainId,
341    ) -> Result<Option<CryptoHash>, Error> {
342        let client = self
343            .context
344            .lock()
345            .await
346            .make_chain_client(chain_id)
347            .await?;
348        let outcome = client.process_pending_block().await?;
349        self.context.lock().await.update_wallet(&client).await?;
350        match outcome {
351            ClientOutcome::Committed(Some(certificate)) => Ok(Some(certificate.hash())),
352            ClientOutcome::Committed(None) => Ok(None),
353            ClientOutcome::WaitForTimeout(timeout) => Err(Error::from(format!(
354                "Please try again at {}",
355                timeout.timestamp
356            ))),
357            ClientOutcome::Conflict(certificate) => Err(Error::from(format!(
358                "A different block was committed: {}",
359                certificate.hash()
360            ))),
361        }
362    }
363
364    /// Transfers `amount` units of value from the given owner's account to the recipient.
365    /// If no owner is given, try to take the units out of the chain account.
366    async fn transfer(
367        &self,
368        chain_id: ChainId,
369        owner: AccountOwner,
370        recipient: Account,
371        amount: Amount,
372    ) -> Result<CryptoHash, Error> {
373        self.apply_client_command(&chain_id, move |client| async move {
374            let result = client
375                .transfer(owner, amount, recipient)
376                .await
377                .map_err(Error::from)
378                .map(|outcome| outcome.map(|certificate| certificate.hash()));
379            (result, client)
380        })
381        .await
382    }
383
384    /// Claims `amount` units of value from the given owner's account in the remote
385    /// `target` chain. Depending on its configuration, the `target` chain may refuse to
386    /// process the message.
387    async fn claim(
388        &self,
389        chain_id: ChainId,
390        owner: AccountOwner,
391        target_id: ChainId,
392        recipient: Account,
393        amount: Amount,
394    ) -> Result<CryptoHash, Error> {
395        self.apply_client_command(&chain_id, move |client| async move {
396            let result = client
397                .claim(owner, target_id, recipient, amount)
398                .await
399                .map_err(Error::from)
400                .map(|outcome| outcome.map(|certificate| certificate.hash()));
401            (result, client)
402        })
403        .await
404    }
405
406    /// Test if a data blob is readable from a transaction in the current chain.
407    // TODO(#2490): Consider removing or renaming this.
408    async fn read_data_blob(
409        &self,
410        chain_id: ChainId,
411        hash: CryptoHash,
412    ) -> Result<CryptoHash, Error> {
413        self.apply_client_command(&chain_id, move |client| async move {
414            let result = client
415                .read_data_blob(hash)
416                .await
417                .map_err(Error::from)
418                .map(|outcome| outcome.map(|certificate| certificate.hash()));
419            (result, client)
420        })
421        .await
422    }
423
424    /// Creates (or activates) a new chain with the given owner.
425    /// This will automatically subscribe to the future committees created by `admin_chain_id`.
426    async fn open_chain(
427        &self,
428        chain_id: ChainId,
429        owner: AccountOwner,
430        balance: Option<Amount>,
431    ) -> Result<ChainId, Error> {
432        let ownership = ChainOwnership::single(owner);
433        let balance = balance.unwrap_or(Amount::ZERO);
434        let description = self
435            .apply_client_command(&chain_id, move |client| {
436                let ownership = ownership.clone();
437                async move {
438                    let result = client
439                        .open_chain(ownership, ApplicationPermissions::default(), balance)
440                        .await
441                        .map_err(Error::from)
442                        .map(|outcome| outcome.map(|(chain_id, _)| chain_id));
443                    (result, client)
444                }
445            })
446            .await?;
447        Ok(description.id())
448    }
449
450    /// Creates (or activates) a new chain by installing the given authentication keys.
451    /// This will automatically subscribe to the future committees created by `admin_chain_id`.
452    #[expect(clippy::too_many_arguments)]
453    async fn open_multi_owner_chain(
454        &self,
455        chain_id: ChainId,
456        application_permissions: Option<ApplicationPermissions>,
457        owners: Vec<AccountOwner>,
458        weights: Option<Vec<u64>>,
459        multi_leader_rounds: Option<u32>,
460        balance: Option<Amount>,
461        #[graphql(desc = "The duration of the fast round, in milliseconds; default: no timeout")]
462        fast_round_ms: Option<u64>,
463        #[graphql(
464            desc = "The duration of the first single-leader and all multi-leader rounds",
465            default = 10_000
466        )]
467        base_timeout_ms: u64,
468        #[graphql(
469            desc = "The number of milliseconds by which the timeout increases after each \
470                    single-leader round",
471            default = 1_000
472        )]
473        timeout_increment_ms: u64,
474        #[graphql(
475            desc = "The age of an incoming tracked or protected message after which the \
476                    validators start transitioning the chain to fallback mode, in milliseconds.",
477            default = 86_400_000
478        )]
479        fallback_duration_ms: u64,
480    ) -> Result<ChainId, Error> {
481        let owners = if let Some(weights) = weights {
482            if weights.len() != owners.len() {
483                return Err(Error::new(format!(
484                    "There are {} owners but {} weights.",
485                    owners.len(),
486                    weights.len()
487                )));
488            }
489            owners.into_iter().zip(weights).collect::<Vec<_>>()
490        } else {
491            owners
492                .into_iter()
493                .zip(iter::repeat(100))
494                .collect::<Vec<_>>()
495        };
496        let multi_leader_rounds = multi_leader_rounds.unwrap_or(u32::MAX);
497        let timeout_config = TimeoutConfig {
498            fast_round_duration: fast_round_ms.map(TimeDelta::from_millis),
499            base_timeout: TimeDelta::from_millis(base_timeout_ms),
500            timeout_increment: TimeDelta::from_millis(timeout_increment_ms),
501            fallback_duration: TimeDelta::from_millis(fallback_duration_ms),
502        };
503        let ownership = ChainOwnership::multiple(owners, multi_leader_rounds, timeout_config);
504        let balance = balance.unwrap_or(Amount::ZERO);
505        let description = self
506            .apply_client_command(&chain_id, move |client| {
507                let ownership = ownership.clone();
508                let application_permissions = application_permissions.clone().unwrap_or_default();
509                async move {
510                    let result = client
511                        .open_chain(ownership, application_permissions, balance)
512                        .await
513                        .map_err(Error::from)
514                        .map(|outcome| outcome.map(|(chain_id, _)| chain_id));
515                    (result, client)
516                }
517            })
518            .await?;
519        Ok(description.id())
520    }
521
522    /// Closes the chain. Returns `None` if it was already closed.
523    async fn close_chain(&self, chain_id: ChainId) -> Result<Option<CryptoHash>, Error> {
524        let maybe_cert = self
525            .apply_client_command(&chain_id, |client| async move {
526                let result = client.close_chain().await.map_err(Error::from);
527                (result, client)
528            })
529            .await?;
530        Ok(maybe_cert.as_ref().map(GenericCertificate::hash))
531    }
532
533    /// Changes the authentication key of the chain.
534    async fn change_owner(
535        &self,
536        chain_id: ChainId,
537        new_owner: AccountOwner,
538    ) -> Result<CryptoHash, Error> {
539        let operation = SystemOperation::ChangeOwnership {
540            super_owners: vec![new_owner],
541            owners: Vec::new(),
542            multi_leader_rounds: 5,
543            open_multi_leader_rounds: false,
544            timeout_config: TimeoutConfig::default(),
545        };
546        self.execute_system_operation(operation, chain_id).await
547    }
548
549    /// Changes the authentication key of the chain.
550    #[expect(clippy::too_many_arguments)]
551    async fn change_multiple_owners(
552        &self,
553        chain_id: ChainId,
554        new_owners: Vec<AccountOwner>,
555        new_weights: Vec<u64>,
556        multi_leader_rounds: u32,
557        open_multi_leader_rounds: bool,
558        #[graphql(desc = "The duration of the fast round, in milliseconds; default: no timeout")]
559        fast_round_ms: Option<u64>,
560        #[graphql(
561            desc = "The duration of the first single-leader and all multi-leader rounds",
562            default = 10_000
563        )]
564        base_timeout_ms: u64,
565        #[graphql(
566            desc = "The number of milliseconds by which the timeout increases after each \
567                    single-leader round",
568            default = 1_000
569        )]
570        timeout_increment_ms: u64,
571        #[graphql(
572            desc = "The age of an incoming tracked or protected message after which the \
573                    validators start transitioning the chain to fallback mode, in milliseconds.",
574            default = 86_400_000
575        )]
576        fallback_duration_ms: u64,
577    ) -> Result<CryptoHash, Error> {
578        let operation = SystemOperation::ChangeOwnership {
579            super_owners: Vec::new(),
580            owners: new_owners.into_iter().zip(new_weights).collect(),
581            multi_leader_rounds,
582            open_multi_leader_rounds,
583            timeout_config: TimeoutConfig {
584                fast_round_duration: fast_round_ms.map(TimeDelta::from_millis),
585                base_timeout: TimeDelta::from_millis(base_timeout_ms),
586                timeout_increment: TimeDelta::from_millis(timeout_increment_ms),
587                fallback_duration: TimeDelta::from_millis(fallback_duration_ms),
588            },
589        };
590        self.execute_system_operation(operation, chain_id).await
591    }
592
593    /// Changes the application permissions configuration on this chain.
594    #[expect(clippy::too_many_arguments)]
595    async fn change_application_permissions(
596        &self,
597        chain_id: ChainId,
598        close_chain: Vec<ApplicationId>,
599        execute_operations: Option<Vec<ApplicationId>>,
600        mandatory_applications: Vec<ApplicationId>,
601        change_application_permissions: Vec<ApplicationId>,
602        call_service_as_oracle: Option<Vec<ApplicationId>>,
603        make_http_requests: Option<Vec<ApplicationId>>,
604    ) -> Result<CryptoHash, Error> {
605        let operation = SystemOperation::ChangeApplicationPermissions(ApplicationPermissions {
606            execute_operations,
607            mandatory_applications,
608            close_chain,
609            change_application_permissions,
610            call_service_as_oracle,
611            make_http_requests,
612        });
613        self.execute_system_operation(operation, chain_id).await
614    }
615
616    /// (admin chain only) Registers a new committee. This will notify the subscribers of
617    /// the admin chain so that they can migrate to the new epoch (by accepting the
618    /// notification as an "incoming message" in a next block).
619    async fn create_committee(
620        &self,
621        chain_id: ChainId,
622        committee: Committee,
623    ) -> Result<CryptoHash, Error> {
624        Ok(self
625            .apply_client_command(&chain_id, move |client| {
626                let committee = committee.clone();
627                async move {
628                    let result = client
629                        .stage_new_committee(committee)
630                        .await
631                        .map_err(Error::from);
632                    (result, client)
633                }
634            })
635            .await?
636            .hash())
637    }
638
639    /// (admin chain only) Removes a committee. Once this message is accepted by a chain,
640    /// blocks from the retired epoch will not be accepted until they are followed (hence
641    /// re-certified) by a block certified by a recent committee.
642    async fn remove_committee(&self, chain_id: ChainId, epoch: Epoch) -> Result<CryptoHash, Error> {
643        let operation = SystemOperation::Admin(AdminOperation::RemoveCommittee { epoch });
644        self.execute_system_operation(operation, chain_id).await
645    }
646
647    /// Publishes a new application module.
648    async fn publish_module(
649        &self,
650        chain_id: ChainId,
651        contract: Bytecode,
652        service: Bytecode,
653        vm_runtime: VmRuntime,
654    ) -> Result<ModuleId, Error> {
655        self.apply_client_command(&chain_id, move |client| {
656            let contract = contract.clone();
657            let service = service.clone();
658            async move {
659                let result = client
660                    .publish_module(contract, service, vm_runtime)
661                    .await
662                    .map_err(Error::from)
663                    .map(|outcome| outcome.map(|(module_id, _)| module_id));
664                (result, client)
665            }
666        })
667        .await
668    }
669
670    /// Publishes a new data blob.
671    async fn publish_data_blob(
672        &self,
673        chain_id: ChainId,
674        bytes: Vec<u8>,
675    ) -> Result<CryptoHash, Error> {
676        self.apply_client_command(&chain_id, |client| {
677            let bytes = bytes.clone();
678            async move {
679                let result = client.publish_data_blob(bytes).await.map_err(Error::from);
680                (result, client)
681            }
682        })
683        .await
684        .map(|_| CryptoHash::new(&BlobContent::new_data(bytes)))
685    }
686
687    /// Creates a new application.
688    async fn create_application(
689        &self,
690        chain_id: ChainId,
691        module_id: ModuleId,
692        parameters: String,
693        instantiation_argument: String,
694        required_application_ids: Vec<ApplicationId>,
695    ) -> Result<ApplicationId, Error> {
696        self.apply_client_command(&chain_id, move |client| {
697            let parameters = parameters.as_bytes().to_vec();
698            let instantiation_argument = instantiation_argument.as_bytes().to_vec();
699            let required_application_ids = required_application_ids.clone();
700            async move {
701                let result = client
702                    .create_application_untyped(
703                        module_id,
704                        parameters,
705                        instantiation_argument,
706                        required_application_ids,
707                    )
708                    .await
709                    .map_err(Error::from)
710                    .map(|outcome| outcome.map(|(application_id, _)| application_id));
711                (result, client)
712            }
713        })
714        .await
715    }
716}
717
718#[async_graphql::Object(cache_control(no_cache))]
719impl<C> QueryRoot<C>
720where
721    C: ClientContext + 'static,
722{
723    async fn chain(
724        &self,
725        chain_id: ChainId,
726    ) -> Result<ChainStateExtendedView<<C::Environment as linera_core::Environment>::Storage>, Error>
727    {
728        let client = self
729            .context
730            .lock()
731            .await
732            .make_chain_client(chain_id)
733            .await?;
734        let view = client.chain_state_view().await?;
735        Ok(ChainStateExtendedView::new(view))
736    }
737
738    async fn applications(&self, chain_id: ChainId) -> Result<Vec<ApplicationOverview>, Error> {
739        let client = self
740            .context
741            .lock()
742            .await
743            .make_chain_client(chain_id)
744            .await?;
745        let applications = client
746            .chain_state_view()
747            .await?
748            .execution_state
749            .list_applications()
750            .await?;
751
752        let overviews = applications
753            .into_iter()
754            .map(|(id, description)| ApplicationOverview::new(id, description, self.port, chain_id))
755            .collect();
756
757        Ok(overviews)
758    }
759
760    async fn chains(&self) -> Result<Chains, Error> {
761        Ok(Chains {
762            list: self
763                .context
764                .lock()
765                .await
766                .wallet()
767                .chain_ids()
768                .try_collect()
769                .await?,
770            default: self.default_chain,
771        })
772    }
773
774    async fn block(
775        &self,
776        hash: Option<CryptoHash>,
777        chain_id: ChainId,
778    ) -> Result<Option<Arc<ConfirmedBlock>>, Error> {
779        let client = self
780            .context
781            .lock()
782            .await
783            .make_chain_client(chain_id)
784            .await?;
785        let hash = match hash {
786            Some(hash) => Some(hash),
787            None => client.chain_info().await?.block_hash,
788        };
789        if let Some(hash) = hash {
790            Ok(Some(client.read_confirmed_block(hash).await?))
791        } else {
792            Ok(None)
793        }
794    }
795
796    async fn events_from_index(
797        &self,
798        chain_id: ChainId,
799        stream_id: StreamId,
800        start_index: u32,
801    ) -> Result<Vec<IndexAndEvent>, Error> {
802        Ok(self
803            .context
804            .lock()
805            .await
806            .make_chain_client(chain_id)
807            .await?
808            .events_from_index(stream_id, start_index)
809            .await?)
810    }
811
812    async fn blocks(
813        &self,
814        from: Option<CryptoHash>,
815        chain_id: ChainId,
816        limit: Option<u32>,
817    ) -> Result<Vec<Arc<ConfirmedBlock>>, Error> {
818        let client = self
819            .context
820            .lock()
821            .await
822            .make_chain_client(chain_id)
823            .await?;
824        let limit = limit.unwrap_or(10);
825        let from = match from {
826            Some(from) => Some(from),
827            None => client.chain_info().await?.block_hash,
828        };
829        let Some(from) = from else {
830            return Ok(vec![]);
831        };
832        let mut hash = Some(from);
833        let mut values = Vec::new();
834        for _ in 0..limit {
835            let Some(next_hash) = hash else {
836                break;
837            };
838            let value = client.read_confirmed_block(next_hash).await?;
839            hash = value.block().header.previous_block_hash;
840            values.push(value);
841        }
842        Ok(values)
843    }
844
845    /// Returns the version information on this node service.
846    async fn version(&self) -> linera_version::VersionInfo {
847        linera_version::VersionInfo::default()
848    }
849}
850
851// What follows is a hack to add a chain_id field to `ChainStateView` based on
852// https://async-graphql.github.io/async-graphql/en/merging_objects.html
853
854struct ChainStateViewExtension(ChainId);
855
856#[async_graphql::Object(cache_control(no_cache))]
857impl ChainStateViewExtension {
858    async fn chain_id(&self) -> ChainId {
859        self.0
860    }
861}
862
863#[derive(MergedObject)]
864struct ChainStateExtendedView<S: Storage>(ChainStateViewExtension, ReadOnlyChainStateView<S>)
865where
866    ChainStateView<S::Context>: ContainerType + OutputType;
867
868/// A wrapper type that allows proxying GraphQL queries to a [`ChainStateView`] that's behind a
869/// [`ChainStateViewReadGuard`].
870pub struct ReadOnlyChainStateView<S: Storage>(ChainStateViewReadGuard<S>)
871where
872    ChainStateView<S::Context>: ContainerType + OutputType;
873
874impl<S: Storage> ContainerType for ReadOnlyChainStateView<S>
875where
876    ChainStateView<S::Context>: ContainerType + OutputType,
877{
878    async fn resolve_field(
879        &self,
880        context: &async_graphql::Context<'_>,
881    ) -> async_graphql::ServerResult<Option<async_graphql::Value>> {
882        self.0.resolve_field(context).await
883    }
884}
885
886impl<S: Storage> OutputType for ReadOnlyChainStateView<S>
887where
888    ChainStateView<S::Context>: ContainerType + OutputType,
889{
890    fn type_name() -> Cow<'static, str> {
891        ChainStateView::<S::Context>::type_name()
892    }
893
894    fn create_type_info(registry: &mut async_graphql::registry::Registry) -> String {
895        ChainStateView::<S::Context>::create_type_info(registry)
896    }
897
898    async fn resolve(
899        &self,
900        context: &async_graphql::ContextSelectionSet<'_>,
901        field: &async_graphql::Positioned<async_graphql::parser::types::Field>,
902    ) -> async_graphql::ServerResult<async_graphql::Value> {
903        self.0.resolve(context, field).await
904    }
905}
906
907impl<S: Storage> ChainStateExtendedView<S>
908where
909    ChainStateView<S::Context>: ContainerType + OutputType,
910{
911    fn new(view: ChainStateViewReadGuard<S>) -> Self {
912        Self(
913            ChainStateViewExtension(view.chain_id()),
914            ReadOnlyChainStateView(view),
915        )
916    }
917}
918
919/// A summary of an application registered on a chain.
920#[derive(SimpleObject)]
921pub struct ApplicationOverview {
922    id: ApplicationId,
923    description: ApplicationDescription,
924    link: String,
925}
926
927impl ApplicationOverview {
928    fn new(
929        id: ApplicationId,
930        description: ApplicationDescription,
931        port: NonZeroU16,
932        chain_id: ChainId,
933    ) -> Self {
934        Self {
935            id,
936            description,
937            link: format!(
938                "http://localhost:{}/chains/{}/applications/{}",
939                port.get(),
940                chain_id,
941                id
942            ),
943        }
944    }
945}
946
947/// Schema type that can be either full (with mutations) or read-only.
948pub enum NodeServiceSchema<C>
949where
950    C: ClientContext + 'static,
951{
952    /// Full schema with mutations enabled.
953    Full(Schema<QueryRoot<C>, MutationRoot<C>, SubscriptionRoot<C>>),
954    /// Read-only schema with mutations disabled.
955    ReadOnly(Schema<QueryRoot<C>, EmptyMutation, SubscriptionRoot<C>>),
956}
957
958impl<C> NodeServiceSchema<C>
959where
960    C: ClientContext,
961{
962    /// Executes a GraphQL request.
963    pub async fn execute(&self, request: impl Into<Request>) -> Response {
964        match self {
965            Self::Full(schema) => schema.execute(request).await,
966            Self::ReadOnly(schema) => schema.execute(request).await,
967        }
968    }
969
970    /// Returns the SDL (Schema Definition Language) representation.
971    pub fn sdl(&self) -> String {
972        match self {
973            Self::Full(schema) => schema.sdl(),
974            Self::ReadOnly(schema) => schema.sdl(),
975        }
976    }
977}
978
979impl<C> Clone for NodeServiceSchema<C>
980where
981    C: ClientContext,
982{
983    fn clone(&self) -> Self {
984        match self {
985            Self::Full(schema) => Self::Full(schema.clone()),
986            Self::ReadOnly(schema) => Self::ReadOnly(schema.clone()),
987        }
988    }
989}
990
991#[cfg(with_metrics)]
992mod query_cache_metrics {
993    use std::sync::LazyLock;
994
995    use linera_base::prometheus_util::{register_int_counter_vec, register_int_gauge};
996    use prometheus::{IntCounterVec, IntGauge};
997
998    pub static QUERY_CACHE_HIT: LazyLock<IntCounterVec> = LazyLock::new(|| {
999        register_int_counter_vec("query_response_cache_hit", "Query response cache hits", &[])
1000    });
1001
1002    pub static QUERY_CACHE_MISS: LazyLock<IntCounterVec> = LazyLock::new(|| {
1003        register_int_counter_vec(
1004            "query_response_cache_miss",
1005            "Query response cache misses",
1006            &[],
1007        )
1008    });
1009
1010    pub static QUERY_CACHE_INVALIDATION: LazyLock<IntCounterVec> = LazyLock::new(|| {
1011        register_int_counter_vec(
1012            "query_response_cache_invalidation",
1013            "Query response cache invalidations (per chain)",
1014            &[],
1015        )
1016    });
1017
1018    pub static QUERY_CACHE_ENTRIES: LazyLock<IntGauge> = LazyLock::new(|| {
1019        register_int_gauge(
1020            "query_response_cache_entries",
1021            "Current number of cached query responses across all chains",
1022        )
1023    });
1024}
1025
1026/// Per-chain cache state: an LRU map plus the `next_block_height` at the time the
1027/// cache was last invalidated. Both are behind the same mutex.
1028struct PerChainCache {
1029    lru: LruCache<(ApplicationId, Vec<u8>), Vec<u8>>,
1030    next_block_height: BlockHeight,
1031}
1032
1033/// An LRU cache for application query responses, keyed per chain.
1034///
1035/// Caches serialized response bytes keyed on `(chain_id, application_id, request_bytes)`.
1036/// The entire per-chain cache is invalidated when a `NewBlock` notification arrives.
1037///
1038/// To prevent a race where a slow query inserts stale data *after* an invalidation,
1039/// each insert carries the chain's `next_block_height` at query time.
1040/// If a newer block has since been processed, the insert is silently dropped.
1041struct QueryResponseCache {
1042    chains: papaya::HashMap<ChainId, StdMutex<PerChainCache>>,
1043    /// Chains for which we have registered a notification subscription.
1044    subscribed: papaya::HashSet<ChainId>,
1045    /// Sender half of the notification channel, used to subscribe new chains lazily.
1046    notification_sender: StdMutex<Option<tokio::sync::mpsc::UnboundedSender<Notification>>>,
1047    capacity_per_chain: std::num::NonZeroUsize,
1048}
1049
1050impl QueryResponseCache {
1051    fn new(capacity_per_chain: usize) -> Self {
1052        Self {
1053            chains: papaya::HashMap::new(),
1054            subscribed: papaya::HashSet::new(),
1055            notification_sender: StdMutex::new(None),
1056            capacity_per_chain: std::num::NonZeroUsize::new(capacity_per_chain)
1057                .expect("capacity must be > 0"),
1058        }
1059    }
1060
1061    /// Stores the notification sender (called once during startup).
1062    fn set_notification_sender(&self, sender: tokio::sync::mpsc::UnboundedSender<Notification>) {
1063        *self
1064            .notification_sender
1065            .lock()
1066            .expect("sender mutex poisoned") = Some(sender);
1067    }
1068
1069    /// Returns the notification sender, if set.
1070    fn notification_sender(&self) -> Option<tokio::sync::mpsc::UnboundedSender<Notification>> {
1071        self.notification_sender
1072            .lock()
1073            .expect("sender mutex poisoned")
1074            .clone()
1075    }
1076
1077    /// Marks a chain as subscribed to notifications.
1078    fn mark_subscribed(&self, chain_id: ChainId) {
1079        self.subscribed.pin().insert(chain_id);
1080    }
1081
1082    /// Returns `true` if the chain is not yet subscribed to notifications.
1083    fn needs_subscription(&self, chain_id: &ChainId) -> bool {
1084        !self.subscribed.pin().contains(chain_id)
1085    }
1086
1087    /// Marks initial chains as subscribed (called during startup).
1088    fn mark_all_subscribed(&self, chain_ids: &[ChainId]) {
1089        let pinned = self.subscribed.pin();
1090        for &chain_id in chain_ids {
1091            pinned.insert(chain_id);
1092        }
1093    }
1094
1095    /// Looks up a cached response. Returns `Some(bytes)` on hit, `None` on miss
1096    /// (including when the chain has no cache entry yet).
1097    fn get(&self, chain_id: ChainId, app_id: &ApplicationId, request: &[u8]) -> Option<Vec<u8>> {
1098        let pinned = self.chains.pin();
1099        let result = pinned.get(&chain_id).and_then(|mutex| {
1100            mutex
1101                .lock()
1102                .expect("LRU mutex poisoned")
1103                .lru
1104                .get(&(*app_id, request.to_vec()))
1105                .cloned()
1106        });
1107        #[cfg(with_metrics)]
1108        {
1109            let metric = if result.is_some() {
1110                &query_cache_metrics::QUERY_CACHE_HIT
1111            } else {
1112                &query_cache_metrics::QUERY_CACHE_MISS
1113            };
1114            metric.with_label_values(&[]).inc();
1115        }
1116        result
1117    }
1118
1119    /// Inserts a response into the cache, unless the chain's `next_block_height` has
1120    /// advanced past the caller's snapshot (which would mean a new block arrived and
1121    /// this response is potentially stale).
1122    fn insert(
1123        &self,
1124        chain_id: ChainId,
1125        app_id: ApplicationId,
1126        request: Vec<u8>,
1127        response: Vec<u8>,
1128        next_block_height: BlockHeight,
1129    ) {
1130        let pinned = self.chains.pin();
1131        let capacity = self.capacity_per_chain;
1132        let mutex = pinned.get_or_insert_with(chain_id, || {
1133            StdMutex::new(PerChainCache {
1134                lru: LruCache::new(capacity),
1135                next_block_height,
1136            })
1137        });
1138        let mut cache = mutex.lock().expect("LRU mutex poisoned");
1139        if next_block_height < cache.next_block_height {
1140            return; // A new block arrived since this query started; discard stale response.
1141        }
1142        // If the chain has advanced since the last cache update, also clear stale entries.
1143        // Note: This should not happen if notifications are timely. Also, this only
1144        // works when we have a cache miss.
1145        if next_block_height > cache.next_block_height {
1146            debug!(
1147                "Unexpected query cache invalidation for chain {chain_id}:\
1148                 {next_block_height} > {}",
1149                cache.next_block_height
1150            );
1151            #[cfg(with_metrics)]
1152            {
1153                query_cache_metrics::QUERY_CACHE_ENTRIES.sub(cache.lru.len() as i64);
1154                query_cache_metrics::QUERY_CACHE_INVALIDATION
1155                    .with_label_values(&[])
1156                    .inc();
1157            }
1158            cache.lru.clear();
1159            cache.next_block_height = next_block_height;
1160        }
1161        #[cfg(with_metrics)]
1162        let prev_len = cache.lru.len();
1163        cache.lru.put((app_id, request), response);
1164        #[cfg(with_metrics)]
1165        if cache.lru.len() != prev_len {
1166            query_cache_metrics::QUERY_CACHE_ENTRIES.inc();
1167        }
1168    }
1169
1170    /// Called when a `NewBlock` notification arrives. Records the new
1171    /// `next_block_height` and clears all cached responses for the chain.
1172    fn invalidate_chain(&self, chain_id: &ChainId, next_block_height: BlockHeight) {
1173        let pinned = self.chains.pin();
1174        let capacity = self.capacity_per_chain;
1175        let mutex = pinned.get_or_insert_with(*chain_id, || {
1176            StdMutex::new(PerChainCache {
1177                lru: LruCache::new(capacity),
1178                next_block_height,
1179            })
1180        });
1181        let mut cache = mutex.lock().expect("LRU mutex poisoned");
1182        if next_block_height > cache.next_block_height {
1183            #[cfg(with_metrics)]
1184            {
1185                query_cache_metrics::QUERY_CACHE_ENTRIES.sub(cache.lru.len() as i64);
1186                query_cache_metrics::QUERY_CACHE_INVALIDATION
1187                    .with_label_values(&[])
1188                    .inc();
1189            }
1190            cache.lru.clear();
1191            cache.next_block_height = next_block_height;
1192        } else {
1193            debug!(
1194                "Query cache for chain {chain_id} was already invalidated:\
1195                 {next_block_height} <= {}",
1196                cache.next_block_height
1197            );
1198        }
1199    }
1200}
1201
1202/// The `NodeService` is a server that exposes a web-server to the client.
1203/// The node service is primarily used to explore the state of a chain in GraphQL.
1204pub struct NodeService<C>
1205where
1206    C: ClientContext + 'static,
1207{
1208    config: ChainListenerConfig,
1209    port: NonZeroU16,
1210    #[cfg(with_metrics)]
1211    metrics_port: NonZeroU16,
1212    default_chain: Option<ChainId>,
1213    context: Arc<Mutex<C>>,
1214    /// If true, disallow mutations and prevent queries from scheduling operations.
1215    read_only: bool,
1216    /// Optional LRU cache for application query responses. `None` when caching is disabled.
1217    query_cache: Option<Arc<QueryResponseCache>>,
1218    query_subscriptions: Option<Arc<crate::query_subscription::QuerySubscriptionManager>>,
1219    cancellation_token: CancellationToken,
1220    enable_memory_profiling: bool,
1221    /// If true, do not start the chain listener; serve queries from local state only.
1222    pause: bool,
1223}
1224
1225impl<C> Clone for NodeService<C>
1226where
1227    C: ClientContext + 'static,
1228{
1229    fn clone(&self) -> Self {
1230        Self {
1231            config: self.config.clone(),
1232            port: self.port,
1233            #[cfg(with_metrics)]
1234            metrics_port: self.metrics_port,
1235            default_chain: self.default_chain,
1236            context: Arc::clone(&self.context),
1237            read_only: self.read_only,
1238            query_cache: self.query_cache.clone(),
1239            query_subscriptions: self.query_subscriptions.clone(),
1240            cancellation_token: self.cancellation_token.clone(),
1241            enable_memory_profiling: self.enable_memory_profiling,
1242            pause: self.pause,
1243        }
1244    }
1245}
1246
1247impl<C> NodeService<C>
1248where
1249    C: ClientContext,
1250{
1251    /// Creates a new instance of the node service given a client chain and a port.
1252    ///
1253    /// `query_cache_size` controls the per-chain LRU cache capacity for application query
1254    /// responses. Pass `None` to disable the cache (the default). Enable with
1255    /// `--query-cache-size <N>`. Incompatible with `--long-lived-services`.
1256    #[expect(clippy::too_many_arguments)]
1257    pub fn new(
1258        config: ChainListenerConfig,
1259        port: NonZeroU16,
1260        #[cfg(with_metrics)] metrics_port: NonZeroU16,
1261        default_chain: Option<ChainId>,
1262        context: Arc<Mutex<C>>,
1263        read_only: bool,
1264        query_cache_size: Option<usize>,
1265        query_subscriptions: Option<Arc<crate::query_subscription::QuerySubscriptionManager>>,
1266        cancellation_token: CancellationToken,
1267        enable_memory_profiling: bool,
1268        pause: bool,
1269    ) -> Self {
1270        let query_cache = query_cache_size.map(|size| Arc::new(QueryResponseCache::new(size)));
1271        Self {
1272            config,
1273            port,
1274            #[cfg(with_metrics)]
1275            metrics_port,
1276            default_chain,
1277            context,
1278            read_only,
1279            query_cache,
1280            query_subscriptions,
1281            cancellation_token,
1282            enable_memory_profiling,
1283            pause,
1284        }
1285    }
1286
1287    /// Returns the socket address on which the metrics endpoint is served.
1288    #[cfg(with_metrics)]
1289    pub fn metrics_address(&self) -> SocketAddr {
1290        SocketAddr::from(([0, 0, 0, 0], self.metrics_port.get()))
1291    }
1292
1293    /// Builds the GraphQL schema served by the node service.
1294    pub fn schema(&self) -> NodeServiceSchema<C> {
1295        let query = QueryRoot {
1296            context: Arc::clone(&self.context),
1297            port: self.port,
1298            default_chain: self.default_chain,
1299        };
1300        let subscription = SubscriptionRoot {
1301            context: Arc::clone(&self.context),
1302            query_subscriptions: self.query_subscriptions.clone(),
1303            cancellation_token: self.cancellation_token.clone(),
1304        };
1305
1306        if self.read_only {
1307            NodeServiceSchema::ReadOnly(Schema::build(query, EmptyMutation, subscription).finish())
1308        } else {
1309            NodeServiceSchema::Full(
1310                Schema::build(
1311                    query,
1312                    MutationRoot {
1313                        context: Arc::clone(&self.context),
1314                    },
1315                    subscription,
1316                )
1317                .finish(),
1318            )
1319        }
1320    }
1321
1322    /// Runs the node service.
1323    #[instrument(name = "node_service", level = "info", skip_all, fields(port = ?self.port))]
1324    pub async fn run(
1325        self,
1326        cancellation_token: CancellationToken,
1327        command_receiver: UnboundedReceiver<ListenerCommand>,
1328    ) -> Result<(), anyhow::Error> {
1329        let port = self.port.get();
1330        let index_handler = axum::routing::get(util::graphiql).post(Self::index_handler);
1331        let application_handler =
1332            axum::routing::get(util::graphiql).post(Self::application_handler);
1333
1334        #[cfg(with_metrics)]
1335        monitoring_server::start_metrics_with_profiling(
1336            self.metrics_address(),
1337            cancellation_token.clone(),
1338            self.enable_memory_profiling,
1339        )
1340        .await;
1341
1342        let base_router = Router::new()
1343            .route("/", index_handler)
1344            .route(
1345                "/chains/{chain_id}/applications/{application_id}",
1346                application_handler,
1347            )
1348            .route("/ready", axum::routing::get(|| async { "ready!" }));
1349
1350        // Create router with appropriate schema for WebSocket subscriptions.
1351        let app = match self.schema() {
1352            NodeServiceSchema::Full(schema) => {
1353                base_router.route_service("/ws", GraphQLSubscription::new(schema))
1354            }
1355            NodeServiceSchema::ReadOnly(schema) => {
1356                base_router.route_service("/ws", GraphQLSubscription::new(schema))
1357            }
1358        }
1359        .layer(Extension(self.clone()))
1360        // TODO(#551): Provide application authentication.
1361        .layer(CorsLayer::permissive());
1362
1363        info!("GraphiQL IDE: http://localhost:{}", port);
1364
1365        // Spawn the cache invalidation listener if caching is enabled.
1366        if let Some(cache) = &self.query_cache {
1367            let guard = self.context.lock().await;
1368            let chain_ids: Vec<ChainId> = guard.wallet().chain_ids().try_collect().await?;
1369            let (tx, mut receiver) = tokio::sync::mpsc::unbounded_channel();
1370            guard.client().subscribe_extra(chain_ids.clone(), &tx);
1371            cache.mark_all_subscribed(&chain_ids);
1372            cache.set_notification_sender(tx);
1373            drop(guard);
1374            let cache = Arc::clone(cache);
1375            tokio::spawn(async move {
1376                while let Some(notification) = receiver.recv().await {
1377                    if let Reason::NewBlock { height, .. } = notification.reason {
1378                        let next_block_height = height
1379                            .try_add_one()
1380                            .expect("block height should not overflow");
1381                        cache.invalidate_chain(&notification.chain_id, next_block_height);
1382                    }
1383                }
1384            });
1385        }
1386
1387        let tcp_listener =
1388            tokio::net::TcpListener::bind(SocketAddr::from(([0, 0, 0, 0], port))).await?;
1389        let server = axum::serve(tcp_listener, app)
1390            .with_graceful_shutdown(cancellation_token.clone().cancelled_owned())
1391            .into_future();
1392
1393        if self.pause {
1394            info!("Running in paused mode: chain synchronization is disabled");
1395            server.await?;
1396        } else {
1397            let storage = self.context.lock().await.storage().clone();
1398            let chain_listener = ChainListener::new(
1399                self.config,
1400                self.context,
1401                storage,
1402                cancellation_token.clone(),
1403                command_receiver,
1404                true,
1405            )
1406            .run()
1407            .await?;
1408            let mut chain_listener = Box::pin(chain_listener).fuse();
1409            futures::select! {
1410                result = chain_listener => result?,
1411                result = Box::pin(server).fuse() => result?,
1412            };
1413        }
1414
1415        Ok(())
1416    }
1417
1418    /// Handles service queries for user applications (including mutations).
1419    async fn handle_service_request(
1420        &self,
1421        application_id: ApplicationId,
1422        request: Vec<u8>,
1423        chain_id: ChainId,
1424        block_hash: Option<CryptoHash>,
1425    ) -> Result<Vec<u8>, NodeServiceError> {
1426        // Only cache read-only queries against the latest state (block_hash == None).
1427        let cache = block_hash
1428            .is_none()
1429            .then_some(self.query_cache.as_ref())
1430            .flatten();
1431
1432        // Return immediately on cache hit.
1433        if let Some(cache) = cache {
1434            if let Some(cached) = cache.get(chain_id, &application_id, &request) {
1435                return Ok(cached);
1436            }
1437        }
1438
1439        let (
1440            QueryOutcome {
1441                response,
1442                operations,
1443            },
1444            block_height,
1445        ) = self
1446            .query_user_application(application_id, request.clone(), chain_id, block_hash)
1447            .await?;
1448        if operations.is_empty() {
1449            if let Some(cache) = cache {
1450                // Lazily subscribe to notifications for chains discovered after startup.
1451                if cache.needs_subscription(&chain_id) {
1452                    if let Some(sender) = cache.notification_sender() {
1453                        self.context
1454                            .lock()
1455                            .await
1456                            .client()
1457                            .subscribe_extra(vec![chain_id], &sender);
1458                        cache.mark_subscribed(chain_id);
1459                    }
1460                }
1461                cache.insert(
1462                    chain_id,
1463                    application_id,
1464                    request,
1465                    response.clone(),
1466                    block_height,
1467                );
1468            }
1469            return Ok(response);
1470        }
1471
1472        if self.read_only {
1473            return Err(NodeServiceError::ReadOnlyModeOperationsNotAllowed);
1474        }
1475
1476        trace!("Query requested a new block with operations: {operations:?}");
1477        let client = self
1478            .context
1479            .lock()
1480            .await
1481            .make_chain_client(chain_id)
1482            .await?;
1483        let hash = loop {
1484            let timeout = match client
1485                .execute_operations(operations.clone(), vec![])
1486                .await?
1487            {
1488                ClientOutcome::Committed(certificate) => break certificate.hash(),
1489                ClientOutcome::Conflict(certificate) => {
1490                    return Err(chain_client::Error::Conflict(certificate.hash()).into());
1491                }
1492                ClientOutcome::WaitForTimeout(timeout) => timeout,
1493            };
1494            let mut stream = client.subscribe().map_err(|_| {
1495                chain_client::Error::InternalError("Could not subscribe to the local node.")
1496            })?;
1497            util::wait_for_next_round(&mut stream, timeout).await;
1498        };
1499        let response = async_graphql::Response::new(hash.to_value());
1500        Ok(serde_json::to_vec(&response)?)
1501    }
1502
1503    /// Queries a user application, returning the raw [`QueryOutcome`] and the height of the
1504    /// chain's latest block at the time of the query (used for cache staleness detection).
1505    async fn query_user_application(
1506        &self,
1507        application_id: ApplicationId,
1508        bytes: Vec<u8>,
1509        chain_id: ChainId,
1510        block_hash: Option<CryptoHash>,
1511    ) -> Result<(QueryOutcome<Vec<u8>>, BlockHeight), NodeServiceError> {
1512        let query = Query::User {
1513            application_id,
1514            bytes,
1515        };
1516        let client = self
1517            .context
1518            .lock()
1519            .await
1520            .make_chain_client(chain_id)
1521            .await?;
1522        let (
1523            QueryOutcome {
1524                response,
1525                operations,
1526            },
1527            next_block_height,
1528        ) = client.query_application(query, block_hash).await?;
1529        match response {
1530            QueryResponse::System(_) => {
1531                unreachable!("cannot get a system response for a user query")
1532            }
1533            QueryResponse::User(user_response_bytes) => Ok((
1534                QueryOutcome {
1535                    response: user_response_bytes,
1536                    operations,
1537                },
1538                next_block_height,
1539            )),
1540        }
1541    }
1542
1543    /// Executes a GraphQL query and generates a response for our `Schema`.
1544    async fn index_handler(service: Extension<Self>, request: GraphQLRequest) -> GraphQLResponse {
1545        service
1546            .0
1547            .schema()
1548            .execute(request.into_inner())
1549            .await
1550            .into()
1551    }
1552
1553    /// Executes a GraphQL query against an application.
1554    /// Pattern matches on the `OperationType` of the query and routes the query
1555    /// accordingly.
1556    async fn application_handler(
1557        Path((chain_id, application_id)): Path<(String, String)>,
1558        service: Extension<Self>,
1559        request: String,
1560    ) -> Result<Vec<u8>, NodeServiceError> {
1561        let chain_id: ChainId = chain_id.parse().map_err(NodeServiceError::InvalidChainId)?;
1562        let application_id: ApplicationId = application_id.parse()?;
1563
1564        debug!(
1565            %chain_id,
1566            %application_id,
1567            "processing request for application:\n{:?}",
1568            &request
1569        );
1570        let response = service
1571            .0
1572            .handle_service_request(application_id, request.into_bytes(), chain_id, None)
1573            .await?;
1574
1575        Ok(response)
1576    }
1577}
1578
1579#[cfg(test)]
1580mod tests {
1581    use linera_base::{
1582        crypto::CryptoHash,
1583        data_types::BlockHeight,
1584        identifiers::{ApplicationId, ChainId},
1585    };
1586
1587    use super::QueryResponseCache;
1588
1589    fn test_chain(n: u64) -> ChainId {
1590        ChainId(CryptoHash::test_hash(format!("chain-{n}")))
1591    }
1592
1593    fn test_app(n: u64) -> ApplicationId {
1594        ApplicationId::new(CryptoHash::test_hash(format!("app-{n}")))
1595    }
1596
1597    #[test]
1598    fn cache_hit_and_miss() {
1599        let cache = QueryResponseCache::new(100);
1600        let chain = test_chain(0);
1601        let app = test_app(0);
1602        let request = b"query { balance }".to_vec();
1603        let response = b"{ \"balance\": 42 }".to_vec();
1604
1605        // Unknown chain — get returns None.
1606        assert!(cache.get(chain, &app, &request).is_none());
1607
1608        // Insert creates the per-chain entry.
1609        cache.insert(
1610            chain,
1611            app,
1612            request.clone(),
1613            response.clone(),
1614            BlockHeight(1),
1615        );
1616
1617        // Hit after insert.
1618        assert_eq!(cache.get(chain, &app, &request), Some(response));
1619    }
1620
1621    #[test]
1622    fn per_chain_isolation() {
1623        let cache = QueryResponseCache::new(100);
1624        let chain_a = test_chain(0);
1625        let chain_b = test_chain(1);
1626        let app = test_app(0);
1627        let request = b"q".to_vec();
1628        let response = b"r".to_vec();
1629
1630        cache.insert(
1631            chain_a,
1632            app,
1633            request.clone(),
1634            response.clone(),
1635            BlockHeight(1),
1636        );
1637
1638        // Invalidating chain B must not affect chain A.
1639        cache.invalidate_chain(&chain_b, BlockHeight(1));
1640        assert_eq!(cache.get(chain_a, &app, &request), Some(response));
1641    }
1642
1643    #[test]
1644    fn invalidation_clears_all_entries() {
1645        let cache = QueryResponseCache::new(100);
1646        let chain = test_chain(0);
1647        let app = test_app(0);
1648
1649        cache.insert(chain, app, b"q1".to_vec(), b"r1".to_vec(), BlockHeight(1));
1650        cache.insert(chain, app, b"q2".to_vec(), b"r2".to_vec(), BlockHeight(1));
1651
1652        cache.invalidate_chain(&chain, BlockHeight(2));
1653        assert!(cache.get(chain, &app, b"q1").is_none());
1654        assert!(cache.get(chain, &app, b"q2").is_none());
1655    }
1656
1657    #[test]
1658    fn lru_eviction() {
1659        let cache = QueryResponseCache::new(2);
1660        let chain = test_chain(0);
1661        let app = test_app(0);
1662
1663        cache.insert(chain, app, b"q1".to_vec(), b"r1".to_vec(), BlockHeight(1));
1664        cache.insert(chain, app, b"q2".to_vec(), b"r2".to_vec(), BlockHeight(1));
1665        // Third insert evicts q1 (least recently used).
1666        cache.insert(chain, app, b"q3".to_vec(), b"r3".to_vec(), BlockHeight(1));
1667
1668        assert!(cache.get(chain, &app, b"q1").is_none());
1669        assert!(cache.get(chain, &app, b"q2").is_some());
1670        assert!(cache.get(chain, &app, b"q3").is_some());
1671    }
1672
1673    #[test]
1674    fn stale_insert_rejected_after_invalidation() {
1675        let cache = QueryResponseCache::new(100);
1676        let chain = test_chain(0);
1677        let app = test_app(0);
1678
1679        // Chain is at block 3. A query starts and snapshots this height.
1680        cache.insert(chain, app, b"q0".to_vec(), b"r0".to_vec(), BlockHeight(3));
1681        let stale_height = BlockHeight(3);
1682
1683        // Block 4 arrives while the query is in flight.
1684        cache.invalidate_chain(&chain, BlockHeight(4));
1685
1686        // Slow query finishes and tries to insert with the stale height.
1687        cache.insert(chain, app, b"q".to_vec(), b"stale".to_vec(), stale_height);
1688
1689        // The stale insert should have been rejected.
1690        assert!(cache.get(chain, &app, b"q").is_none());
1691    }
1692}