github.com/juju/juju@v0.0.0-20240327075706-a90865de2538/worker/peergrouper/desired.go (about) 1 // Copyright 2014 Canonical Ltd. 2 // Licensed under the AGPLv3, see LICENCE file for details. 3 4 package peergrouper 5 6 import ( 7 "fmt" 8 "sort" 9 "strconv" 10 "strings" 11 "time" 12 13 "github.com/juju/errors" 14 "github.com/juju/replicaset/v3" 15 16 "github.com/juju/juju/core/network" 17 "github.com/juju/juju/core/status" 18 "github.com/juju/juju/state" 19 ) 20 21 // jujuNodeKey is the key for the tag where we save a member's node id. 22 const jujuNodeKey = "juju-machine-id" 23 24 // peerGroupInfo holds information used in attempting to determine a Mongo 25 // peer group. 26 type peerGroupInfo struct { 27 // Maps below are keyed on node ID. 28 29 // controllers holds the controllerTrackers for known controller nodes sourced from the peergrouper 30 // worker. Indexed by node.Id() 31 controllers map[string]*controllerTracker 32 33 // Replica-set members sourced from the Mongo session that are recognised by 34 // their association with known controller nodes. 35 recognised map[string]replicaset.Member 36 37 // Replica-set member statuses sourced from the Mongo session. 38 statuses map[string]replicaset.MemberStatus 39 40 toRemove []replicaset.Member 41 extra []replicaset.Member 42 maxMemberId int 43 mongoPort int 44 haSpace network.SpaceInfo 45 } 46 47 // desiredChanges tracks the specific changes we are asking to be made to the peer group. 48 type desiredChanges struct { 49 // isChanged is set False if the existing peer group is already in a valid configuration. 50 isChanged bool 51 52 // stepDownPrimary is set if we want to remove the vote from the Mongo Primary. This is specially flagged, 53 // because you have to ask the primary to step down before you can remove its vote. 54 stepDownPrimary bool 55 56 // members is the map of Id to replicaset.Member for the desired list of controller nodes in the replicaset. 57 members map[string]*replicaset.Member 58 } 59 60 // peerGroupChanges tracks the process of computing the desiredChanges to the peer group. 61 type peerGroupChanges struct { 62 // info is the input state we will be processing 63 info *peerGroupInfo 64 65 // this block all represents active processing state 66 toRemoveVote []string 67 toAddVote []string 68 toKeepVoting []string 69 toKeepNonVoting []string 70 toKeepCreateNonVotingMember []string 71 72 // desired tracks the final changes to the peer group that we want to make 73 desired desiredChanges 74 } 75 76 func newPeerGroupInfo( 77 controllers map[string]*controllerTracker, 78 statuses []replicaset.MemberStatus, 79 members []replicaset.Member, 80 mongoPort int, 81 haSpace network.SpaceInfo, 82 ) (*peerGroupInfo, error) { 83 if len(members) == 0 { 84 return nil, fmt.Errorf("current member set is empty") 85 } 86 87 info := peerGroupInfo{ 88 controllers: controllers, 89 statuses: make(map[string]replicaset.MemberStatus), 90 recognised: make(map[string]replicaset.Member), 91 maxMemberId: -1, 92 mongoPort: mongoPort, 93 haSpace: haSpace, 94 } 95 96 // Iterate over the input members and associate them with a controller if 97 // possible; add any non-juju unassociated members to the "extra" slice. 98 // Unassociated members with the juju machine id tag are to be removed. 99 // Link the statuses with the controller node IDs where associated. 100 // Keep track of the highest member ID that we observe. 101 for _, m := range members { 102 if m.Id > info.maxMemberId { 103 info.maxMemberId = m.Id 104 } 105 106 controllerId, ok := m.Tags[jujuNodeKey] 107 if !ok { 108 info.extra = append(info.extra, m) 109 continue 110 } 111 found := false 112 if node, got := controllers[controllerId]; got { 113 info.recognised[controllerId] = m 114 found = node.host.Life() != state.Dead 115 } 116 117 // This invariably makes for N^2, but we anticipate small N. 118 for _, sts := range statuses { 119 if sts.Id == m.Id { 120 info.statuses[controllerId] = sts 121 } 122 } 123 if !found { 124 info.toRemove = append(info.toRemove, m) 125 } 126 } 127 128 return &info, nil 129 } 130 131 // isPrimary returns true if the given controller node id is the mongo primary. 132 func (info *peerGroupInfo) isPrimary(workerControllerId string) (bool, error) { 133 primaryNodeId := -1 134 // Current status of replicaset contains node state. 135 // Here we determine node id of the primary node. 136 for _, m := range info.statuses { 137 if m.State == replicaset.PrimaryState { 138 primaryNodeId = m.Id 139 break 140 } 141 } 142 if primaryNodeId == -1 { 143 return false, errors.NotFoundf("HA primary machine") 144 } 145 146 for _, m := range info.recognised { 147 if m.Id == primaryNodeId { 148 if primaryControllerId, ok := m.Tags[jujuNodeKey]; ok { 149 return primaryControllerId == workerControllerId, nil 150 } 151 } 152 } 153 return false, errors.NotFoundf("HA primary machine") 154 } 155 156 // getLogMessage generates a nicely formatted log message from the known peer 157 // group information. 158 func (info *peerGroupInfo) getLogMessage() string { 159 lines := []string{ 160 fmt.Sprintf("calculating desired peer group\ndesired voting members: (maxId: %d)", info.maxMemberId), 161 } 162 163 template := "\n %#v: rs_id=%d, rs_addr=%s, rs_primary=%v" 164 ids := make([]string, 0, len(info.recognised)) 165 for id := range info.recognised { 166 ids = append(ids, id) 167 } 168 sortAsInts(ids) 169 for _, id := range ids { 170 rm := info.recognised[id] 171 isPrimary := isPrimaryMember(info, id) 172 lines = append(lines, fmt.Sprintf(template, info.controllers[id], rm.Id, rm.Address, isPrimary)) 173 } 174 175 if len(info.toRemove) > 0 { 176 lines = append(lines, "\nmembers to remove:") 177 template := "\n rs_id=%d, rs_addr=%s, tags=%v, vote=%t" 178 for _, em := range info.toRemove { 179 vote := em.Votes != nil && *em.Votes > 0 180 lines = append(lines, fmt.Sprintf(template, em.Id, em.Address, em.Tags, vote)) 181 } 182 } 183 184 if len(info.extra) > 0 { 185 lines = append(lines, "\nother non-juju members:") 186 template := "\n rs_id=%d, rs_addr=%s, tags=%v, vote=%t" 187 for _, em := range info.extra { 188 vote := em.Votes != nil && *em.Votes > 0 189 lines = append(lines, fmt.Sprintf(template, em.Id, em.Address, em.Tags, vote)) 190 } 191 } 192 193 return strings.Join(lines, "") 194 } 195 196 // initNewReplicaSet creates a new node ID indexed map of known replica-set 197 // members to use as the basis for a newly calculated replica-set. 198 func (p *peerGroupChanges) initNewReplicaSet() map[string]*replicaset.Member { 199 rs := make(map[string]*replicaset.Member, len(p.info.recognised)) 200 for id := range p.info.recognised { 201 // Local-scoped variable required here, 202 // or the same pointer to the loop variable is used each time. 203 m := p.info.recognised[id] 204 rs[id] = &m 205 } 206 return rs 207 } 208 209 // desiredPeerGroup returns a new Mongo peer-group calculated from the input 210 // peerGroupInfo. 211 // Returned are the new members indexed by node ID, and a map indicating 212 // which controller nodes are set as voters in the new new peer-group. 213 // If the new peer-group is does not differ from that indicated by the input 214 // peerGroupInfo, a nil member map is returned along with the correct voters 215 // map. 216 // An error is returned if: 217 // 1. There are members unrecognised by controller node association, 218 // and any of these are set as voters. 219 // 2. There is no HA space configured and any nodes have multiple 220 // cloud-local addresses. 221 func desiredPeerGroup(info *peerGroupInfo) (desiredChanges, error) { 222 logger.Debugf(info.getLogMessage()) 223 224 peerChanges := peerGroupChanges{ 225 info: info, 226 desired: desiredChanges{ 227 isChanged: false, 228 stepDownPrimary: false, 229 members: map[string]*replicaset.Member{}, 230 }, 231 } 232 return peerChanges.computeDesiredPeerGroup() 233 } 234 235 func (p *peerGroupChanges) computeDesiredPeerGroup() (desiredChanges, error) { 236 237 // We may find extra peer group members if the controller nodes have been 238 // removed or their controller status removed. 239 // This should only happen if they had been set to non-voting before 240 // removal, in which case we want to remove them from the members list. 241 // If we find a member that is still configured to vote, it is an error. 242 // TODO: There are some other possibilities for what to do in that case. 243 // 1) Leave them untouched, but deal with others as usual (ignore). 244 // 2) Leave them untouched and deal with others, but make sure the extras 245 // are not eligible to be primary. 246 // 3) Remove them. 247 // 4) Do nothing. 248 err := p.checkExtraMembers() 249 if err != nil { 250 return desiredChanges{}, errors.Trace(err) 251 } 252 253 p.desired.members = p.initNewReplicaSet() 254 p.possiblePeerGroupChanges() 255 p.reviewPeerGroupChanges() 256 p.createNonVotingMember() 257 258 // Set up initial record of controller node votes. Any changes after 259 // this will trigger a peer group election. 260 p.adjustVotes() 261 262 if err := p.updateAddresses(); err != nil { 263 return desiredChanges{}, errors.Trace(err) 264 } 265 266 return p.desired, nil 267 } 268 269 // checkExtraMembers checks to see if any of the input members, identified as 270 // not being associated with controller nodes, is set as a voter in the peer group. 271 // If any have, an error is returned. 272 // The boolean indicates whether any extra members were present at all. 273 func (p *peerGroupChanges) checkExtraMembers() error { 274 // Note: (jam 2018-04-18) With the new "juju remove-controller --force" it is much easier to get into this situation 275 // because an active controller that is in the replicaset would get removed while it still had voting rights. 276 // Given that Juju is in control of the replicaset we don't really just 'accept' that some other node has a vote. 277 // *maybe* we could allow non-voting members that would be used by 3rd parties to provide a warm database backup. 278 // But I think the right answer is probably to downgrade unknown members from voting. 279 // Note: (wallyworld) notwithstanding the above, each controller runs its own peer grouper worker. The 280 // mongo primary will remove nodes as needed from the replicaset. There will be a short time where 281 // Juju managed nodes will not yet be accounted for by the other secondary workers. These are accounted 282 // for in the 'toRemove' list. 283 for _, member := range p.info.extra { 284 if isVotingMember(&member) { 285 return fmt.Errorf("non juju voting member %v found in peer group", member) 286 } 287 } 288 if len(p.info.toRemove) > 0 || len(p.info.extra) > 0 { 289 p.desired.isChanged = true 290 } 291 return nil 292 } 293 294 // sortAsInts converts all the vals to an integer to sort them as numbers instead of strings 295 // If any of the values are not valid integers, they will be sorted as strings, and added to the end 296 // the slice will be sorted in place. 297 // (generally this should only be used for strings we expect to represent ints, but we don't want to error if 298 // something isn't an int.) 299 func sortAsInts(vals []string) { 300 asInts := make([]int, 0, len(vals)) 301 extra := []string{} 302 for _, val := range vals { 303 asInt, err := strconv.Atoi(val) 304 if err != nil { 305 extra = append(extra, val) 306 } else { 307 asInts = append(asInts, asInt) 308 } 309 } 310 sort.Ints(asInts) 311 sort.Strings(extra) 312 i := 0 313 for _, asInt := range asInts { 314 vals[i] = strconv.Itoa(asInt) 315 i++ 316 } 317 for _, val := range extra { 318 vals[i] = val 319 i++ 320 } 321 } 322 323 // possiblePeerGroupChanges returns a set of slices classifying all the 324 // existing controller nodes according to how their vote might move. 325 // toRemoveVote holds nodes whose vote should be removed; 326 // toAddVote holds nodes which are ready to vote; 327 // toKeep holds nodes with no desired change to their voting status 328 // (this includes nodes that are not yet represented in the peer group). 329 func (p *peerGroupChanges) possiblePeerGroupChanges() { 330 nodeIds := make([]string, 0, len(p.info.controllers)) 331 for id := range p.info.controllers { 332 nodeIds = append(nodeIds, id) 333 } 334 sortAsInts(nodeIds) 335 logger.Debugf("assessing possible peer group changes:") 336 for _, id := range nodeIds { 337 m := p.info.controllers[id] 338 member := p.desired.members[id] 339 if m.host.Life() != state.Alive { 340 if _, ok := p.desired.members[id]; !ok { 341 // Dead machine already removed from replicaset. 342 continue 343 } 344 logger.Debugf("controller %v has died %q, wants vote: %v", id, m.host.Life(), m.WantsVote()) 345 if isPrimaryMember(p.info, id) { 346 p.desired.stepDownPrimary = true 347 } 348 delete(p.desired.members, id) 349 p.desired.isChanged = true 350 continue 351 } 352 isVoting := member != nil && isVotingMember(member) 353 wantsVote := m.WantsVote() 354 switch { 355 case wantsVote && isVoting: 356 logger.Debugf("node %q is already voting", id) 357 p.toKeepVoting = append(p.toKeepVoting, id) 358 case wantsVote && !isVoting: 359 if status, ok := p.info.statuses[id]; ok && isReady(status) { 360 logger.Debugf("node %q is a potential voter", id) 361 p.toAddVote = append(p.toAddVote, id) 362 } else if member != nil { 363 logger.Debugf("node %q exists but is not ready (status: %v, healthy: %v)", 364 id, status.State, status.Healthy) 365 p.toKeepNonVoting = append(p.toKeepNonVoting, id) 366 } else { 367 logger.Debugf("node %q does not exist and is not ready (status: %v, healthy: %v)", 368 id, status.State, status.Healthy) 369 p.toKeepCreateNonVotingMember = append(p.toKeepCreateNonVotingMember, id) 370 } 371 case !wantsVote && isVoting: 372 p.toRemoveVote = append(p.toRemoveVote, id) 373 if isPrimaryMember(p.info, id) { 374 p.desired.stepDownPrimary = true 375 logger.Debugf("primary node %q is a potential non-voter", id) 376 } else { 377 logger.Debugf("node %q is a potential non-voter", id) 378 } 379 case !wantsVote && !isVoting: 380 logger.Debugf("node %q does not want the vote", id) 381 p.toKeepNonVoting = append(p.toKeepNonVoting, id) 382 } 383 } 384 logger.Debugf("assessed") 385 } 386 387 func isReady(status replicaset.MemberStatus) bool { 388 return status.Healthy && (status.State == replicaset.PrimaryState || 389 status.State == replicaset.SecondaryState) 390 } 391 392 // reviewPeerGroupChanges adds some extra logic after creating 393 // possiblePeerGroupChanges to safely add or remove controller nodes, keeping the 394 // correct odd number of voters peer structure, and preventing the primary from 395 // demotion. 396 func (p *peerGroupChanges) reviewPeerGroupChanges() { 397 currVoters := 0 398 for _, m := range p.desired.members { 399 if isVotingMember(m) { 400 currVoters += 1 401 } 402 } 403 keptVoters := currVoters - len(p.toRemoveVote) 404 if keptVoters == 0 { 405 // to keep no voters means to step down the primary without a replacement, which is not possible. 406 // So restore the current primary. Once there is another member to work with after reconfiguring, we will then 407 // be able to ask the current primary to step down, and then we can finally remove it. 408 var tempToRemove []string 409 for _, id := range p.toRemoveVote { 410 isPrimary := isPrimaryMember(p.info, id) 411 if !isPrimary { 412 tempToRemove = append(tempToRemove, id) 413 } else { 414 logger.Debugf("asked to remove all voters, preserving primary voter %q", id) 415 p.desired.stepDownPrimary = false 416 } 417 } 418 p.toRemoveVote = tempToRemove 419 } 420 newCount := keptVoters + len(p.toAddVote) 421 if (newCount)%2 == 1 { 422 logger.Debugf("number of voters is odd") 423 // if this is true we will create an odd number of voters 424 return 425 } 426 if len(p.toAddVote) > 0 { 427 last := p.toAddVote[len(p.toAddVote)-1] 428 logger.Debugf("number of voters would be even, not adding %q to maintain odd", last) 429 p.toAddVote = p.toAddVote[:len(p.toAddVote)-1] 430 return 431 } 432 // we must remove an extra peer 433 // make sure we don't pick the primary to be removed. 434 for i, id := range p.toKeepVoting { 435 if !isPrimaryMember(p.info, id) { 436 p.toRemoveVote = append(p.toRemoveVote, id) 437 logger.Debugf("removing vote from %q to maintain odd number of voters", id) 438 if i == len(p.toKeepVoting)-1 { 439 p.toKeepVoting = p.toKeepVoting[:i] 440 } else { 441 p.toKeepVoting = append(p.toKeepVoting[:i], p.toKeepVoting[i+1:]...) 442 } 443 break 444 } 445 } 446 } 447 448 func isVotingMember(m *replicaset.Member) bool { 449 v := m.Votes 450 return v == nil || *v > 0 451 } 452 453 func isPrimaryMember(info *peerGroupInfo, id string) bool { 454 return info.statuses[id].State == replicaset.PrimaryState 455 } 456 457 func setMemberVoting(member *replicaset.Member, voting bool) { 458 if voting { 459 member.Votes = nil 460 member.Priority = nil 461 } else { 462 votes := 0 463 member.Votes = &votes 464 priority := 0.0 465 member.Priority = &priority 466 } 467 } 468 469 // adjustVotes removes and adds votes to the members via setVoting. 470 func (p *peerGroupChanges) adjustVotes() { 471 setVoting := func(memberIds []string, voting bool) { 472 for _, id := range memberIds { 473 setMemberVoting(p.desired.members[id], voting) 474 } 475 } 476 477 if len(p.toAddVote) > 0 || 478 len(p.toRemoveVote) > 0 || 479 len(p.toKeepCreateNonVotingMember) > 0 { 480 p.desired.isChanged = true 481 } 482 setVoting(p.toAddVote, true) 483 setVoting(p.toRemoveVote, false) 484 setVoting(p.toKeepCreateNonVotingMember, false) 485 } 486 487 // createMembers from a list of member IDs, instantiate a new replica-set 488 // member and add it to members map with the given ID. 489 func (p *peerGroupChanges) createNonVotingMember() { 490 for _, id := range p.toKeepCreateNonVotingMember { 491 logger.Debugf("create member with id %q", id) 492 p.info.maxMemberId++ 493 member := &replicaset.Member{ 494 Tags: map[string]string{ 495 jujuNodeKey: id, 496 }, 497 Id: p.info.maxMemberId, 498 } 499 setMemberVoting(member, false) 500 p.desired.members[id] = member 501 } 502 for _, id := range p.toKeepNonVoting { 503 if p.desired.members[id] != nil { 504 continue 505 } 506 logger.Debugf("create member with id %q", id) 507 p.info.maxMemberId++ 508 member := &replicaset.Member{ 509 Tags: map[string]string{ 510 jujuNodeKey: id, 511 }, 512 Id: p.info.maxMemberId, 513 } 514 setMemberVoting(member, false) 515 p.desired.members[id] = member 516 } 517 } 518 519 // updateAddresses updates the member addresses in the new replica-set, using 520 // the HA space if one is configured. 521 func (p *peerGroupChanges) updateAddresses() error { 522 var err error 523 if p.info.haSpace.Name == "" { 524 err = p.updateAddressesFromInternal() 525 } else { 526 err = p.updateAddressesFromSpace() 527 } 528 return errors.Annotate(err, "updating member addresses") 529 } 530 531 const multiAddressMessage = "multiple usable addresses found" + 532 "\nrun \"juju controller-config juju-ha-space=<name>\" to set a space for Mongo peer communication" 533 534 // updateAddressesFromInternal attempts to update each member with a 535 // cloud-local address from the node. 536 // If there is a single cloud local address available, it is used. 537 // If there are multiple addresses, then a check is made to ensure that: 538 // - the member was previously in the replica-set and; 539 // - the previous address used for replication is still available. 540 // 541 // If the check is satisfied, then a warning is logged and no change is made. 542 // Otherwise an error is returned to indicate that a HA space must be 543 // configured in order to proceed. Such nodes have their status set to 544 // indicate that they require intervention. 545 func (p *peerGroupChanges) updateAddressesFromInternal() error { 546 var multipleAddresses []string 547 548 ids := p.sortedMemberIds() 549 singleController := len(ids) == 1 550 551 for _, id := range ids { 552 m := p.info.controllers[id] 553 hostPorts := m.GetPotentialMongoHostPorts(p.info.mongoPort) 554 addrs := hostPorts.AllMatchingScope(network.ScopeMatchCloudLocal) 555 556 // This should not happen because SelectInternalHostPorts will choose a 557 // public address when there are no cloud-local addresses. 558 // Zero addresses would mean the node is completely inaccessible. 559 // We ignore this outcome and leave the address alone. 560 if len(addrs) == 0 { 561 continue 562 } 563 564 // Unique address; we can use this for Mongo peer communication. 565 member := p.desired.members[id] 566 if len(addrs) == 1 { 567 addr := addrs[0] 568 logger.Debugf("node %q selected address %q by scope from %v", id, addr, hostPorts) 569 570 if member.Address != addr { 571 member.Address = addr 572 p.desired.isChanged = true 573 } 574 continue 575 } 576 577 // Multiple potential Mongo addresses. 578 // Checks are required in order to use it as a peer. 579 unchanged := false 580 if _, ok := p.info.recognised[id]; ok { 581 for _, addr := range addrs { 582 if member.Address == addr { 583 // If this is a single controller with multiple addresses, 584 // avoid warning logs for every peer-group check. 585 if !singleController { 586 logger.Warningf("%s\npreserving member with unchanged address %q", multiAddressMessage, addr) 587 } 588 unchanged = true 589 break 590 } 591 } 592 } 593 594 // If this member was not previously in the replica-set, or if its 595 // address has changed, we enforce the policy of requiring a 596 // configured HA space when there are multiple cloud-local addresses. 597 if !unchanged { 598 multipleAddresses = append(multipleAddresses, id) 599 if err := m.host.SetStatus(getStatusInfo(multiAddressMessage)); err != nil { 600 return errors.Trace(err) 601 } 602 } 603 } 604 605 if len(multipleAddresses) > 0 { 606 ids := strings.Join(multipleAddresses, ", ") 607 return fmt.Errorf("juju-ha-space is not set and these nodes have more than one usable address: %s"+ 608 "\nrun \"juju controller-config juju-ha-space=<name>\" to set a space for Mongo peer communication", ids) 609 } 610 return nil 611 } 612 613 // updateAddressesFromSpace updates the member addresses based on the 614 // configured HA space. 615 // If no addresses are available for any of the nodes, then such nodes 616 // have their status set and are included in the detail of the returned error. 617 func (p *peerGroupChanges) updateAddressesFromSpace() error { 618 space := p.info.haSpace 619 var noAddresses []string 620 621 for _, id := range p.sortedMemberIds() { 622 m := p.info.controllers[id] 623 addr, err := m.SelectMongoAddressFromSpace(p.info.mongoPort, space) 624 if err != nil { 625 if errors.IsNotFound(err) { 626 noAddresses = append(noAddresses, id) 627 msg := fmt.Sprintf("no addresses in configured juju-ha-space %q", space.Name) 628 if err := m.host.SetStatus(getStatusInfo(msg)); err != nil { 629 return errors.Trace(err) 630 } 631 continue 632 } 633 return errors.Trace(err) 634 } 635 if addr != p.desired.members[id].Address { 636 p.desired.members[id].Address = addr 637 p.desired.isChanged = true 638 } 639 } 640 641 if len(noAddresses) > 0 { 642 ids := strings.Join(noAddresses, ", ") 643 return fmt.Errorf( 644 "no usable Mongo addresses found in configured juju-ha-space %q for nodes: %s", space.Name, ids) 645 } 646 return nil 647 } 648 649 // sortedMemberIds returns the list of p.desired.members in integer-sorted order 650 func (p *peerGroupChanges) sortedMemberIds() []string { 651 memberIds := make([]string, 0, len(p.desired.members)) 652 for id := range p.desired.members { 653 memberIds = append(memberIds, id) 654 } 655 sortAsInts(memberIds) 656 return memberIds 657 } 658 659 // getStatusInfo creates and returns a StatusInfo instance for use as a controller 660 // status. The *controller* status is not ideal for conveying this information, 661 // which is a really a characteristic of its role as a controller application. 662 // For this reason we leave the status as "Started" and supplement with an 663 // appropriate message. 664 // This is subject to change if/when controller status is represented in its 665 // own right. 666 func getStatusInfo(msg string) status.StatusInfo { 667 now := time.Now() 668 return status.StatusInfo{ 669 Status: status.Started, 670 Message: msg, 671 Since: &now, 672 } 673 }