Skip to content

Diagnostic Slack

invalid_cursor: a stored cursor replayed after expiry

The sync job is careful. It follows every cursor, it handles rate limits, and when it is interrupted it writes next_cursor to the database so the next run can pick up where it left off. That last part was tested by restarting the job immediately, which worked perfectly.

Six weeks later the job is stopped for a deploy on a Friday and started again on a Monday, and it comes back invalid_cursor on the first call. Nothing was corrupted. The checkpoint is exactly the string Slack handed over. It just is not a checkpoint.

Read-only token Python and Node.js Tests included
A store shelf filled with lots of different items
Photo by Oxana Melis on Unsplash
The short answer

Slack's pagination cursors are opaque and short-lived, and they encode the query as well as the position. That makes them safe to use inside one pagination loop and unsafe for anything else. Persisting one to resume across runs fails in two ways that look identical from the outside: the cursor aged out, or the parameters sent with the continuation are not the parameters that produced it. Changing limit, types or exclude_archived between pages is enough.

So the audit is not of your pagination loop, which may be perfect. It is of the checkpoint records your job persists. The script below reads them, dates them, compares the parameters recorded against the parameters that would be sent now, and then replays each cursor once alongside a control call with no cursor at all. The control is the point: it separates "this cursor is dead" from "this method is broken", which the error string alone will not do. What it prints is the stable key to checkpoint on instead, per method.

The problem in plain words

A cursor looks exactly like a resumable offset. It is a string, it means "carry on from here", and the obvious thing to do with it when a long job is interrupted is to write it down. Every database has somewhere to put a string. The tests pass, because the tests restart the job within seconds of stopping it.

Then the job is stopped for a weekend, or a queue backs up, or a retry fires a day later, and the resume call returns ok: false with invalid_cursor. The natural reading is that something corrupted the value. It did not. Cursors are time-limited by design, and the lifetime is not a number Slack publishes, so there is no timeout to configure and no header that says when it expires. The only durable statement is the one in the documentation: cursors are for the loop that created them.

The second failure has nothing to do with time and produces the same error, which is what makes this genuinely hard to debug. The cursor encodes the query. A job that pages conversations.list with types=public_channel and then, on resume, sends the same cursor with types=public_channel,private_channel because somebody widened a config value, gets invalid_cursor on a cursor that is minutes old. The parameters and the cursor have to match, and nothing in the stored string will tell you what they were unless you stored them alongside it.

Loop followscursorscorrectly, page bypageInterruptednext_cursorwritten downRestart insecondsthe test thatpassedRestart onMondayinvalid_cursorBlamed oncorruptionthe string isintact
The string in the database is intact and it is not a checkpoint. A cursor carries the position and the query together, opaquely, and it was only ever promised to last for the loop that issued it.

Why it happens

A cursor is a continuation token, not an offset. It carries the position and the query together, opaquely. There is no stable meaning to extract from it, no expiry to read out of it, and no way to migrate one to a different parameter set. Treating it as data that can be stored is the whole bug.

The lifetime is unspecified, so it cannot be checked against a clock you control. The honest test is not "is this cursor still valid", which nobody can answer offline. It is "how old is this checkpoint", with a budget you chose. Anything hours old is a finding before it errors, which is the only moment at which this is cheap to fix.

A checkpoint with no timestamp is worse than a stale one. If the record does not say when the cursor was issued, nothing downstream can decide whether to trust it, and the job will keep replaying a value of unknown age forever. The script reports undated records as their own verdict rather than assuming they are fresh.

Parameter drift is invisible and produces the same error as expiry. Storing the parameters next to the cursor costs nothing and turns an unattributable failure into a diff. Without it, a config change three commits away looks exactly like an expired token.

The control call is what makes the finding attributable. Replaying a cursor that fails tells you very little on its own; the method could be broken, the scope could have been removed, the channel could have been archived. One call with the same parameters and no cursor separates those cases in a single request, and it is the difference between "the cursor is dead" and "everything here is dead".

The fix, as a flow

This chain fails one step later than the page size one, and on a restart rather than on a response. The step that goes wrong is the test that passed: restarting a job immediately is exactly the experiment a short-lived token survives. The fix branch has three red rows because three different things produce one error string.

The checkpoint recordage, query, then replayOlder than the budgeta loop it outlivedtypes changed sincethe cursor encodes the queryNo issued_at at allreplayed at an unknown ageControl call failed toothe cursor is not the faultResumed on a ts or an idsurvives the weekend
The control call is what makes any of this attributable. A replay that fails on its own could be a dead cursor, a removed scope or an archived channel; the same request without a cursor separates them.

How to fix it

Find where the job writes its checkpoints

A table, a Redis key, a JSON file on a volume. The script reads a JSON array of records, so export whatever you have into that shape once; the records need a method, a cursor, ideally the parameters it was issued with, and ideally a timestamp.

Date every record before touching the network

staleness_verdict is offline. It sorts records into fresh, aging, expired by age, and undated, against a budget you set. A record from Friday afternoon on a Monday morning is a finding whether or not the replay happens to work.

Diff the parameters against what the job would send now

params_drifted returns the keys that changed. A cursor issued under types=public_channel and replayed under a wider set is dead on arrival, and the diff names the config value somebody widened rather than leaving you with an error string.

Take a control reading with no cursor at all

One GET, same method, same parameters, cursor omitted. If that fails too, the cursor was never the problem and the finding is about scopes or the channel. If it succeeds and the replay fails, the cursor is confirmed dead and the attribution is exact.

Replay the stored cursor once and classify it

Once. A cursor that returned invalid_cursor will keep doing so, and retrying it is how a resumable job turns one dead checkpoint into a rate limit. The verdict is cursor-rejected, accepted, or an error that belongs to something else.

Replace the checkpoint with a key that means something

The printed repair is per method: the ts of the newest message you stored, passed as oldest on the next run for the history methods; the last ID you processed for the list methods, restarting pagination from the beginning. Both survive a weekend, a deploy, and a change to types.

How to check it worked

Re-run after the job checkpoints on a stable key. The checkpoint file should hold no cursors at all, which the script reports as nothing to audit.

python3 slack_cursor_checkpoint_audit.py checkpoints.json --max-age 3600
# conversations.list   age      expired-by-age   issued 261,143s ago, over the 3600s budget
# conversations.list   params   drifted          types changed since the cursor was issued
# conversations.list   control  reachable        the same call without a cursor answered ok
# conversations.list   replay   cursor-rejected  invalid_cursor: the stored value is dead
#   repair: checkpoint the last channel ID you processed and restart pagination
# 1 checkpoint(s) that will not resume

The full code

Four pure functions and two GETs per record. staleness_verdict and params_drifted run with no token at all, which matters because most of these findings are visible in the checkpoint file alone. replay_verdict keeps a dead cursor apart from a broken call, and checkpoint_repair answers the only question the reader actually has, which is what to store instead.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 108 Slack fixes, free and open source.
slack_cursor_checkpoint_audit.py
"""Audit the pagination cursors a resumable Slack job persisted.

Read only. For each stored checkpoint: date it, diff the parameters it was
issued under against the ones that would be sent now, take one control reading
with no cursor, and replay the cursor exactly once. Nothing is written and no
checkpoint is repaired; the key to store instead is printed per method.

The checkpoint file is a JSON array of records:

  [{"method": "conversations.list",
    "cursor": "dGVhbTpDMDYxRkE1UEI=",
    "issued_at": 1756400000,
    "params": {"types": "public_channel", "limit": "200"}}]
"""
import argparse
import json
import logging
import os
import sys
import time

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_cursor_checkpoint_audit")

API = "https://slack.com/api/"

# What to checkpoint instead, per method. Every one of these is a value that
# still means the same thing after a weekend, a deploy and a config change,
# which is the whole property a cursor does not have.
STABLE_KEYS = {
    "conversations.history": "the ts of the newest message you stored, sent as "
                             "oldest on the next run",
    "conversations.replies": "the thread ts plus the ts of the last reply you "
                             "stored, sent as oldest",
    "conversations.list": "the last channel ID you processed, restarting "
                          "pagination from the beginning and skipping past it",
    "users.list": "the last user ID you processed, restarting pagination from "
                  "the beginning",
    "users.conversations": "the last channel ID you processed, restarting "
                           "pagination from the beginning",
    "conversations.members": "the last member ID you processed, restarting "
                             "pagination from the beginning",
    "files.list": "the ts_to of the window you finished, used as the next run's "
                  "ts_from",
}

# Parameters that are part of the query a cursor encodes. Changing any of them
# between the call that issued the cursor and the call that replays it
# invalidates the cursor, and produces exactly the error an expired one does.
QUERY_KEYS = ("types", "exclude_archived", "exclude_members", "limit", "channel",
              "user", "oldest", "latest", "inclusive", "team_id")


def staleness_verdict(issued_at, now=None, max_age=3600):
    """Date one checkpoint. Pure, offline, and the cheapest check here.

    Slack does not publish how long a cursor lives, so this cannot answer "is
    it still valid". It answers "how old is this", against a budget you chose,
    which is the question that is actually decidable.
    """
    when = time.time() if now is None else float(now)
    if issued_at in (None, "", 0):
        return ("undated",
                "the record does not say when the cursor was issued, so nothing "
                "downstream can decide whether to trust it. An undated checkpoint "
                "is replayed forever at an unknown age.")
    try:
        age = when - float(issued_at)
    except (TypeError, ValueError):
        return ("undated",
                "the issued_at value %r is not a timestamp, so this record is "
                "effectively undated." % issued_at)

    if age < 0:
        return ("undated",
                "the checkpoint is dated %.0fs in the future, so the clock that "
                "wrote it and the clock reading it disagree. Treat the age as "
                "unknown." % -age)
    budget = float(max_age)
    if age > budget:
        return ("expired-by-age",
                "issued %.0fs ago, over the %.0fs budget. Cursors are scoped to "
                "the loop that created them and this one has outlived any loop."
                % (age, budget))
    if age > budget / 2:
        return ("aging",
                "issued %.0fs ago, over half the %.0fs budget. It may still work "
                "and it is not something to depend on." % (age, budget))
    return ("fresh", "issued %.0fs ago, inside the %.0fs budget" % (age, budget))


def params_drifted(issued_with, sending_now, keys=QUERY_KEYS):
    """The query keys that changed since the cursor was issued. Pure.

    A cursor encodes the query as well as the position, so a widened types
    value invalidates a cursor that is thirty seconds old, with the same error
    an expired one gives. Storing the parameters is what turns that into a diff.
    """
    was = dict(issued_with or {})
    now = dict(sending_now or {})
    out = []
    for key in keys:
        a = was.get(key)
        b = now.get(key)
        if a is None and b is None:
            continue
        if str(a) != str(b):
            out.append(key)
    return sorted(out)


def replay_verdict(ok, error=None, control_ok=True):
    """Classify one replay, with the control reading beside it. Pure.

    The control is the whole reason this can attribute anything. A failed
    replay on its own is ambiguous between a dead cursor, a removed scope and
    an archived channel; the same call without a cursor separates them.
    """
    err = str(error or "").strip()
    if not control_ok:
        return ("call-unreachable",
                "the same call without a cursor also failed%s, so the cursor is "
                "not what is wrong here. Fix the call first."
                % (" with %s" % err if err else ""))
    if ok is True:
        return ("accepted",
                "the stored cursor was still valid at the moment it was replayed. "
                "That is luck rather than a design: nothing about it will be true "
                "tomorrow.")
    if err == "invalid_cursor":
        return ("cursor-rejected",
                "invalid_cursor: the stored value is dead. Age and parameter "
                "drift both produce this and the checks above say which.")
    if err:
        return ("other-error",
                "the replay failed with %s, which is not a cursor problem. The "
                "control call succeeded, so something about this specific "
                "continuation is different." % err)
    return ("inconclusive", "no result and no error was recorded for this replay")


def checkpoint_repair(method):
    """What to store instead of a cursor, for this method. Pure."""
    stable = STABLE_KEYS.get(str(method or ""))
    if stable is None:
        return ("checkpoint on a stable, meaningful key from the items "
                "themselves rather than on the cursor: the last ID or timestamp "
                "you finished with, restarting pagination from the beginning")
    return "checkpoint %s" % stable


def read_checkpoints(path):
    """The stored records. Read only, and the only file this script opens."""
    with open(path, "r", encoding="utf-8") as fh:
        records = json.load(fh)
    if isinstance(records, dict):
        records = [records]
    return [r for r in records if isinstance(r, dict)]


def call(session, method, params):
    """One GET. Returns (ok, error)."""
    body = session.get(API + method, params=params, timeout=30).json()
    return (body.get("ok") is True, body.get("error"))


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("checkpoints", help="JSON file of stored cursor records")
    ap.add_argument("--token-env", default="SLACK_BOT_TOKEN",
                    help="environment variable holding the bot token")
    ap.add_argument("--max-age", type=float, default=3600,
                    help="seconds a checkpoint may be old before it is a finding")
    ap.add_argument("--offline", action="store_true",
                    help="date and diff the records without replaying anything")
    args = ap.parse_args()

    records = read_checkpoints(args.checkpoints)
    if not records:
        log.info("no cursors stored, which is the state this note is arguing for")
        return 0

    session = None
    if not args.offline:
        token = os.environ.get(args.token_env)
        if not token:
            log.error("set %s, or pass --offline to audit the file alone",
                      args.token_env)
            return 2
        session = requests.Session()
        session.headers.update({"Authorization": "Bearer " + token})

    bad = 0
    for record in records:
        method = str(record.get("method") or "?")
        issued_with = record.get("params") or {}
        sending_now = record.get("params_now") or issued_with
        cursor = str(record.get("cursor") or "")
        failed = False

        verdict, detail = staleness_verdict(record.get("issued_at"), None, args.max_age)
        (log.info if verdict == "fresh" else log.warning)(
            "%-20s %-8s %-16s %s", method, "age", verdict, detail)
        failed = failed or verdict != "fresh"

        drift = params_drifted(issued_with, sending_now)
        if drift:
            log.warning("%-20s %-8s %-16s %s changed since the cursor was issued",
                        method, "params", "drifted", ", ".join(drift))
            failed = True
        else:
            log.info("%-20s %-8s %-16s the query is unchanged",
                     method, "params", "stable")

        if session is not None and cursor:
            control_ok, control_err = call(session, method, dict(sending_now))
            log.info("%-20s %-8s %-16s %s", method, "control",
                     "reachable" if control_ok else "unreachable",
                     "the same call without a cursor answered ok" if control_ok
                     else "the same call without a cursor failed with %s" % control_err)
            ok, err = call(session, method, dict(sending_now, cursor=cursor))
            rv, rd = replay_verdict(ok, err, control_ok)
            (log.info if rv == "accepted" else log.warning)(
                "%-20s %-8s %-16s %s", method, "replay", rv, rd)
            failed = failed or rv != "accepted"

        if failed:
            bad += 1
            log.warning("  repair: %s", checkpoint_repair(method))
            log.warning("  repair: keep cursors in memory for the life of one "
                        "pagination loop and never write one down")

    log.info("%d checkpoint(s) that will not resume, out of %d",
             bad, len(records))
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
slack-cursor-checkpoint-audit.mjs
/**
 * Audit the pagination cursors a resumable Slack job persisted.
 *
 * Read only. For each stored checkpoint: date it, diff the parameters it was
 * issued under against the ones that would be sent now, take one control
 * reading with no cursor, and replay the cursor exactly once. Nothing is
 * written and no checkpoint is repaired; the key to store instead is printed.
 */

import { readFile } from 'node:fs/promises';

const API = 'https://slack.com/api/';

// What to checkpoint instead, per method. Every one of these still means the
// same thing after a weekend, a deploy and a config change.
const STABLE_KEYS = new Map([
  ['conversations.history',
    'the ts of the newest message you stored, sent as oldest on the next run'],
  ['conversations.replies',
    'the thread ts plus the ts of the last reply you stored, sent as oldest'],
  ['conversations.list',
    'the last channel ID you processed, restarting pagination from the beginning ' +
    'and skipping past it'],
  ['users.list',
    'the last user ID you processed, restarting pagination from the beginning'],
  ['users.conversations',
    'the last channel ID you processed, restarting pagination from the beginning'],
  ['conversations.members',
    'the last member ID you processed, restarting pagination from the beginning'],
  ['files.list',
    "the ts_to of the window you finished, used as the next run's ts_from"],
]);

// Parameters that are part of the query a cursor encodes. Changing any of them
// invalidates the cursor, with exactly the error an expired one gives.
const QUERY_KEYS = ['types', 'exclude_archived', 'exclude_members', 'limit',
  'channel', 'user', 'oldest', 'latest', 'inclusive', 'team_id'];

/**
 * Date one checkpoint. Pure, offline, and the cheapest check here.
 * Slack does not publish how long a cursor lives, so this answers "how old is
 * this" against a budget you chose, which is the decidable question.
 */
export function stalenessVerdict(issuedAt, now = null, maxAge = 3600) {
  const when = now === null ? Date.now() / 1000 : Number(now);
  if (issuedAt === null || issuedAt === undefined || issuedAt === '' || issuedAt === 0) {
    return ['undated',
      'the record does not say when the cursor was issued, so nothing downstream ' +
      'can decide whether to trust it. An undated checkpoint is replayed forever ' +
      'at an unknown age.'];
  }
  const stamp = Number(issuedAt);
  if (!Number.isFinite(stamp)) {
    return ['undated',
      `the issued_at value ${issuedAt} is not a timestamp, so this record is ` +
      'effectively undated.'];
  }

  const age = when - stamp;
  if (age < 0) {
    return ['undated',
      `the checkpoint is dated ${(-age).toFixed(0)}s in the future, so the clock ` +
      'that wrote it and the clock reading it disagree. Treat the age as unknown.'];
  }
  const budget = Number(maxAge);
  if (age > budget) {
    return ['expired-by-age',
      `issued ${age.toFixed(0)}s ago, over the ${budget.toFixed(0)}s budget. ` +
      'Cursors are scoped to the loop that created them and this one has ' +
      'outlived any loop.'];
  }
  if (age > budget / 2) {
    return ['aging',
      `issued ${age.toFixed(0)}s ago, over half the ${budget.toFixed(0)}s budget. ` +
      'It may still work and it is not something to depend on.'];
  }
  return ['fresh', `issued ${age.toFixed(0)}s ago, inside the ${budget.toFixed(0)}s budget`];
}

/**
 * The query keys that changed since the cursor was issued. Pure.
 * A widened types value invalidates a cursor that is thirty seconds old, with
 * the same error an expired one gives.
 */
export function paramsDrifted(issuedWith, sendingNow, keys = QUERY_KEYS) {
  const was = { ...(issuedWith ?? {}) };
  const now = { ...(sendingNow ?? {}) };
  const out = [];
  for (const key of keys) {
    const a = was[key];
    const b = now[key];
    if ((a === undefined || a === null) && (b === undefined || b === null)) continue;
    if (String(a) !== String(b)) out.push(key);
  }
  return out.sort();
}

/**
 * Classify one replay, with the control reading beside it. Pure.
 * A failed replay on its own is ambiguous between a dead cursor, a removed
 * scope and an archived channel; the control call separates them.
 */
export function replayVerdict(ok, error = null, controlOk = true) {
  const err = String(error ?? '').trim();
  if (!controlOk) {
    return ['call-unreachable',
      'the same call without a cursor also failed' + (err ? ` with ${err}` : '') +
      ', so the cursor is not what is wrong here. Fix the call first.'];
  }
  if (ok === true) {
    return ['accepted',
      'the stored cursor was still valid at the moment it was replayed. That is ' +
      'luck rather than a design: nothing about it will be true tomorrow.'];
  }
  if (err === 'invalid_cursor') {
    return ['cursor-rejected',
      'invalid_cursor: the stored value is dead. Age and parameter drift both ' +
      'produce this and the checks above say which.'];
  }
  if (err) {
    return ['other-error',
      `the replay failed with ${err}, which is not a cursor problem. The control ` +
      'call succeeded, so something about this specific continuation is different.'];
  }
  return ['inconclusive', 'no result and no error was recorded for this replay'];
}

/** What to store instead of a cursor, for this method. Pure. */
export function checkpointRepair(method) {
  const stable = STABLE_KEYS.get(String(method ?? ''));
  if (stable === undefined) {
    return 'checkpoint on a stable, meaningful key from the items themselves ' +
      'rather than on the cursor: the last ID or timestamp you finished with, ' +
      'restarting pagination from the beginning';
  }
  return `checkpoint ${stable}`;
}

async function call(token, method, params) {
  const res = await fetch(`${API}${method}?${new URLSearchParams(params)}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  const body = await res.json();
  return [body.ok === true, body.error];
}

function arg(args, name, fallback = null) {
  const i = args.indexOf(name);
  return i === -1 ? fallback : args[i + 1];
}

async function main() {
  const args = process.argv.slice(2);
  const path = args.find((a) => !a.startsWith('--')
    && args[args.indexOf(a) - 1]?.startsWith('--') !== true);
  if (!path) {
    console.error('usage: <checkpoints.json> [--max-age 3600] [--offline]');
    process.exitCode = 2;
    return;
  }
  const maxAge = Number(arg(args, '--max-age', 3600));
  const offline = args.includes('--offline');

  const records = JSON.parse(await readFile(path, 'utf-8'));
  const list = (Array.isArray(records) ? records : [records])
    .filter((r) => r && typeof r === 'object');
  if (list.length === 0) {
    console.log('no cursors stored, which is the state this note is arguing for');
    return;
  }

  let token = null;
  if (!offline) {
    const tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
    token = process.env[tokenEnv];
    if (!token) {
      console.error(`set ${tokenEnv}, or pass --offline to audit the file alone`);
      process.exitCode = 2;
      return;
    }
  }

  let bad = 0;
  for (const record of list) {
    const method = String(record.method ?? '?');
    const issuedWith = record.params ?? {};
    const sendingNow = record.params_now ?? issuedWith;
    const cursor = String(record.cursor ?? '');
    let failed = false;

    const [verdict, detail] = stalenessVerdict(record.issued_at, null, maxAge);
    (verdict === 'fresh' ? console.log : console.warn)(
      `${method.padEnd(20)} ${'age'.padEnd(8)} ${verdict.padEnd(16)} ${detail}`);
    failed = failed || verdict !== 'fresh';

    const drift = paramsDrifted(issuedWith, sendingNow);
    if (drift.length > 0) {
      console.warn(`${method.padEnd(20)} ${'params'.padEnd(8)} ${'drifted'.padEnd(16)} ` +
        `${drift.join(', ')} changed since the cursor was issued`);
      failed = true;
    } else {
      console.log(`${method.padEnd(20)} ${'params'.padEnd(8)} ${'stable'.padEnd(16)} ` +
        'the query is unchanged');
    }

    if (token !== null && cursor) {
      const [controlOk, controlErr] = await call(token, method, { ...sendingNow });
      console.log(`${method.padEnd(20)} ${'control'.padEnd(8)} ` +
        `${(controlOk ? 'reachable' : 'unreachable').padEnd(16)} ` +
        (controlOk ? 'the same call without a cursor answered ok'
          : `the same call without a cursor failed with ${controlErr}`));
      const [ok, err] = await call(token, method, { ...sendingNow, cursor });
      const [rv, rd] = replayVerdict(ok, err, controlOk);
      (rv === 'accepted' ? console.log : console.warn)(
        `${method.padEnd(20)} ${'replay'.padEnd(8)} ${rv.padEnd(16)} ${rd}`);
      failed = failed || rv !== 'accepted';
    }

    if (failed) {
      bad += 1;
      console.warn(`  repair: ${checkpointRepair(method)}`);
      console.warn('  repair: keep cursors in memory for the life of one ' +
        'pagination loop and never write one down');
    }
  }

  console.log(`${bad} checkpoint(s) that will not resume, out of ${list.length}`);
  process.exitCode = bad ? 1 : 0;
}

// Only run when invoked directly, so importing this module in the tests does
// not execute main() and fail the file on a missing checkpoint path.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

Every test passes an explicit now, so the clock is a parameter rather than an ambient fact and a suite that runs at midnight behaves like one that runs at noon. The cases that matter are the two that produce the same error for different reasons: a checkpoint from Friday replayed on Monday, and a cursor thirty seconds old replayed after somebody widened types. The last test is the one that stops the script overclaiming: when the control call fails too, the verdict is that the cursor was never the problem.

test_slack_cursor_checkpoint_audit.py
from slack_cursor_checkpoint_audit import (checkpoint_repair, params_drifted,
                                            replay_verdict, staleness_verdict)

NOW = 1756400000.0
ISSUED = {"types": "public_channel", "exclude_archived": "true", "limit": "200"}


def test_a_checkpoint_from_friday_read_on_monday_is_expired():
    verdict, detail = staleness_verdict(NOW - 261143, NOW, 3600)
    assert verdict == "expired-by-age"
    assert "261143s ago" in detail


def test_a_checkpoint_inside_the_budget_is_fresh():
    assert staleness_verdict(NOW - 60, NOW, 3600)[0] == "fresh"


def test_past_half_the_budget_is_flagged_before_it_fails():
    assert staleness_verdict(NOW - 2000, NOW, 3600)[0] == "aging"


def test_a_record_with_no_timestamp_is_its_own_verdict():
    assert staleness_verdict(None, NOW, 3600)[0] == "undated"
    assert staleness_verdict("", NOW, 3600)[0] == "undated"
    assert staleness_verdict("last tuesday", NOW, 3600)[0] == "undated"


def test_a_checkpoint_dated_in_the_future_is_not_called_fresh():
    verdict, detail = staleness_verdict(NOW + 900, NOW, 3600)
    assert verdict == "undated"
    assert "disagree" in detail


def test_an_unchanged_query_drifts_in_no_keys():
    assert params_drifted(ISSUED, dict(ISSUED)) == []


def test_widening_types_is_the_drift_that_kills_a_fresh_cursor():
    now = dict(ISSUED, types="public_channel,private_channel")
    assert params_drifted(ISSUED, now) == ["types"]


def test_every_query_key_that_moved_is_named_and_sorted():
    now = dict(ISSUED, types="private_channel", limit="1000")
    assert params_drifted(ISSUED, now) == ["limit", "types"]


def test_a_key_added_or_removed_counts_as_drift():
    assert params_drifted(ISSUED, dict(ISSUED, oldest="1756300000.0")) == ["oldest"]
    without = {k: v for k, v in ISSUED.items() if k != "limit"}
    assert params_drifted(ISSUED, without) == ["limit"]


def test_the_dead_cursor_is_named_when_the_control_call_worked():
    verdict, detail = replay_verdict(False, "invalid_cursor", control_ok=True)
    assert verdict == "cursor-rejected"
    assert "invalid_cursor" in detail


def test_a_replay_that_still_works_is_called_luck_rather_than_a_design():
    verdict, detail = replay_verdict(True, None, control_ok=True)
    assert verdict == "accepted"
    assert "luck" in detail


def test_when_the_control_call_fails_the_cursor_is_not_blamed():
    verdict, detail = replay_verdict(False, "missing_scope", control_ok=False)
    assert verdict == "call-unreachable"
    assert "not what is wrong here" in detail


def test_an_unrelated_error_is_not_dressed_up_as_a_cursor_problem():
    assert replay_verdict(False, "channel_not_found", True)[0] == "other-error"
    assert replay_verdict(False, None, True)[0] == "inconclusive"


def test_the_repair_is_a_stable_key_and_it_differs_by_method():
    assert "oldest" in checkpoint_repair("conversations.history")
    assert "channel ID" in checkpoint_repair("conversations.list")
    assert "stable, meaningful key" in checkpoint_repair("some.future.method")
slack-cursor-checkpoint-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { checkpointRepair, paramsDrifted, replayVerdict, stalenessVerdict }
  from './slack-cursor-checkpoint-audit.mjs';

const NOW = 1756400000.0;
const ISSUED = { types: 'public_channel', exclude_archived: 'true', limit: '200' };

test('a checkpoint from friday read on monday is expired', () => {
  const [verdict, detail] = stalenessVerdict(NOW - 261143, NOW, 3600);
  assert.equal(verdict, 'expired-by-age');
  assert.match(detail, /261143s ago/);
});

test('a checkpoint inside the budget is fresh', () => {
  assert.equal(stalenessVerdict(NOW - 60, NOW, 3600)[0], 'fresh');
});

test('past half the budget is flagged before it fails', () => {
  assert.equal(stalenessVerdict(NOW - 2000, NOW, 3600)[0], 'aging');
});

test('a record with no timestamp is its own verdict', () => {
  assert.equal(stalenessVerdict(null, NOW, 3600)[0], 'undated');
  assert.equal(stalenessVerdict('', NOW, 3600)[0], 'undated');
  assert.equal(stalenessVerdict('last tuesday', NOW, 3600)[0], 'undated');
});

test('a checkpoint dated in the future is not called fresh', () => {
  const [verdict, detail] = stalenessVerdict(NOW + 900, NOW, 3600);
  assert.equal(verdict, 'undated');
  assert.match(detail, /disagree/);
});

test('an unchanged query drifts in no keys', () => {
  assert.deepEqual(paramsDrifted(ISSUED, { ...ISSUED }), []);
});

test('widening types is the drift that kills a fresh cursor', () => {
  assert.deepEqual(paramsDrifted(ISSUED,
    { ...ISSUED, types: 'public_channel,private_channel' }), ['types']);
});

test('every query key that moved is named and sorted', () => {
  assert.deepEqual(paramsDrifted(ISSUED,
    { ...ISSUED, types: 'private_channel', limit: '1000' }), ['limit', 'types']);
});

test('a key added or removed counts as drift', () => {
  assert.deepEqual(paramsDrifted(ISSUED, { ...ISSUED, oldest: '1756300000.0' }),
    ['oldest']);
  const without = { ...ISSUED };
  delete without.limit;
  assert.deepEqual(paramsDrifted(ISSUED, without), ['limit']);
});

test('the dead cursor is named when the control call worked', () => {
  const [verdict, detail] = replayVerdict(false, 'invalid_cursor', true);
  assert.equal(verdict, 'cursor-rejected');
  assert.match(detail, /invalid_cursor/);
});

test('a replay that still works is called luck rather than a design', () => {
  const [verdict, detail] = replayVerdict(true, null, true);
  assert.equal(verdict, 'accepted');
  assert.match(detail, /luck/);
});

test('when the control call fails the cursor is not blamed', () => {
  const [verdict, detail] = replayVerdict(false, 'missing_scope', false);
  assert.equal(verdict, 'call-unreachable');
  assert.match(detail, /not what is wrong here/);
});

test('an unrelated error is not dressed up as a cursor problem', () => {
  assert.equal(replayVerdict(false, 'channel_not_found', true)[0], 'other-error');
  assert.equal(replayVerdict(false, null, true)[0], 'inconclusive');
});

test('the repair is a stable key and it differs by method', () => {
  assert.match(checkpointRepair('conversations.history'), /oldest/);
  assert.match(checkpointRepair('conversations.list'), /channel ID/);
  assert.match(checkpointRepair('some.future.method'), /stable, meaningful key/);
});

FAQ

How long does a Slack pagination cursor live?

Slack does not publish a number, and that is the answer rather than an omission. The documented contract is that a cursor continues one pagination loop, so any design that depends on a specific lifetime is depending on something nobody has promised. The script dates checkpoints against a budget you set because that is the only version of the question that can be answered.

Can I store the cursor if I refresh it often enough?

You can, and it will still break on the day the parameters change, because a cursor encodes the query as well as the position. Widening types, switching exclude_archived, or raising limit between the call that issued the cursor and the call that replays it produces invalid_cursor on a cursor that is seconds old. Frequency does not help with that failure at all.

What should a resumable sync checkpoint instead?

Something that still means what it meant last week. For conversations.history and conversations.replies that is the ts of the newest message you stored, passed as oldest on the next run. For the list methods it is the last ID you processed, restarting pagination from the beginning and skipping past it. Both survive a weekend and a config change.

Why does the script make a call without the cursor first?

Because a failed replay on its own does not attribute anything. The cursor might be dead, or the scope might have been removed, or the channel might have been archived since the job last ran. One control request with the same parameters and no cursor tells those apart, and it turns invalid_cursor from a symptom into a conclusion.

The replay succeeded. Is the checkpoint fine?

No, it is fresh. The script says so in those words on purpose: an accepted replay means the cursor had not expired at the instant it was tried, which is a fact about this minute rather than a property of the design. The finding to act on is that a cursor is being persisted at all, and that is true whether or not today's replay worked.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.