Skip to content

Contract state not persisted after PUT merge in network mode #1995

@sanity

Description

@sanity

Contract State Not Persisted After PUT Merge

Summary

When a peer receives a PUT request for a contract it already has cached, the updated state is computed correctly but never persisted to the state store. This causes the peer to continue serving stale cached state even after receiving and validating updates from the network.

Environment

  • Freenet version: 0.1.32
  • Mode: freenet network
  • Contract type: WebApp container (River)

Steps to Reproduce

  1. Start Freenet in network mode: freenet network
  2. Publish a contract (e.g., River webapp): fdev publish --code ... contract --webapp-archive ...
  3. Access the contract via HTTP to cache it locally
  4. Republish the same contract with updated state (larger size)
  5. Access the contract again via HTTP

Expected: The updated state (new version) is served
Actual: The old cached state is still served

Root Cause Analysis

Code Location

crates/core/src/contract/executor/runtime.rs:36-316 - upsert_contract_state()

The Bug

When upsert_contract_state is called for an existing contract:

  1. Line 258: Gets current state from state_store

    let current_state = match self.state_store.get(&key).await {
        Ok(s) => s,
        ...
    };
  2. Line 280: Calls attempt_state_update to compute merged state

    let updated_state = match self
        .attempt_state_update(&params, &current_state, &key, &updates)
        .await?
  3. Line 296-306: Validates the updated state and returns result

    match self.runtime.validate_state(&key, &params, &updated_state, &related_contracts) {
        ValidateResult::Valid => {
            if updated_state.as_ref() == current_state.as_ref() {
                Ok(UpsertResult::NoChange)
            } else {
                Ok(UpsertResult::Updated(updated_state))  // ⚠️ State NOT stored!
            }
        }
    }

The updated state is never written back to state_store!

Comparison: Working Path

In contrast, attempt_state_update (line 863-866) does persist the state:

self.state_store
    .update(key, new_state.clone())
    .await
    .map_err(ExecutorError::other)?;

But upsert_contract_state returns the updated state without storing it, relying on the caller to persist it. However, the caller in contract/mod.rs:110-125 just passes the state in a response event without storing it.

Observed Behavior

Debug Log Evidence

[2025-10-26T23:00:16] DEBUG freenet::contract::executor::runtime:
  upserting contract state
  contract: BcfxyjCH4snaknrBoCiqhYc9UFvmiJvhsp5d4L5DuvRa
  state_size: 2218768  ← New incoming state

[2025-10-26T23:00:16] INFO freenet::operations::put:
  Forwarding PUT to target peer
  tx: 01K8HC0G1SM641DAMM7E64RA01
  target_peer: v6MWKgqHiBMNcGtG

[2025-10-26T23:00:26] DEBUG freenet::contract::executor::runtime:
  fetched contract state
  contract: BcfxyjCH4snaknrBoCiqhYc9UFvmiJvhsp5d4L5DuvRa
  state_size: 2134337  ← Old cached state still being served!
  state_hash: 6044f16041b4891bb8f81b0bfaa42f09f4331ec2ade81bd316a30b699ee232b1

The state size shows:

  • Incoming PUT: 2,218,768 bytes (new version)
  • Cached GET: 2,134,337 bytes (old version)

Proposed Fix

Add state persistence to upsert_contract_state when returning Updated:

ValidateResult::Valid => {
    if updated_state.as_ref() == current_state.as_ref() {
        Ok(UpsertResult::NoChange)
    } else {
        // Store the updated state before returning
        self.state_store
            .update(&key, updated_state.clone())
            .await
            .map_err(ExecutorError::other)?;

        Ok(UpsertResult::Updated(updated_state))
    }
}

Alternatively, ensure the caller in contract/mod.rs persists the state after receiving UpsertResult::Updated.

Impact

  • High: Peers continue serving stale contract state even after receiving valid updates
  • Affects all contract types when running in network mode
  • Creates inconsistency between peers in the network
  • Users see old versions of webapps/contracts despite successful publication

Related Code

  • crates/core/src/contract/executor/runtime.rs:36 - upsert_contract_state
  • crates/core/src/contract/executor/runtime.rs:750 - perform_contract_put
  • crates/core/src/contract/mod.rs:110 - Caller that handles UpsertResult
  • crates/core/src/contract/executor/runtime.rs:863 - Working example in attempt_state_update

Metadata

Metadata

Assignees

No one assigned

    Labels

    A-contractsArea: Contract runtime, SDK, and executionA-networkingArea: Networking, ring protocol, peer discoveryE-mediumExperience needed to fix/implement: Medium / intermediateP-highHigh priorityT-bugType: Something is broken

    Type

    No type

    Projects

    Status

    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions