Skip to content
Draft
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
68 changes: 61 additions & 7 deletions crates/iota-config/src/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,19 @@ pub struct NodeConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub consensus_config: Option<ConsensusConfig>,

/// Flag to enable index processing for a full node.
///
/// If set to true, node creates `IndexStore` for transaction
/// data including ownership and balance information.
#[serde(default = "default_enable_index_processing")]
pub enable_index_processing: bool,
/// Flag to enable the JSON-RPC API. Default: `true`. When `true` the node
/// serves every JSON-RPC method, including the index-backed ones; when
/// `false` nothing is mounted on `json_rpc_address`, the `/health`
/// endpoint included. Metrics and the admin interface are unaffected.
#[serde(default = "default_enable_jsonrpc_api")]
pub enable_jsonrpc_api: bool,

/// Renamed to `enable_jsonrpc_api`, and rejected by
/// [`NodeConfig::check_renamed_keys`]. Never set this: it is here only so
/// that a config file still carrying the old key is refused rather than
/// ignored.
#[serde(default, skip_serializing)]
pub enable_index_processing: Option<bool>,

// only allow websocket connections for jsonrpc traffic
#[serde(default)]
Expand Down Expand Up @@ -677,7 +684,7 @@ fn default_authority_store_pruning_config() -> AuthorityStorePruningConfig {
AuthorityStorePruningConfig::default()
}

pub fn default_enable_index_processing() -> bool {
pub fn default_enable_jsonrpc_api() -> bool {
true
}

Expand Down Expand Up @@ -731,6 +738,23 @@ pub fn bool_true() -> bool {
impl Config for NodeConfig {}

impl NodeConfig {
/// Fails if the config file still carries a key that has been renamed.
///
/// Call this before doing any work. A config file is loaded with unknown
/// keys ignored, so an old key left in place has no effect at all, and
/// the node runs on the new key's default instead — which is not
/// necessarily what the old key said.
pub fn check_renamed_keys(&self) -> Result<()> {
if self.enable_index_processing.is_some() {
anyhow::bail!(
"`enable-index-processing` was renamed to `enable-jsonrpc-api` (default true); \
remove the old key and set `enable-jsonrpc-api` to the value you want. Leaving \
the old key in place would serve the JSON-RPC API and rebuild its index."
);
}
Ok(())
}

pub fn authority_key_pair(&self) -> &AuthorityKeyPair {
self.authority_key_pair.authority_keypair()
}
Expand Down Expand Up @@ -1595,6 +1619,36 @@ mod tests {
assert!(config.enable_soft_locking);
}

#[test]
fn renamed_enable_index_processing_key_is_refused() {
const TEMPLATE: &str = include_str!("../data/fullnode-template.yaml");

let mut template: serde_yaml::Value = serde_yaml::from_str(TEMPLATE).unwrap();
template
.as_mapping_mut()
.unwrap()
.insert("enable-index-processing".into(), false.into());

let mut config: NodeConfig = serde_yaml::from_value(template).unwrap();
assert_eq!(config.enable_index_processing, Some(false));
assert!(config.enable_jsonrpc_api);
let err = config.check_renamed_keys().unwrap_err().to_string();
assert!(err.contains("enable-index-processing"), "{err}");
assert!(err.contains("enable-jsonrpc-api"), "{err}");

// The field never reaches a serialized config, so a node that rewrites
// its config cannot reintroduce the key it just refused.
config.enable_index_processing = Some(true);
let serialized = serde_yaml::to_string(&config).unwrap();
assert!(
!serialized.contains("enable-index-processing"),
"{serialized}"
);

let config: NodeConfig = serde_yaml::from_str(TEMPLATE).unwrap();
assert!(config.check_renamed_keys().is_ok());
}

#[test]
fn load_key_pairs_to_node_config() {
let authority_key_pair: AuthorityKeyPair =
Expand Down
6 changes: 4 additions & 2 deletions crates/iota-core/src/authority.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3695,8 +3695,10 @@ impl AuthorityState {
}
}

/// The index store when this node maintains the JSON-RPC group's tables,
/// `None` when it maintains no index at all or only the gRPC group's.
/// The index store when this node maintains the JSON-RPC group's tables.
/// `None` on a validator, and on a fullnode that serves no JSON-RPC —
/// the same flag mounts the JSON-RPC router, so a node that answers a
/// JSON-RPC call always has this.
fn jsonrpc_indexes(&self) -> Option<&Arc<RpcIndexesStore>> {
self.rpc_indexes_store
.as_ref()
Expand Down
1 change: 0 additions & 1 deletion crates/iota-core/src/authority/authority_store_pruner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ static PERIODIC_PRUNING_TABLES: Lazy<BTreeSet<String>> = Lazy::new(|| {
.collect()
});
pub const EPOCH_DURATION_MS_FOR_TESTING: u64 = 24 * 60 * 60 * 1000;
pub const MIN_EPOCHS_TO_RETAIN_FOR_INDEXES: u64 = 7;

/// Maximum number of checkpoints whose data is written in a single pruning
/// `WriteBatch`. Bounds batch memory only; it does not cap total work per run,
Expand Down
4 changes: 3 additions & 1 deletion crates/iota-core/src/rpc_index_history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,9 @@ impl<B> EpochBuckets<B> {

/// Drops the buckets of expired epochs: with `epochs_to_retain` = N, the
/// buckets of the newest N epochs are kept and every older bucket is
/// dropped wholesale.
/// dropped wholesale. `0` keeps the newest bucket, exactly as `1` does,
/// so a caller that clamps its own retention to at least 1 changes
/// nothing here.
///
/// Returns the earliest epoch to retain, `None` when there is no history
/// at all. It is persisted before the drops and never moves backwards,
Expand Down
35 changes: 18 additions & 17 deletions crates/iota-core/src/rpc_indexes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ use self::{
},
};
use crate::{
authority::{AuthorityStore, authority_store_pruner::MIN_EPOCHS_TO_RETAIN_FOR_INDEXES},
authority::AuthorityStore,
checkpoints::CheckpointStore,
index_rebuild_cancellation::{RebuildCancelled, is_cancelled},
par_index_live_object_set::{
Expand Down Expand Up @@ -719,18 +719,6 @@ impl RpcIndexesStore {
})
.unwrap_or(0);

// The pruner never retains fewer epochs than its floor, so the
// backfill must not stop above it either.
let epochs_to_retain = epochs_to_retain.map(|epochs| {
if epochs < MIN_EPOCHS_TO_RETAIN_FOR_INDEXES {
warn!(
"num_epochs_to_retain_for_indexes is below the {MIN_EPOCHS_TO_RETAIN_FOR_INDEXES} \
epoch floor, retaining {MIN_EPOCHS_TO_RETAIN_FOR_INDEXES} epochs instead"
);
}
epochs.max(MIN_EPOCHS_TO_RETAIN_FOR_INDEXES)
});

let store = Arc::new(Self::finish_open(
opened,
registry,
Expand All @@ -746,6 +734,16 @@ impl RpcIndexesStore {

/// Opens the store without the init logic of [`Self::new`] — for tests.
pub fn new_without_init(path: PathBuf, groups: BTreeSet<IndexGroup>) -> Self {
Self::new_without_init_with_retention(path, groups, None)
}

/// [`Self::new_without_init`] with an explicit retention, for tests that
/// exercise pruning without a full node's setup.
pub fn new_without_init_with_retention(
path: PathBuf,
groups: BTreeSet<IndexGroup>,
epochs_to_retain: Option<u64>,
) -> Self {
let opened = Self::open_index_db(&path).expect("unable to open the RPC index database");
Self::finish_open(
opened,
Expand All @@ -754,7 +752,7 @@ impl RpcIndexesStore {
None,
0,
Arc::default(),
None,
epochs_to_retain,
)
.expect("unable to open the RPC index database")
}
Expand Down Expand Up @@ -935,12 +933,15 @@ impl RpcIndexesStore {
Ok(None)
}

/// Drops the history of expired epochs, clamped to
/// [`MIN_EPOCHS_TO_RETAIN_FOR_INDEXES`] — the one pruning entry point,
/// Drops the history of expired epochs — the one pruning entry point,
/// covering every history table, digests included, since they all live
/// in the one bucket family. Returns the earliest epoch to retain,
/// `None` when index pruning is off or there is no history at all.
///
/// The newest epoch's bucket is always kept, whatever the configured
/// retention: [`Self::index_checkpoint`] reads its digests to skip an
/// already-indexed transaction.
///
/// A query racing a drop may report an error for the dropped epoch's
/// rows; a retry no longer sees the bucket. Queries block for the
/// duration of the drops, so callers on an async runtime must use
Expand All @@ -950,7 +951,7 @@ impl RpcIndexesStore {
return Ok(None);
};
self.history
.prune(epochs_to_retain)
.prune(epochs_to_retain.max(1))
.map_err(|e| IotaError::Storage(e.to_string()))
}

Expand Down
66 changes: 66 additions & 0 deletions crates/iota-core/src/unit_tests/rpc_indexes_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,19 @@ fn open_index_store(path: std::path::PathBuf) -> RpcIndexesStore {
)
}

/// Opens an `RpcIndexesStore` at `path` without running the rebuild path,
/// serving every group, with an explicit epoch retention.
fn open_index_store_with_retention(
path: &std::path::Path,
epochs_to_retain: Option<u64>,
) -> RpcIndexesStore {
RpcIndexesStore::new_without_init_with_retention(
path.to_path_buf(),
BTreeSet::from([IndexGroup::JsonRpc, IndexGroup::Grpc]),
epochs_to_retain,
)
}

/// Closes the store's database, waiting until every handle is released
/// so the same path can be reopened.
async fn close_index_store(index_store: impl std::borrow::Borrow<RpcIndexesStore>) {
Expand Down Expand Up @@ -3267,3 +3280,56 @@ async fn test_owner_objects_page_excludes_only_the_cursor() {
"the objects after the cursor must not be lost with the cursor's row"
);
}

/// A retention of one epoch keeps the current epoch's history and drops
/// every older epoch, with no floor raising it.
#[tokio::test]
async fn test_retention_of_one_epoch_keeps_only_the_current_epoch() {
let path = iota_common::tempdir();
let store = open_index_store_with_retention(path.path(), Some(1));

for epoch in 0..4 {
store.ensure_history_bucket(epoch).unwrap();
}
assert_eq!(store.prune().unwrap(), Some(3));
assert_eq!(store.history.earliest_retained(), 3);
}

/// A retention of two epochs keeps the current epoch and the one before it.
#[tokio::test]
async fn test_retention_of_two_epochs_keeps_the_previous_epoch() {
let path = iota_common::tempdir();
let store = open_index_store_with_retention(path.path(), Some(2));

for epoch in 0..4 {
store.ensure_history_bucket(epoch).unwrap();
}
assert_eq!(store.prune().unwrap(), Some(2));
assert_eq!(store.history.earliest_retained(), 2);
}

/// Pruning never drops the newest epoch's bucket, whatever a caller asks
/// for: checkpoint ingest reads its digests to tell an already-indexed
/// transaction from a new one.
#[tokio::test]
async fn test_pruning_keeps_the_newest_bucket_whatever_the_retention() {
let path = iota_common::tempdir();
let store = open_index_store_with_retention(path.path(), Some(0));

for epoch in 0..3 {
store.ensure_history_bucket(epoch).unwrap();
}
assert_eq!(store.prune().unwrap(), Some(2));
assert_eq!(store.history.newest_epoch(), Some(2));

// The bucket ingest depends on is still usable after the prune.
let mut builder = TestCheckpointDataBuilder::new(0)
.with_epoch(2)
.start_transaction(0)
.create_coin_object(0, 1, 100, GAS::type_tag())
.finish_transaction();
let checkpoint = builder.build_checkpoint();
let digest = *checkpoint.transactions[0].effects.transaction_digest();
index_checkpoint_for_testing(&store, &checkpoint);
assert!(store.lookup_digest(&digest).unwrap().is_some());
}
75 changes: 70 additions & 5 deletions crates/iota-e2e-tests/tests/rpc_indexes_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@
//! harness fix first; reopen semantics are pinned by the `rpc_indexes`
//! unit tests instead.

use std::time::Duration;
use std::{num::NonZeroUsize, time::Duration};

use iota_core::authority::authority_store_pruner::MIN_EPOCHS_TO_RETAIN_FOR_INDEXES;
use iota_json_rpc_types::TransactionFilter;
use iota_json_rpc_api::{CoinReadApiClient, IndexerApiClient};
use iota_json_rpc_types::{EventFilter, IotaTransactionBlockResponseQuery, TransactionFilter};
use iota_macros::sim_test;
use iota_sdk::wallet_context::WalletContext;
use iota_sdk_types::{Address, TransactionDigest};
use iota_swarm::memory::Swarm;
use iota_test_transaction_builder::TestTransactionBuilder;
use test_cluster::{TestCluster, TestClusterBuilder};

Expand Down Expand Up @@ -74,20 +75,24 @@ async fn indexes_chain_across_epoch_buckets_on_a_live_node() {
assert_eq!(reverse, vec![digest_epoch_1, digest_epoch_0]);
}

/// Retention this test configures, in epochs. Small enough that the test can
/// advance past it, large enough that recent history survives.
const EPOCHS_TO_RETAIN: u64 = 2;

/// With `num_epochs_to_retain_for_indexes` configured, the pruner drops
/// expired epochs' history on a running node while recent history and the
/// live-state tables keep serving.
#[sim_test]
async fn index_pruning_drops_expired_epochs_on_a_live_node() {
let cluster = TestClusterBuilder::new()
.with_fullnode_num_epochs_to_retain_for_indexes(Some(MIN_EPOCHS_TO_RETAIN_FOR_INDEXES))
.with_fullnode_num_epochs_to_retain_for_indexes(Some(EPOCHS_TO_RETAIN))
.build()
.await;

let (sender, old_digest) = transfer_coin(&cluster.wallet).await;

// One epoch past the retention, so epoch 0 falls out of it.
for _ in 0..=MIN_EPOCHS_TO_RETAIN_FOR_INDEXES {
for _ in 0..=EPOCHS_TO_RETAIN {
cluster.force_new_epoch().await;
}
let (_, recent_digest) = transfer_coin(&cluster.wallet).await;
Expand Down Expand Up @@ -122,3 +127,63 @@ async fn index_pruning_drops_expired_epochs_on_a_live_node() {
"queries must serve the retained epochs only"
);
}

/// A node serving the JSON-RPC API answers every index-backed endpoint,
/// so a client needs no capability probe before using it.
#[sim_test]
async fn jsonrpc_node_serves_every_index_backed_endpoint() {
// The JSON-RPC API is on by default; `TestClusterBuilder` has no knob to
// turn it off, so this exercises that default rather than setting it.
let cluster = TestClusterBuilder::new().build().await;
let address = cluster.get_address_0();
let client = cluster.rpc_client();

client
.get_owned_objects(address, None, None, None)
.await
.expect("getOwnedObjects must be served");
client
.get_coins(address, None, None, None)
.await
.expect("getCoins must be served");
client
.get_balance(address, None)
.await
.expect("getBalance must be served");
client
.get_all_balances(address)
.await
.expect("getAllBalances must be served");
client
.query_transaction_blocks(
IotaTransactionBlockResponseQuery::default(),
None,
Some(1),
Some(false),
)
.await
.expect("queryTransactionBlocks must be served");
client
.query_events(EventFilter::All(vec![]), None, Some(1), Some(false))
.await
.expect("queryEvents must be served");
}

/// A node with the JSON-RPC API off mounts no HTTP server on its JSON-RPC
/// address, the way a node with the gRPC API off serves no gRPC.
#[sim_test]
async fn node_without_jsonrpc_api_mounts_no_http_server() {
let mut swarm = Swarm::builder()
.committee_size(NonZeroUsize::new(1).unwrap())
.with_fullnode_count(1)
.with_fullnode_enable_jsonrpc_api(false)
.build();
swarm.launch().await.unwrap();

let fullnode = swarm.fullnodes().next().unwrap();
let address = fullnode.config().json_rpc_address;
assert!(
tokio::net::TcpStream::connect(address).await.is_err(),
"nothing must listen on the JSON-RPC address when the API is off"
);
}
Loading
Loading