k3s-io/k3s · warning

this server has not yet been promoted from learner to…

Error message

this server has not yet been promoted from learner to voting member

What it means

ETCD.Test() rejects a datastore health check when the member's status reports IsLearner: the server was added via MemberAddAsLearner and has not yet been promoted to a voting member. Learners cannot serve consensus, so the server is not yet a full member; promotion happens automatically once the learner catches up.

Solutions

  1. Treat as transient: wait and retry; promotion is automatic once the learner's raft log catches up.
  2. If stuck, check network/latency to peers and etcd health (member list shows isLearner flag); verify the cluster has quorum to promote.
  3. As last resort remove the stuck learner (etcdctl member remove) and rejoin the node fresh.
Defensive patterns

Strategy: retry

Validate before calling

// Poll member status until promotion before declaring the node ready:
func waitNotLearner(ctx context.Context, cli *clientv3.Client, memberID uint64) error {
	return wait.PollUntilContextCancel(ctx, 5*time.Second, true, func(ctx context.Context) (bool, error) {
		ml, err := cli.MemberList(ctx)
		if err != nil { return false, nil }
		for _, m := range ml.Members {
			if m.ID == memberID { return !m.IsLearner, nil }
		}
		return false, nil
	})
}

Try / catch

if err := etcd.Test(ctx, false); err != nil {
	if strings.Contains(err.Error(), "not yet been promoted from learner") {
		// transient by design: backoff and re-Test; only page a human after N minutes
	}
}

Prevention

When it happens

Trigger: Immediately after adding a new server to an etcd cluster: the joiner sits as learner while syncing the datastore; Test() is called during startup before the catch-up completes; a stalled learner that never catches up never gets promoted.

Common situations: Normal (transient) during server joins, resolving in seconds-to-minutes; a chronically underprovisioned/slow network node stuck as learner; a learner added against a quorum-impaired cluster.

Related errors


AI-assisted analysis of k3s-io/k3s@6ba341e396 (2026-08-15). Data as JSON: /api/errors/8674e9f98ca5d291. Report an issue: GitHub.

Appendix: source

Thrown at pkg/etcd/etcd.go:211

// Test ensures that the local node is a voting member of the target cluster
// and not in maintenance mode due to alarms.
// If it is still a learner or not a part of the cluster, an error is raised.
// If enableMaintenance is true, an attempt will be made to clear alarms.
// Startup defragmentation is delegated to etcd itself via the
// bootstrap-defrag-threshold-megabytes flag.
func (e *ETCD) Test(ctx context.Context, enableMaintenance bool) error {
	if e.config == nil {
		return errors.New("control config not set")
	}
	if e.client == nil {
		return errors.New("etcd datastore is not started")
	}

	status, err := e.status(ctx)
	if err != nil {
		return errors.WithMessage(err, "failed to get etcd status")
	} else if status.IsLearner {
		return errors.New("this server has not yet been promoted from learner to voting member")
	} else if status.Leader == 0 {
		return errorsv3.ErrNoLeader
	}

	logrus.Infof("Connected to etcd v%s - datastore using %d of %d bytes", status.Version, status.DbSizeInUse, status.DbSize)

	if len(status.Errors) > 0 {
		logrus.Warnf("Errors present on etcd cluster: %s", strings.Join(status.Errors, ","))
	}

	if !enableMaintenance {
		return nil
	}

	// clear alarms on this node
	if err := e.clearAlarms(ctx, status.Header.MemberId); err != nil {
		return errors.WithMessage(err, "failed to disarm etcd alarms")
	}

View on GitHub (pinned to 6ba341e396)