Skip to content

cherrypick data affinity feature to release-8.5#10061

Merged
ti-chi-bot[bot] merged 14 commits into
tikv:release-8.5from
lhy1024:release-8.5-affinity
Dec 19, 2025
Merged

cherrypick data affinity feature to release-8.5#10061
ti-chi-bot[bot] merged 14 commits into
tikv:release-8.5from
lhy1024:release-8.5-affinity

Conversation

@lhy1024
Copy link
Copy Markdown
Member

@lhy1024 lhy1024 commented Dec 14, 2025

Conflict Resolution Details


Commit1: PR #9993 - Affinity Storage & Config

Conflict 1: pkg/slice/slice.go and pkg/slice/slice_test.go

Resolution: Accept both

<<<<<<< HEAD
=======
// HasDupInSorted takes a sorted slice and checks whether it contains any duplicate elements.
func HasDupInSorted[T comparable](sortedSlice []T) bool { ... }
>>>>>>> 12d3952d4

Conflict 2: pkg/storage/storage.go

Resolution: Remove MaintenanceStorage

<<<<<<< HEAD
=======
endpoint.MaintenanceStorage
endpoint.AffinityStorage
>>>>>>> 12d3952d4

Conflict 3: Keypath Architecture ⚠️ Critical

Resolution: Refactor keypath implementation

  • AffinityGroupIDPath returns relative path rather than full path (consistent with RegionLabelKeyPath)
  • LoadAllAffinityGroups uses keypath.AffinityGroupPath+"/" because loadRangeByPrefix will add PDRootPath() prefix

Master branch implementation:

package keypath

import (
	"path"
)

// AffinityGroupIDPath returns the path to save a specific affinity group object.
// Its format is: "/pd/{cluster_id}/affinity_groups/{group_id}"
func AffinityGroupIDPath(groupID string) string {
	return path.Join(PDRootPath(), AffinityGroupPath, groupID)
}

// AffinityGroupsPathPrefix returns the path prefix for all affinity groups.
// Its format is: "/pd/{cluster_id}/affinity_groups/"
// This is used for loading all groups via a range read.
func AffinityGroupsPathPrefix() string {
	return path.Join(PDRootPath(), AffinityGroupPath)
}

Release-8.5 implementation:

// AffinityGroupIDPath returns the path to save a specific affinity group object.
// Its format is: "/pd/{cluster_id}/affinity_groups/{group_id}"
func AffinityGroupIDPath(groupID string) string {
	return path.Join(AffinityGroupPath, groupID)
}

// AffinityGroupsPathPrefix returns the path prefix for all affinity groups.
// Its format is: "/pd/{cluster_id}/affinity_groups/"
// This is used for loading all groups via a range read.
func AffinityGroupsPathPrefix() string {
	return path.Join(PDRootPath(), AffinityGroupPath)
}

I checked it in etcd-ctl. They are the same.
8.5
img_v3_02t4_3b5fb298-a91d-451f-8c26-7b1034e28e9g
master
img_v3_02t4_1d1817b9-2709-40a8-a21e-0e25e7bf879g


Commit2-4: PR #9997, #9998, #9999

✅ No conflicts


Commit5: PR #10038 - Affinity Filter & kvproto

Conflict 1: go.sum and go.mod

Resolution: Update kvproto version


Conflict 2: pkg/schedule/core/cluster_informer.go

Resolution:

  • Kept old signature: AllocID() (uint64, error)
  • Added new method: GetAffinityManager() *affinity.Manager

Conflict 3: pkg/mock/mockcluster/mockcluster.go, pkg/schedule/schedulers/balance_leader_test.go, pkg/schedule/schedulers/balance_region_test.go

Resolution: Kept private field keyRangeManager with getter method


Conflict 4: hot_region_solver.go ⚠️ File Migration (Critical)

Resolution: Migrated affinity filter logic to hot_region.go:622-640

affinityFilter := filter.NewAffinityFilter(bs.SchedulerCluster)
if !affinityFilter.Select(bs.cur.region).IsOK() {
	hotSchedulerIgnoredAffinity.Inc()
	continue
}

Conflict 5: server/server.go:2155-2172

Resolution: Kept both methods

  • GetGlobalTSOAllocator()
  • GetAffinityManager()

Conflict 6: pkg/schedule/labeler/labeler.go:436-445 and pkg/core/store_option.go:101-108

Resolution: Copied function implementation from master


Commit6: PR #10050 - Refactor

✅ No conflicts


Commit7: PR #10041 - API & Client Support

Conflict 1: client/http/api.go and server/apiv2/router.go

Resolution:

  • Only add affinity-related APIs
  • Discard GetKeyspaceMetaByID and RegisterMaintenance

Conflict 2: tests/integrations/client/http_client_test.go

Resolution: Remove unrelated TestGetSiblingsRegions


Conflict 3: tests/server/apiv2/handlers/affinity_test.go

Resolution: Replace test framework functions

Function Type Master (Microservice) Release-8.5 (API Mode)
Both modes RunTest() RunTestBasedOnMode()
PD mode only RunTestInNonMicroserviceEnv() RunTestInPDMode()
MCS/API mode RunTestInMicroserviceEnv() RunTestInAPIMode()

Conflict 4: Naming Convention

Resolution: Use MicroService (capital S) rather than Microservice


Commit8: PR #10040 - Affinity Checker

Conflict 1: pkg/schedule/checker/metrics.go

Incoming changes:

<<<<<<< HEAD
=======
	affinityChecker   = "affinity_checker"
)

const (
	// patrol phases
	phaseWaitForChannel = "wait_for_channel"
	phaseCheckPriority  = "check_priority"
	phaseCheckPending   = "check_pending"
	phaseScanRegions    = "scan_regions"
	phaseUpdateLabel    = "update_label_stats"
)

// checkerControllerMetrics contains pre-created Prometheus metrics for the checker controller.
type checkerControllerMetrics struct {
	patrolPhaseHistograms map[string]prometheus.Observer
	checkRegionHistograms map[string]prometheus.Observer
	patrolRegionChannelSize prometheus.Gauge
}

func newCheckerControllerMetrics() *checkerControllerMetrics { ... }
>>>>>>> 4a0b4051a

Resolution: Only keep affinity-related metrics, discard patrol phases and controller metrics


Conflict 2: pkg/schedule/checker/checker_controller.go

Incoming changes:

<<<<<<< HEAD
	return &Controller{
=======
	ruleManager := cluster.GetRuleManager()
	c := &Controller{
>>>>>>> 4a0b4051a

Resolution: Accept the ruleManager initialization


Conflict 3: Checker Integration - measureChecker wrapper ⚠️ Critical

Incoming changes:

<<<<<<< HEAD
	if c.mergeChecker != nil {
		allowed := opController.OperatorCount(operator.OpMerge) < c.conf.GetMergeScheduleLimit()
		if !allowed {
			operator.IncOperatorLimitCounter(c.mergeChecker.GetType(), operator.OpMerge)
		} else if ops := c.mergeChecker.Check(region); ops != nil {
			// It makes sure that two operators can be added successfully altogether.
			return ops
=======
	if ops := measureChecker(c.metrics.checkRegionHistograms[affinityChecker], func() []*operator.Operator {
		if opController.OperatorCount(operator.OpAffinity) < c.conf.GetAffinityScheduleLimit() {
			// It makes sure that two affinity merge operators can be added successfully altogether.
			return c.affinityChecker.Check(region)
>>>>>>> fdc1cdf23 (schedule: add affinity checker (#10040))

Resolution: Remove measureChecker wrapper, keep release-8.5 style

Final implementation:

// Check affinity checker (after rule/replica checker, before merge checker)
if opController.OperatorCount(operator.OpAffinity) < c.conf.GetAffinityScheduleLimit() {
	// It makes sure that two affinity merge operators can be added successfully altogether.
	if ops := c.affinityChecker.Check(region); len(ops) > 0 {
		return ops
	}
} else {
	operator.IncOperatorLimitCounter(c.affinityChecker.GetType(), operator.OpAffinity)
}

// Keep release-8.5 merge checker style (no measureChecker)
if c.mergeChecker != nil {
	allowed := opController.OperatorCount(operator.OpMerge) < c.conf.GetMergeScheduleLimit()
	if !allowed {
		operator.IncOperatorLimitCounter(c.mergeChecker.GetType(), operator.OpMerge)
	} else if ops := c.mergeChecker.Check(region); ops != nil {
		return ops
	}
}
return nil

Conflict 4: API Method

Resolution: Replace AllowLeaderTransferIn() with AllowLeaderTransfer()


Commit9: PR #10043 - pd-ctl Support

✅ No conflicts


Commit9: PR #10081 - merge fix

replace suite.newRegionInfor with RegionInfo


Commit10: PR #10080 - other fix

Conflict 1: Function Name

Resolution: Replace AllowLeaderTransferIn() with AllowLeaderTransfer()

<<<<<<< HEAD
		case !store.AllowLeaderTransfer() || m.conf.CheckLabelProperty(config.RejectLeader, store.GetLabels()):
			unavailableStores[store.GetID()] = storeEvictLeader
=======
		case !store.AllowLeaderTransferIn() || m.conf.CheckLabelProperty(config.RejectLeader, store.GetLabels()) ||
			store.EvictedAsSlowStore() || store.EvictedAsStoppingStore() || store.IsEvictedAsSlowTrend():
			unavailableStores[store.GetID()] = storeLeaderEvicted
		case store.IsBusy():
			unavailableStores[store.GetID()] = storeBusy
>>>>>>> b53de7a81 (affinity: add scatter filter, add more evict check and avoid statistic miss (#10080))

Conflict 2: Other PR

Resolution: Keep old

<<<<<<< HEAD
	// If there is an old operator, replace it. The priority should be checked
	// already.
	if oldi, ok := oc.operators.Load(regionID); ok {
		old := oldi.(*Operator)
		_ = oc.removeOperatorInner(old)
		_ = old.Replace()
		oc.buryOperator(old)
=======
	old, loaded := oc.operators.LoadOrStore(regionID, op)
	if loaded {
		// If there is an old operator and it has lower priority, replace it
		oldOp := old.(*Operator)
		if !isHigherPriorityOperator(op, oldOp) {
			log.Debug("operator already exists with higher or equal priority",
				zap.Uint64("region-id", regionID),
				zap.Reflect("old", oldOp),
				zap.Reflect("new", op))
			_ = op.Cancel(AlreadyExist)
			oc.buryOperator(op)
			operatorCounter.WithLabelValues(op.Desc(), "redundant").Inc()
			return false
		}
		// replace old operator
		if !oc.operators.CompareAndSwap(regionID, oldOp, op) {
			_ = op.Cancel()
			oc.buryOperator(op)
			log.Debug("operator changed during replace, skip this add",
				zap.Uint64("region-id", regionID),
				zap.Reflect("old", oldOp),
				zap.Reflect("new", op))
			return false
		}
		oc.counts.dec(oldOp.SchedulerKind())
		oc.ack(oldOp)
		if oldOp.HasRelatedMergeRegion() {
			oc.removeRelatedMergeOperator(oldOp)
		}
		_ = oldOp.Replace()
		oc.buryOperator(oldOp)
>>>>>>> b53de7a81 (affinity: add scatter filter, add more evict check and avoid statistic miss (#10080))
<<<<<<< HEAD
=======
	"github.com/tikv/pd/pkg/utils/keyutil"
	"github.com/tikv/pd/pkg/utils/testutil"
>>>>>>> b53de7a81 (affinity: add scatter filter, add more evict check and avoid statistic mis
<<<<<<< HEAD
func (suite *httpClientTestSuite) TestGetSiblingsRegions() {
========
.....
>>>>>>>>

Conflict 3: OpAdmin⚠️ Critical

Keep OpAdmin|OpMerge, it changed to only OpAdmin in master.
But it still be OpAdmin|OpMerge in release 8.5.


Conflict 4: Rename
replace PauseLeaderTransferIn with PauseLeaderTransfer


What problem does this PR solve?

Issue Number: Close #9764

What is changed and how does it work?

Check List

Tests

  • Unit test
  • Integration test
  • Manual test (add detailed scripts or steps below)

Release note

None.

@ti-chi-bot
Copy link
Copy Markdown
Contributor

ti-chi-bot Bot commented Dec 14, 2025

Skipping CI for Draft Pull Request.
If you want CI signal for your change, please convert it to an actual PR.
You can still manually trigger a test run with /test all

@ti-chi-bot
Copy link
Copy Markdown
Contributor

ti-chi-bot Bot commented Dec 14, 2025

This cherry pick PR is for a release branch and has not yet been approved by triage owners.
Adding the do-not-merge/cherry-pick-not-approved label.

To merge this cherry pick:

  1. It must be approved by the approvers firstly.
  2. AFTER it has been approved by approvers, please wait for the cherry-pick merging approval from triage owners.
Details

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository.

@ti-chi-bot ti-chi-bot Bot added release-note-none Denotes a PR that doesn't merit a release note. do-not-merge/work-in-progress Indicates that a PR should not merge because it is a work in progress. do-not-merge/cherry-pick-not-approved dco-signoff: yes Indicates the PR's author has signed the dco. size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files. labels Dec 14, 2025
@codecov
Copy link
Copy Markdown

codecov Bot commented Dec 14, 2025

Codecov Report

❌ Patch coverage is 75.70742% with 455 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.86%. Comparing base (c5362f0) to head (cad0ed5).
⚠️ Report is 2 commits behind head on release-8.5.

Additional details and impacted files
@@               Coverage Diff               @@
##           release-8.5   #10061      +/-   ##
===============================================
- Coverage        77.95%   77.86%   -0.09%     
===============================================
  Files              471      482      +11     
  Lines            63502    65343    +1841     
===============================================
+ Hits             49505    50882    +1377     
- Misses           10370    10725     +355     
- Partials          3627     3736     +109     
Flag Coverage Δ
unittests 77.86% <75.70%> (-0.09%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@lhy1024 lhy1024 force-pushed the release-8.5-affinity branch 6 times, most recently from 7f1d976 to b3248da Compare December 16, 2025 12:52
lhy1024 and others added 7 commits December 16, 2025 20:52
ref tikv#9764

Signed-off-by: lhy1024 <admin@liudos.us>
ref tikv#9764

Signed-off-by: lhy1024 <admin@liudos.us>
ref tikv#9764

Signed-off-by: lhy1024 <admin@liudos.us>

Co-authored-by: 混沌DM <hundundm@gmail.com>
Signed-off-by: lhy1024 <admin@liudos.us>
close tikv#9764

Signed-off-by: lhy1024 <admin@liudos.us>
ref tikv#9764

Signed-off-by: lhy1024 <admin@liudos.us>
@lhy1024 lhy1024 force-pushed the release-8.5-affinity branch from b3248da to ef62a65 Compare December 16, 2025 12:52
@lhy1024 lhy1024 marked this pull request as ready for review December 16, 2025 12:52
@ti-chi-bot ti-chi-bot Bot removed the do-not-merge/work-in-progress Indicates that a PR should not merge because it is a work in progress. label Dec 16, 2025
ref tikv#9764

Signed-off-by: lhy1024 <admin@liudos.us>
ref tikv#9764

Signed-off-by: lhy1024 <admin@liudos.us>
@lhy1024 lhy1024 force-pushed the release-8.5-affinity branch 3 times, most recently from 0467ae4 to cecd033 Compare December 16, 2025 14:33
Comment thread client/http/api.go Outdated
Comment thread pkg/mock/mockcluster/mockcluster.go
Comment thread pkg/slice/slice.go Outdated
Comment thread pkg/slice/slice_test.go Outdated
Comment thread tests/server/apiv2/handlers/affinity_test.go Outdated
Comment thread tests/server/apiv2/handlers/affinity_test.go Outdated
Comment thread client/http/interface.go
// UpdateAffinityGroupPeers updates the leader and voter stores of an affinity group.
UpdateAffinityGroupPeers(ctx context.Context, groupID string, leaderStoreID uint64, voterStoreIDs []uint64) (*AffinityGroupState, error)
// DeleteAffinityGroup deletes an affinity group by group ID.
DeleteAffinityGroup(ctx context.Context, groupID string, force bool) error
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pls note when we should force or not.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should force when the groups contain key range. I think we can add it in another pr.

Comment thread client/http/interface.go
var state AffinityGroupState
err = c.request(ctx, newRequestInfo().
WithName("UpdateAffinityGroupPeers").
WithURI(fmt.Sprintf(AffinityGroupByID, groupID)).
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about extracting a new function, such as WithURI(RegionByID(regionID))?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, But it doesn't affect correctness. I've documented it, and I'll complete them in another PR.

Comment thread client/http/interface.go
return errors.Trace(err)
}
// Use POST with ?delete query parameter for batch deletion
url := AffinityGroups + "?delete"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

}

// Check if region is in an affinity group that doesn't allow regular scheduling
if !r.affinityFilter.Select(region).IsOK() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it returns an error, the client will retry the scatter, but it always fails. So we should return nil for the affinity region

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, I fixed it in #10091 (comment)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Subsystem: "affinity",
Name: "status",
Help: "Status of the affinity manager.",
}, []string{"type"})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to add a group label to check which group has problems.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I worry that there are lots of group metrics. We can query them in SQL
{2AC68D2E-8126-49F4-861A-1FF269E56F1E}

@ti-chi-bot ti-chi-bot Bot added the needs-1-more-lgtm Indicates a PR needs 1 more LGTM. label Dec 19, 2025
defaultReplicaScheduleLimit = 64
defaultMergeScheduleLimit = 8
defaultHotRegionScheduleLimit = 4
defaultAffinityScheduleLimit = 0 // default to disable
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

During normal use, what value will this be set to? In addition, what is the specific function of this parameter? Controlling the rate?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It controls affinity-move-peer and affinity-merge-region. We can use the value from leader-schedule-limit and merge-schedule-limit.

So I think 4 or 8 is ok. It also performed well in local testing.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When defaultAffinityScheduleLimit is 0, it means affinity scheduling is disabled.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When there is already a switch to control the feature, do we still need to set this default to 0?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is safe to set defaultAffinityScheduleLimit to 0. Because we need to cherry pick it to 8.5.5.

And we now use it more in poc scenario. We should avoid to affect other users.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is already a switch to control the feature

We use affinity-schedule-limit as a switch, and there is no other config to control it.

In checker PR, we replace affinity-schedule-enable with affinity-schedule-limit

ref tikv#9764

Signed-off-by: lhy1024 <admin@liudos.us>
// AffinityGroupsPathPrefix returns the path prefix for all affinity groups.
// Its format is: "/pd/{cluster_id}/affinity_groups/"
// This is used for loading all groups via a range read.
func AffinityGroupsPathPrefix() string {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can add this when using it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the furture, we will pick mcs for it. We will use AffinityGroupsPathPrefix in mcs.

I think we could keep it. And it has no any side effects

@lhy1024 lhy1024 requested review from HunDunDM and okJiang December 19, 2025 05:40
Comment thread pkg/core/store_option.go
}

// SetNodeState sets the node state for the store.
func SetNodeState(nodeState metapb.NodeState) StoreCreateOption {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we directly use SetStoreState?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SetStoreState cannot set NodeState_Preparing, and it only be used in test file.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

{6350338C-F518-49C0-89D1-2CF94117429E}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please also add a comment.

@ti-chi-bot ti-chi-bot Bot added lgtm and removed needs-1-more-lgtm Indicates a PR needs 1 more LGTM. labels Dec 19, 2025
@ti-chi-bot
Copy link
Copy Markdown
Contributor

ti-chi-bot Bot commented Dec 19, 2025

[LGTM Timeline notifier]

Timeline:

  • 2025-12-19 03:07:04.740380974 +0000 UTC m=+1787969.554158536: ☑️ agreed by bufferflies.
  • 2025-12-19 07:49:07.044806781 +0000 UTC m=+1804891.858584343: ☑️ agreed by rleungx.

@lhy1024
Copy link
Copy Markdown
Member Author

lhy1024 commented Dec 19, 2025

@okJiang @niubell PTAL

@ti-chi-bot
Copy link
Copy Markdown
Contributor

ti-chi-bot Bot commented Dec 19, 2025

[APPROVALNOTIFIER] This PR is APPROVED

This pull-request has been approved by: bufferflies, HunDunDM, niubell, okJiang, rleungx

The full list of commands accepted by this bot can be found here.

The pull request process is described here

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@ti-chi-bot ti-chi-bot Bot added approved cherry-pick-approved Cherry pick PR approved by release team. and removed do-not-merge/cherry-pick-not-approved labels Dec 19, 2025
@ti-chi-bot ti-chi-bot Bot merged commit 029eb6e into tikv:release-8.5 Dec 19, 2025
28 of 34 checks passed
@ti-chi-bot ti-chi-bot Bot added do-not-merge/cherry-pick-not-approved cherry-pick-approved Cherry pick PR approved by release team. and removed cherry-pick-approved Cherry pick PR approved by release team. do-not-merge/cherry-pick-not-approved labels Mar 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

approved cherry-pick-approved Cherry pick PR approved by release team. dco-signoff: yes Indicates the PR's author has signed the dco. lgtm release-note-none Denotes a PR that doesn't merit a release note. size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants