github.com/aristanetworks/goarista@v0.0.0-20240514173732-cca2755bbd44/gnmi/client/client.go (about)

     1  // Copyright (c) 2017 Arista Networks, Inc.
     2  // Use of this source code is governed by the Apache License 2.0
     3  // that can be found in the COPYING file.
     4  
     5  package client
     6  
     7  import (
     8  	"context"
     9  	"errors"
    10  	"flag"
    11  	"fmt"
    12  	"os"
    13  	"runtime/debug"
    14  	"strconv"
    15  	"strings"
    16  	"sync/atomic"
    17  	"time"
    18  
    19  	aflag "github.com/aristanetworks/goarista/flag"
    20  	"github.com/aristanetworks/goarista/gnmi"
    21  
    22  	"github.com/aristanetworks/glog"
    23  	pb "github.com/openconfig/gnmi/proto/gnmi"
    24  	"github.com/openconfig/gnmi/proto/gnmi_ext"
    25  	"golang.org/x/sync/errgroup"
    26  	"google.golang.org/grpc"
    27  	"google.golang.org/grpc/keepalive"
    28  	"google.golang.org/protobuf/proto"
    29  )
    30  
    31  // TODO: Make this more clear
    32  var help = `Usage of gnmi:
    33  gnmi -addr [<VRF-NAME>/]ADDRESS:PORT [options...]
    34    capabilities
    35    get ((encoding=ENCODING) (origin=ORIGIN) (target=TARGET) PATH+)+
    36    subscribe ((origin=ORIGIN) (target=TARGET) PATH+)+ 
    37    ((update|replace|union_replace (origin=ORIGIN) (target=TARGET) PATH JSON|FILE) |
    38     (delete (origin=ORIGIN) (target=TARGET) PATH))+
    39  `
    40  
    41  type reqParams struct {
    42  	encoding string
    43  	origin   string
    44  	target   string
    45  	paths    []string
    46  }
    47  
    48  func usageAndExit(s string) {
    49  	flag.Usage()
    50  	if s != "" {
    51  		fmt.Fprintln(os.Stderr, s)
    52  	}
    53  	os.Exit(1)
    54  }
    55  
    56  // Main initializes the gNMI client.
    57  func Main() {
    58  	cfg := &gnmi.Config{}
    59  	flag.StringVar(&cfg.Addr, "addr", "", "Address of gNMI gRPC server with optional VRF name")
    60  	flag.StringVar(&cfg.CAFile, "cafile", "", "Path to server TLS certificate file")
    61  	flag.StringVar(&cfg.CertFile, "certfile", "", "Path to client TLS certificate file")
    62  	flag.StringVar(&cfg.KeyFile, "keyfile", "", "Path to client TLS private key file")
    63  	flag.StringVar(&cfg.Password, "password", "", "Password to authenticate with")
    64  	flag.StringVar(&cfg.Username, "username", "", "Username to authenticate with")
    65  	flag.StringVar(&cfg.Compression, "compression", "", "Compression method. "+
    66  		`Supported options: "" and "gzip"`)
    67  	flag.BoolVar(&cfg.TLS, "tls", false, "Enable TLS")
    68  	flag.StringVar(&cfg.TLSMinVersion, "tls-min-version", "",
    69  		fmt.Sprintf("Set minimum TLS version for connection (%s)", gnmi.TLSVersions))
    70  	flag.StringVar(&cfg.TLSMaxVersion, "tls-max-version", "",
    71  		fmt.Sprintf("Set maximum TLS version for connection (%s)", gnmi.TLSVersions))
    72  	flag.BoolVar(&cfg.BDP, "bdp", true,
    73  		"Enable Bandwidth Delay Product (BDP) estimation and dynamic flow control window")
    74  	outputVersion := flag.Bool("version", false, "print version information")
    75  
    76  	subscribeOptions := &gnmi.SubscribeOptions{}
    77  	flag.StringVar(&subscribeOptions.Prefix, "prefix", "", "Subscribe prefix path")
    78  	flag.BoolVar(&subscribeOptions.UpdatesOnly, "updates_only", false,
    79  		"Subscribe to updates only (false | true)")
    80  	flag.StringVar(&subscribeOptions.Mode, "mode", "stream",
    81  		"Subscribe mode (stream | once | poll)")
    82  	flag.StringVar(&subscribeOptions.StreamMode, "stream_mode", "target_defined",
    83  		"Subscribe stream mode, only applies for stream subscriptions "+
    84  			"(target_defined | on_change | sample)")
    85  	sampleIntervalStr := flag.String("sample_interval", "0", "Subscribe sample interval, "+
    86  		"only applies for sample subscriptions (400ms, 2.5s, 1m, etc.)")
    87  	heartbeatIntervalStr := flag.String("heartbeat_interval", "0", "Subscribe heartbeat "+
    88  		"interval, only applies for on-change subscriptions (400ms, 2.5s, 1m, etc.)")
    89  	arbitrationStr := flag.String("arbitration", "", "master arbitration identifier "+
    90  		"([<role_id>:]<election_id>)")
    91  	historyStartStr := flag.String("history_start", "", "Historical data subscription "+
    92  		"start time (nanoseconds since Unix epoch or RFC3339 format with nanosecond "+
    93  		"precision, e.g., 2006-01-02T15:04:05.999999999+07:00)")
    94  	historyEndStr := flag.String("history_end", "", "Historical data subscription "+
    95  		"end time (nanoseconds since Unix epoch or RFC3339 format with nanosecond "+
    96  		"precision, e.g., 2006-01-02T15:04:05.999999999+07:00)")
    97  	historySnapshotStr := flag.String("history_snapshot", "", "Historical data subscription "+
    98  		"snapshot time (nanoseconds since Unix epoch or RFC3339 format with nanosecond "+
    99  		"precision, e.g., 2006-01-02T15:04:05.999999999+07:00)")
   100  	dataTypeStr := flag.String("data_type", "all",
   101  		"Get data type (all | config | state | operational)")
   102  	flag.StringVar(&cfg.Token, "token", "", "Authentication token")
   103  	grpcMetadata := aflag.Map{}
   104  	flag.Var(grpcMetadata, "grpcmetadata",
   105  		"key=value gRPC metadata fields, can be used repeatedly")
   106  
   107  	debugMode := flag.String("debug", "", "Enable a debug mode:\n"+
   108  		"  'proto' : print SubscribeResponses in protobuf text format\n"+
   109  		"  'latency' : print timing numbers to help debug latency\n"+
   110  		"  'throughput' : print number of notifications sent in a second\n"+
   111  		"  'clog' : start a subscribe and then don't read any of the responses")
   112  
   113  	keepaliveTimeStr := flag.String("keepalive_time", "", "Keepalive ping interval. "+
   114  		"After inactivity of this duration, ping the server (30s, 2m, etc. Default 10s). "+
   115  		"10s is the minimum value allowed. If a value less than 10s is supplied, 10s will be used")
   116  
   117  	flag.Usage = func() {
   118  		fmt.Fprintln(os.Stderr, help)
   119  		flag.PrintDefaults()
   120  	}
   121  	flag.Parse()
   122  	if *outputVersion {
   123  		if info, ok := debug.ReadBuildInfo(); ok {
   124  			for _, data := range info.Settings {
   125  				if data.Key == "vcs.revision" {
   126  					fmt.Printf("%s_%s", data.Value, info.GoVersion)
   127  				}
   128  			}
   129  		} else {
   130  			fmt.Printf("version information only available in go 1.18+")
   131  		}
   132  		return
   133  	}
   134  
   135  	if cfg.Addr == "" {
   136  		usageAndExit("error: address not specified")
   137  	}
   138  	cfg.GRPCMetadata = grpcMetadata
   139  
   140  	var sampleInterval, heartbeatInterval time.Duration
   141  	var err error
   142  	if sampleInterval, err = time.ParseDuration(*sampleIntervalStr); err != nil {
   143  		usageAndExit(fmt.Sprintf("error: sample interval (%s) invalid", *sampleIntervalStr))
   144  	}
   145  	subscribeOptions.SampleInterval = uint64(sampleInterval)
   146  	if heartbeatInterval, err = time.ParseDuration(*heartbeatIntervalStr); err != nil {
   147  		usageAndExit(fmt.Sprintf("error: heartbeat interval (%s) invalid", *heartbeatIntervalStr))
   148  	}
   149  	subscribeOptions.HeartbeatInterval = uint64(heartbeatInterval)
   150  
   151  	var histExt *gnmi_ext.Extension_History
   152  	if *historyStartStr != "" || *historyEndStr != "" || *historySnapshotStr != "" {
   153  		if *historySnapshotStr != "" {
   154  			if *historyStartStr != "" || *historyEndStr != "" {
   155  				usageAndExit("error: specified history start/end and snapshot time")
   156  			}
   157  			t, err := parseTime(*historySnapshotStr)
   158  			if err != nil {
   159  				usageAndExit(fmt.Sprintf("error: invalid snapshot time (%s): %s",
   160  					*historySnapshotStr, err))
   161  			}
   162  			histExt = gnmi.HistorySnapshotExtension(t.UnixNano())
   163  		} else {
   164  			var s, e int64
   165  			if *historyStartStr != "" {
   166  				st, err := parseTime(*historyStartStr)
   167  				if err != nil {
   168  					usageAndExit(fmt.Sprintf("error: invalid start time (%s): %s",
   169  						*historyStartStr, err))
   170  				}
   171  				s = st.UnixNano()
   172  			}
   173  			if *historyEndStr != "" {
   174  				et, err := parseTime(*historyEndStr)
   175  				if err != nil {
   176  					usageAndExit(fmt.Sprintf("error: invalid end time (%s): %s",
   177  						*historyEndStr, err))
   178  				}
   179  				e = et.UnixNano()
   180  			}
   181  			histExt = gnmi.HistoryRangeExtension(s, e)
   182  		}
   183  	}
   184  
   185  	if *keepaliveTimeStr != "" {
   186  		var keepaliveTime time.Duration
   187  		var err error
   188  		if keepaliveTime, err = time.ParseDuration(*keepaliveTimeStr); err != nil {
   189  			usageAndExit(fmt.Sprintf("error: keepalive time (%s) invalid", *keepaliveTimeStr))
   190  		}
   191  
   192  		timeout := time.Duration(keepaliveTime * time.Second)
   193  		cfg.DialOptions = append(cfg.DialOptions,
   194  			grpc.WithKeepaliveParams(keepalive.ClientParameters{Time: timeout}))
   195  	}
   196  
   197  	args := flag.Args()
   198  
   199  	ctx := gnmi.NewContext(context.Background(), cfg)
   200  	client, err := gnmi.Dial(cfg)
   201  	if err != nil {
   202  		glog.Fatal(err)
   203  	}
   204  
   205  	var setOps []*gnmi.Operation
   206  	for i := 0; i < len(args); i++ {
   207  		op := args[i]
   208  		switch op {
   209  		case "capabilities":
   210  			if len(setOps) != 0 {
   211  				usageAndExit("error: 'capabilities' not allowed after" +
   212  					" 'update|replace|delete|union_replace'")
   213  			}
   214  			err := gnmi.Capabilities(ctx, client)
   215  			if err != nil {
   216  				glog.Fatal(err)
   217  			}
   218  			return
   219  		case "get":
   220  			if len(setOps) != 0 {
   221  				usageAndExit("error: 'get' not allowed after" +
   222  					" 'update|replace|delete|union_replace'")
   223  			}
   224  			pathParams, argsParsed := parsereqParams(args[1:], false)
   225  			if argsParsed == 0 {
   226  				usageAndExit("error: missing path")
   227  			}
   228  			for _, pathParam := range pathParams {
   229  				req, err := newGetRequest(pathParam, *dataTypeStr)
   230  				if err != nil {
   231  					usageAndExit("error: " + err.Error())
   232  				}
   233  
   234  				err = gnmi.GetWithRequest(ctx, client, req)
   235  				if err != nil {
   236  					glog.Fatal(err)
   237  				}
   238  			}
   239  
   240  			return
   241  		case "subscribe":
   242  			if len(setOps) != 0 {
   243  				usageAndExit("error: 'subscribe' not allowed after" +
   244  					" 'update|replace|delete|union_replace'")
   245  			}
   246  			var g errgroup.Group
   247  			pathParams, argsParsed := parsereqParams(args[1:], false)
   248  			if argsParsed == 0 {
   249  				usageAndExit("error: missing path")
   250  			}
   251  			for _, pathParam := range pathParams {
   252  				subOptions, err := newSubscribeOptions(pathParam, histExt, subscribeOptions)
   253  				if err != nil {
   254  					usageAndExit("error: " + err.Error())
   255  				}
   256  
   257  				respChan := make(chan *pb.SubscribeResponse)
   258  				g.Go(func() error {
   259  					return gnmi.SubscribeErr(ctx, client, subOptions, respChan)
   260  				})
   261  				switch *debugMode {
   262  				case "proto":
   263  					for resp := range respChan {
   264  						fmt.Println(resp)
   265  					}
   266  				case "latency":
   267  					for resp := range respChan {
   268  						printLatencyStats(resp)
   269  					}
   270  				case "throughput":
   271  					handleThroughput(respChan)
   272  				case "clog":
   273  					// Don't read any subscription updates
   274  					g.Wait()
   275  				case "":
   276  					go processSubscribeResponses(pathParam.origin, respChan)
   277  
   278  				default:
   279  					usageAndExit(fmt.Sprintf("unknown debug option: %q", *debugMode))
   280  				}
   281  			}
   282  			if err := g.Wait(); err != nil {
   283  				glog.Fatal(err)
   284  			}
   285  			return
   286  		case "update", "replace", "delete", "union_replace":
   287  			j, op, err := newSetOperation(i, args, *arbitrationStr)
   288  			if err != nil {
   289  				usageAndExit("error: " + err.Error())
   290  			}
   291  			if op != nil {
   292  				setOps = append(setOps, op)
   293  			}
   294  			i = j
   295  		default:
   296  			usageAndExit(fmt.Sprintf("error: unknown operation %q", args[i]))
   297  		}
   298  	}
   299  	arb, err := gnmi.ArbitrationExt(*arbitrationStr)
   300  	if err != nil {
   301  		glog.Fatal(err)
   302  	}
   303  	var exts []*gnmi_ext.Extension
   304  	if arb != nil {
   305  		exts = append(exts, arb)
   306  	}
   307  	err = gnmi.Set(ctx, client, setOps, exts...)
   308  	if err != nil {
   309  		glog.Fatal(err)
   310  	}
   311  
   312  }
   313  
   314  func newSetOperation(
   315  	index int,
   316  	args []string,
   317  	arbitrationStr string) (int, *gnmi.Operation, error) {
   318  
   319  	// ok if no args, if arbitration was specified
   320  	if len(args) == index+1 && arbitrationStr == "" {
   321  		return 0, nil, errors.New("missing path")
   322  	}
   323  
   324  	op := &gnmi.Operation{
   325  		Type: args[index],
   326  	}
   327  	index++
   328  	if len(args) <= index {
   329  		return index, nil, nil
   330  	}
   331  
   332  	pathParams, argsParsed := parsereqParams(args[index:], true)
   333  	index += argsParsed
   334  
   335  	// process update | replace | delete | union_replace request one at a time
   336  	pathParam := pathParams[0]
   337  
   338  	// check that encoding is not set
   339  	if pathParam.encoding != "" {
   340  		return 0, nil, fmt.Errorf("encoding option is not supported for '%s'", op.Type)
   341  	}
   342  
   343  	if len(pathParam.paths) == 0 {
   344  		return 0, nil, fmt.Errorf("missing path for '%s'", op.Type)
   345  	}
   346  
   347  	op.Path = gnmi.SplitPath(pathParam.paths[0])
   348  	op.Origin = pathParam.origin
   349  	op.Target = pathParam.target
   350  	if op.Type == "delete" {
   351  		// set index to be right before the next arg that needs to be processed
   352  		index--
   353  	} else {
   354  		// no need for index-- since the value of update/replace/union_replace is right
   355  		// before the next arg
   356  		if len(args) == index {
   357  			return 0, nil, errors.New("missing JSON or FILEPATH to data")
   358  		}
   359  		op.Val = args[index]
   360  	}
   361  
   362  	return index, op, nil
   363  }
   364  
   365  func newSubscribeOptions(
   366  	pathParam reqParams,
   367  	histExt *gnmi_ext.Extension_History,
   368  	subscribeOptions *gnmi.SubscribeOptions) (*gnmi.SubscribeOptions, error) {
   369  
   370  	origin := pathParam.origin
   371  	target := pathParam.target
   372  	paths := pathParam.paths
   373  	encoding := pathParam.encoding
   374  
   375  	// check that encoding is not set
   376  	if encoding != "" {
   377  		return nil, errors.New("encoding option is not supported for 'subscribe'")
   378  	}
   379  
   380  	subOptions := new(gnmi.SubscribeOptions)
   381  	*subOptions = *subscribeOptions
   382  	subOptions.Origin = origin
   383  	subOptions.Target = target
   384  	subOptions.Paths = gnmi.SplitPaths(paths)
   385  	if histExt != nil {
   386  		subOptions.Extensions = []*gnmi_ext.Extension{{
   387  			Ext: histExt,
   388  		}}
   389  	}
   390  
   391  	return subOptions, nil
   392  }
   393  
   394  func newGetRequest(pathParam reqParams, dataTypeStr string) (*pb.GetRequest, error) {
   395  	origin := pathParam.origin
   396  	target := pathParam.target
   397  	paths := pathParam.paths
   398  	encoding := pathParam.encoding
   399  
   400  	req, err := gnmi.NewGetRequest(gnmi.SplitPaths(paths), origin)
   401  	if err != nil {
   402  		glog.Fatal(err)
   403  	}
   404  
   405  	// set target
   406  	if target != "" {
   407  		if req.Prefix == nil {
   408  			req.Prefix = &pb.Path{}
   409  		}
   410  		req.Prefix.Target = target
   411  	}
   412  
   413  	// set type
   414  	switch strings.ToLower(dataTypeStr) {
   415  	case "", "all":
   416  		req.Type = pb.GetRequest_ALL
   417  	case "config":
   418  		req.Type = pb.GetRequest_CONFIG
   419  	case "state":
   420  		req.Type = pb.GetRequest_STATE
   421  	case "operational":
   422  		req.Type = pb.GetRequest_OPERATIONAL
   423  	default:
   424  		return nil, fmt.Errorf("invalid data type (%s)", dataTypeStr)
   425  	}
   426  
   427  	// set encoding
   428  	switch en := strings.ToLower(encoding); en {
   429  	case "ascii":
   430  		req.Encoding = pb.Encoding_ASCII
   431  	case "json":
   432  		req.Encoding = pb.Encoding_JSON
   433  	case "json_ietf":
   434  		req.Encoding = pb.Encoding_JSON_IETF
   435  	case "proto":
   436  		req.Encoding = pb.Encoding_PROTO
   437  	case "bytes":
   438  		req.Encoding = pb.Encoding_BYTES
   439  	case "":
   440  	default:
   441  		return nil, fmt.Errorf(
   442  			`invalid encoding '%s'
   443  Supported encodings are (case insensitive):
   444  - JSON
   445  - Bytes
   446  - Proto
   447  - ASCII
   448  - JSON_IETF`, en)
   449  	}
   450  
   451  	return req, nil
   452  }
   453  
   454  func processSubscribeResponses(origin string, respChan chan *pb.SubscribeResponse) {
   455  	for resp := range respChan {
   456  		if err := gnmi.LogSubscribeResponse(resp); err != nil {
   457  			glog.Fatal(err)
   458  		}
   459  	}
   460  }
   461  
   462  // Parse string timestamp, first trying for ns since epoch, and then
   463  // for RFC3339.
   464  func parseTime(ts string) (time.Time, error) {
   465  	if ti, err := strconv.ParseInt(ts, 10, 64); err == nil {
   466  		return time.Unix(0, ti), nil
   467  	}
   468  	return time.Parse(time.RFC3339Nano, ts)
   469  }
   470  
   471  func parseStringOpt(s, prefix string) (string, bool) {
   472  	if strings.HasPrefix(s, prefix+"=") {
   473  		return strings.TrimPrefix(s, prefix+"="), true
   474  	}
   475  	return "", false
   476  }
   477  
   478  func parseOrigin(s string) (string, bool) {
   479  	return parseStringOpt(s, "origin")
   480  }
   481  
   482  func parseTarget(s string) (string, bool) {
   483  	return parseStringOpt(s, "target")
   484  }
   485  
   486  func parseEncoding(s string) (string, bool) {
   487  	return parseStringOpt(s, "encoding")
   488  }
   489  
   490  func parsereqParams(args []string, maxOnePath bool) ([]reqParams, int) {
   491  	var pathParam *reqParams = new(reqParams)
   492  	var pathParams []reqParams
   493  	var argsParsed int
   494  
   495  	//           [[ Some Considerations ]]
   496  	// - No need to follow encoding - origin - target - path as names are specified.
   497  	// - PATHS+ still mark the end of a pathParam
   498  	// - only path is required and everything else is optional
   499  	// - there can be one or more paths
   500  	// - there can be zero or one encoding, origin, and target.
   501  
   502  	var isOriginSet bool
   503  	var isTargetSet bool
   504  	var isEncodingSet bool
   505  
   506  	// check if the current config forms a pathParam
   507  	// If yes, reset all the trackers and add it to pathParams
   508  	// otherwise, don't do anything
   509  	var checkGroup = func(isItemSet bool) {
   510  		if isItemSet || len(pathParam.paths) > 0 {
   511  			isOriginSet = false
   512  			isTargetSet = false
   513  			isEncodingSet = false
   514  			pathParams = append(pathParams, *pathParam)
   515  			pathParam = new(reqParams)
   516  		}
   517  	}
   518  
   519  	for _, arg := range args {
   520  		argsParsed++
   521  		if o, ok := parseOrigin(arg); ok {
   522  			checkGroup(isOriginSet)
   523  			pathParam.origin = o
   524  			isOriginSet = true
   525  		} else if t, ok := parseTarget(arg); ok {
   526  			checkGroup(isTargetSet)
   527  			pathParam.target = t
   528  			isTargetSet = true
   529  		} else if e, ok := parseEncoding(arg); ok {
   530  			checkGroup(isEncodingSet)
   531  			pathParam.encoding = e
   532  			isEncodingSet = true
   533  		} else {
   534  			pathParam.paths = append(pathParam.paths, arg)
   535  			if maxOnePath {
   536  				break
   537  			}
   538  		}
   539  	}
   540  
   541  	// The last pathParam wasn't added, add it now
   542  	pathParams = append(pathParams, *pathParam)
   543  
   544  	// validate that all reqParams have a valid path
   545  	for _, param := range pathParams {
   546  		if param.paths == nil {
   547  			return pathParams, 0 // no path provided
   548  		}
   549  	}
   550  
   551  	return pathParams, argsParsed
   552  }
   553  
   554  func printLatencyStats(s *pb.SubscribeResponse) {
   555  	switch resp := s.Response.(type) {
   556  	case *pb.SubscribeResponse_SyncResponse:
   557  		fmt.Printf("now=%d sync_response=%t\n",
   558  			time.Now().UnixNano(), resp.SyncResponse)
   559  	case *pb.SubscribeResponse_Update:
   560  		notif := resp.Update
   561  		now := time.Now().UnixNano()
   562  		fmt.Printf("now=%d timestamp=%d latency=%s prefix=%s size=%d updates=%d deletes=%d\n",
   563  			now,
   564  			notif.Timestamp,
   565  			time.Duration(now-notif.Timestamp),
   566  			gnmi.StrPath(notif.Prefix),
   567  			proto.Size(s),
   568  			len(notif.Update),
   569  			len(notif.Delete),
   570  		)
   571  	}
   572  }
   573  
   574  func handleThroughput(respChan <-chan *pb.SubscribeResponse) {
   575  	var notifs uint64
   576  	var updates uint64
   577  	go func() {
   578  		var (
   579  			lastNotifs  uint64
   580  			lastUpdates uint64
   581  			lastTime    = time.Now()
   582  		)
   583  		ticker := time.NewTicker(10 * time.Second)
   584  		for t := range ticker.C {
   585  			newNotifs := atomic.LoadUint64(&notifs)
   586  			newUpdates := atomic.LoadUint64(&updates)
   587  			dNotifs := newNotifs - lastNotifs
   588  			dUpdates := newUpdates - lastUpdates
   589  			since := t.Sub(lastTime).Seconds()
   590  			lastNotifs = newNotifs
   591  			lastUpdates = newUpdates
   592  			lastTime = t
   593  			fmt.Printf("%s: %f notifs/s %f updates/s\n",
   594  				t, float64(dNotifs)/since, float64(dUpdates)/since)
   595  		}
   596  	}()
   597  
   598  	for resp := range respChan {
   599  		r, ok := resp.Response.(*pb.SubscribeResponse_Update)
   600  		if !ok {
   601  			continue
   602  		}
   603  		notif := r.Update
   604  		atomic.AddUint64(&notifs, 1)
   605  		atomic.AddUint64(&updates, uint64(len(notif.Update)+len(notif.Delete)))
   606  	}
   607  	return
   608  }