neondatabase/neon · error · TimeTravelError

Received ListVersions response for key={key} with version_id

Error message

Received ListVersions response for key={key} with version_id='null', indicating either disabled versioning, or legacy objects with null version id values

What it means

GCS port of the same time-travel guard as the Azure backend: while grouping listed versions per key, a version whose id is the literal string \"null\" aborts the listing. GCS identifies versions by numeric generations, so a \"null\" id signals a non-versioned/legacy situation — data imported with S3-style 'null' version markers or synthetic ids from migration tooling — which time travel cannot address.

Source

Thrown at libs/remote_storage/src/gcs_bucket.rs:1232

       tracing::info!(
           "Built list for time travel with {} versions and deletions",
           versions_and_deletes.len()
       );

       // Work on the list of references instead of the objects directly,
       // otherwise we get lifetime errors in the sort_by_key call below.
       let mut versions_and_deletes = versions_and_deletes.iter().collect::<Vec<_>>();

       versions_and_deletes.sort_by_key(|vd| (&vd.key, &vd.last_modified));

       let mut vds_for_key = HashMap::<_, Vec<_>>::new();

       for vd in &versions_and_deletes {
           let GCSVersion { key, .. } = &vd;
           if Some(vd.id.0.as_str()) == Some("null") {
               // TODO: check the behavior of using the SDK on a non-versioned container
               return Err(TimeTravelError::Other(anyhow::anyhow!(
                   "Received ListVersions response for key={key} with version_id='null', \
                   indicating either disabled versioning, or legacy objects with null version id values"
               )));
           }
           tracing::trace!("Parsing version key={key} id={:?}", vd.id);
           vds_for_key.entry(key).or_default().push(vd);
       }

       let warn_threshold = 3;
       let max_retries = 10;
       let is_permanent = |e: &_| matches!(e, TimeTravelError::Cancelled);

       for (key, versions) in vds_for_key {
           let last_vd = versions.last().unwrap();
           let key = self.relative_path_to_gcs_object(key);
           if last_vd.last_modified > done_if_after {
               /// Case 1: we have a recent object outside of our restore window.
               tracing::trace!("Key {key} has version later than done_if_after, skipping");
               continue;

View on GitHub (pinned to 8f60b04da4)

Solutions

  1. Identify the offending keys (the error names the key) and rewrite those objects through normal GCS writes so they carry real generations
  2. Re-run the migration/import step that produced 'null' version ids, dropping the null markers
  3. Verify listing tooling maps GCS generations directly (they are numeric strings) instead of S3 null-version semantics
  4. Route non-versioned legacy paths away from time-travel APIs
Defensive patterns

Strategy: validation

Validate before calling

// Before time travel, scan one page of versions for 'null' ids and fail with guidance.
async fn assert_gcs_generations(storage: &Arc<GenericRemoteStorage>, key: &RemotePath, cancel: &CancellationToken) -> anyhow::Result<()> {
    let versions = storage.list_versions(key, Some(MaxKeys::new(100)), cancel).await
        .map_err(|e| anyhow::anyhow!("listing failed: {e:#}"))?;
    let null_ids = versions.versions.iter().filter(|v| v.id.0 == "null").count();
    anyhow::ensure!(null_ids == 0,
        "{null_ids} versions of {key} carry a 'null' id (migrated/legacy data); rewrite them via normal GCS writes");
    Ok(())
}

Try / catch

// Detect the family and report the remediation.
let versions = match storage.list_versions(key, None, &cancel).await {
    Ok(v) => v,
    Err(TimeTravelError::Other(e)) if format!("{e:#}").contains("version_id='null'") => {
        anyhow::bail!("'null' version id found (legacy/migrated data without generations); rewrite affected objects before time travel");
    }
    Err(e) => return Err(e.into()),
};

Prevention

When it happens

Trigger: list_versions / time travel on a GCS bucket containing objects whose generation was recorded as the string \"null\" — typically data migrated from S3-style stores or written by tooling that emits 'null' version ids for non-versioned objects.

Common situations: Buckets populated by S3-to-GCS migration tools; test fixtures with hand-written version ids; time travel pointed at data written outside the normal GCS write path.

Related errors


AI-assisted analysis of neondatabase/neon@8f60b04da4 (2026-08-16). Data as JSON: /api/errors/27314601acd40f5f. Report an issue: GitHub.