Repository: brimsec/zq
Pull request #2033 (2021-01-29 16:35:01)

Fix shape bug with nested records
I ran into this bug while working on fuse.


Patch:
diff --git a/api/api.go b/api/api.go
index 5f40d37ea..a4c10f4f6 100644
--- a/api/api.go
+++ b/api/api.go
@@ -136,11 +136,6 @@ type SpacePostRequest struct {
 	Storage  *StorageConfig `json:"storage,omitempty"`
 }
 
-type SubspacePostRequest struct {
-	Name        string             `json:"name"`
-	OpenOptions ArchiveOpenOptions `json:"open_options"`
-}
-
 type SpacePutRequest struct {
 	Name string `json:"name"`
 }
@@ -314,14 +309,9 @@ type StorageConfig struct {
 }
 
 type ArchiveConfig struct {
-	OpenOptions   *ArchiveOpenOptions   `json:"open_options,omitempty"`
 	CreateOptions *ArchiveCreateOptions `json:"create_options,omitempty"`
 }
 
-type ArchiveOpenOptions struct {
-	LogFilter []string `json:"log_filter,omitempty"`
-}
-
 type ArchiveCreateOptions struct {
 	LogSizeThreshold *int64 `json:"log_size_threshold,omitempty"`
 }
diff --git a/api/client/connection.go b/api/client/connection.go
index c48b079b8..b59cf3f5a 100644
--- a/api/client/connection.go
+++ b/api/client/connection.go
@@ -192,20 +192,6 @@ func (c *Connection) SpacePost(ctx context.Context, req api.SpacePostRequest) (*
 	return resp.Result().(*api.Space), nil
 }
 
-func (c *Connection) SubspacePost(ctx context.Context, id api.SpaceID, req api.SubspacePostRequest) (*api.Space, error) {
-	resp, err := c.Request(ctx).
-		SetBody(req).
-		SetResult(&api.Space{}).
-		Post(path.Join("/space", string(id), "subspace"))
-	if err != nil {
-		if r, ok := err.(*ErrorResponse); ok && r.StatusCode() == http.StatusConflict {
-			return nil, ErrSpaceExists
-		}
-		return nil, err
-	}
-	return resp.Result().(*api.Space), nil
-}
-
 func (c *Connection) SpacePut(ctx context.Context, id api.SpaceID, req api.SpacePutRequest) error {
 	_, err := c.Request(ctx).
 		SetBody(req).
diff --git a/cmd/zapi/cmd/newsubspace/newsubspace.go b/cmd/zapi/cmd/newsubspace/newsubspace.go
deleted file mode 100644
index b1bbbde50..000000000
--- a/cmd/zapi/cmd/newsubspace/newsubspace.go
+++ /dev/null
@@ -1,58 +0,0 @@
-package newsubspace
-
-import (
-	"errors"
-	"flag"
-	"fmt"
-
-	"github.com/brimsec/zq/api"
-	"github.com/brimsec/zq/cmd/zapi/cmd"
-	"github.com/mccanne/charm"
-)
-
-var NewSubspace = &charm.Spec{
-	Name:  "newsubspace",
-	Usage: "newsubspace -p parent_space_id -n subspace_name log1 [log2 ...]",
-	Short: "create a new subspace",
-	Long:  `Creates a subspace of the given logs from the parent space.`,
-	New:   New,
-}
-
-func init() {
-	cmd.CLI.Add(NewSubspace)
-}
-
-type Command struct {
-	*cmd.Command
-	parentID string
-	name     string
-}
-
-func New(parent charm.Command, f *flag.FlagSet) (charm.Command, error) {
-	c := &Command{Command: parent.(*cmd.Command)}
-	f.StringVar(&c.parentID, "p", "", "id of parent space")
-	f.StringVar(&c.name, "n", "", "name for subspace")
-	return c, nil
-}
-
-func (c *Command) Run(args []string) error {
-	if len(args) == 0 {
-		return errors.New("must specify at least one log from parent")
-	}
-	defer c.Cleanup()
-	if err := c.Init(); err != nil {
-		return err
-	}
-	conn := c.Connection()
-	req := api.SubspacePostRequest{
-		Name: c.name,
-		OpenOptions: api.ArchiveOpenOptions{
-			LogFilter: args,
-		},
-	}
-	if _, err := conn.SubspacePost(c.Context(), api.SpaceID(c.parentID), req); err != nil {
-		return fmt.Errorf("couldn't create subspace %s: %w", c.name, err)
-	}
-	fmt.Printf("%s: subspace created\n", c.name)
-	return nil
-}
diff --git a/cmd/zapi/main.go b/cmd/zapi/main.go
index d1d0dc536..85529aca3 100644
--- a/cmd/zapi/main.go
+++ b/cmd/zapi/main.go
@@ -10,7 +10,6 @@ import (
 	_ "github.com/brimsec/zq/cmd/zapi/cmd/index"
 	_ "github.com/brimsec/zq/cmd/zapi/cmd/info"
 	_ "github.com/brimsec/zq/cmd/zapi/cmd/new"
-	_ "github.com/brimsec/zq/cmd/zapi/cmd/newsubspace"
 	_ "github.com/brimsec/zq/cmd/zapi/cmd/post"
 	_ "github.com/brimsec/zq/cmd/zapi/cmd/rename"
 	_ "github.com/brimsec/zq/cmd/zapi/cmd/repl"
diff --git a/expr/shaper.go b/expr/shaper.go
index 2e4548b55..18481b09b 100644
--- a/expr/shaper.go
+++ b/expr/shaper.go
@@ -125,7 +125,9 @@ func (s op) buildRecord(in zcode.Bytes, b *zcode.Builder) {
 		case castPrimitive:
 			step.castPrimitive(bytes, b)
 		case record:
+			b.BeginContainer()
 			step.buildRecord(bytes, b)
+			b.EndContainer()
 		}
 	}
 }
diff --git a/go.mod b/go.mod
index 83c8bfc0d..4c70e45ba 100644
--- a/go.mod
+++ b/go.mod
@@ -18,6 +18,7 @@ require (
 	github.com/google/gopacket v1.1.17
 	github.com/gorilla/mux v1.7.5-0.20200711200521-98cb6bf42e08
 	github.com/gosuri/uilive v0.0.4
+	github.com/hashicorp/golang-lru v0.5.4
 	github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
 	github.com/klauspost/compress v1.10.3 // indirect
 	github.com/mattn/go-isatty v0.0.12 // indirect
@@ -30,6 +31,7 @@ require (
 	github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4
 	github.com/pmezard/go-difflib v1.0.0
 	github.com/prometheus/client_golang v1.7.1
+	github.com/prometheus/client_model v0.2.0
 	github.com/segmentio/ksuid v1.0.2
 	github.com/stretchr/testify v1.6.1
 	github.com/xitongsys/parquet-go v1.5.3-0.20200514000040-789bba367841
diff --git a/go.sum b/go.sum
index f1526b1b3..702ac3b81 100644
--- a/go.sum
+++ b/go.sum
@@ -220,6 +220,8 @@ github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+
 github.com/hashicorp/go-uuid v0.0.0-20180228145832-27454136f036/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
 github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
 github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
+github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
 github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
 github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
 github.com/influxdata/influxdb v1.7.6/go.mod h1:qZna6X/4elxqT3yI9iZYdZrWWdeFOOprn86kgg4+IzY=
diff --git a/pkg/iosrc/iosrc.go b/pkg/iosrc/iosrc.go
index 15ce3ddbe..c8dca01a2 100644
--- a/pkg/iosrc/iosrc.go
+++ b/pkg/iosrc/iosrc.go
@@ -4,8 +4,6 @@ package iosrc
 
 import (
 	"context"
-	"errors"
-	"fmt"
 	"io"
 	"os"
 	"time"
@@ -13,11 +11,11 @@ import (
 
 const FileScheme = "file"
 
-var schemes = map[string]Source{
+var DefaultMuxSource = NewMuxSource(map[string]Source{
 	"file":  DefaultFileSource,
 	"stdio": defaultStdioSource,
 	"s3":    defaultS3Source,
-}
+})
 
 type Reader interface {
 	io.Reader
@@ -62,122 +60,49 @@ type ReplacerAble interface {
 }
 
 func NewReader(ctx context.Context, uri URI) (Reader, error) {
-	source, err := GetSource(uri)
-	if err != nil {
-		return nil, err
-	}
-	return source.NewReader(ctx, uri)
+	return DefaultMuxSource.NewReader(ctx, uri)
 }
 
 func NewWriter(ctx context.Context, uri URI) (io.WriteCloser, error) {
-	source, err := GetSource(uri)
-	if err != nil {
-		return nil, err
-	}
-	return source.NewWriter(ctx, uri)
+	return DefaultMuxSource.NewWriter(ctx, uri)
 }
 
 func ReadFile(ctx context.Context, uri URI) ([]byte, error) {
-	source, err := GetSource(uri)
-	if err != nil {
-		return nil, err
-	}
-	return source.ReadFile(ctx, uri)
+	return DefaultMuxSource.ReadFile(ctx, uri)
 }
 
 func WriteFile(ctx context.Context, uri URI, d []byte) error {
-	source, err := GetSource(uri)
-	if err != nil {
-		return err
-	}
-	return source.WriteFile(ctx, d, uri)
+	return DefaultMuxSource.WriteFile(ctx, uri, d)
 }
 
 func Exists(ctx context.Context, uri URI) (bool, error) {
-	source, err := GetSource(uri)
-	if err != nil {
-		return false, err
-	}
-	return source.Exists(ctx, uri)
+	return DefaultMuxSource.Exists(ctx, uri)
 }
 
 func Remove(ctx context.Context, uri URI) error {
-	source, err := GetSource(uri)
-	if err != nil {
-		return err
-	}
-	return source.Remove(ctx, uri)
+	return DefaultMuxSource.Remove(ctx, uri)
 }
 
 func RemoveAll(ctx context.Context, uri URI) error {
-	source, err := GetSource(uri)
-	if err != nil {
-		return err
-	}
-	return source.RemoveAll(ctx, uri)
+	return DefaultMuxSource.RemoveAll(ctx, uri)
 }
 
 func Stat(ctx context.Context, uri URI) (Info, error) {
-	source, err := GetSource(uri)
-	if err != nil {
-		return nil, err
-	}
-	return source.Stat(ctx, uri)
+	return DefaultMuxSource.Stat(ctx, uri)
 }
 
 func ReadDir(ctx context.Context, uri URI) ([]Info, error) {
-	source, err := GetSource(uri)
-	if err != nil {
-		return nil, err
-	}
-	return source.ReadDir(ctx, uri)
+	return DefaultMuxSource.ReadDir(ctx, uri)
 }
 
 func GetSource(uri URI) (Source, error) {
-	scheme := getScheme(uri)
-	source, ok := schemes[scheme]
-	if !ok {
-		return nil, fmt.Errorf("unknown scheme: %q", scheme)
-	}
-	return source, nil
+	return DefaultMuxSource.GetSource(uri)
 }
 
 func Replace(ctx context.Context, uri URI, fn func(w io.Writer) error) error {
-	src, err := GetSource(uri)
-	if err != nil {
-		return err
-	}
-	replacerAble, ok := src.(ReplacerAble)
-	if !ok {
-		return errors.New("source does not support replacement")
-	}
-	r, err := replacerAble.NewReplacer(ctx, uri)
-	if err != nil {
-		return err
-	}
-	if err := fn(r); err != nil {
-		r.Abort()
-		return err
-	}
-	return r.Close()
-}
-
-// MkdirAll will run Source.MkdirAll on the provided URI if the URI's source
-// is a DirMaker, otherwise it will do nothing.
+	return DefaultMuxSource.Replace(ctx, uri, fn)
+}
+
 func MkdirAll(uri URI, mode os.FileMode) error {
-	src, err := GetSource(uri)
-	if err != nil {
-		return err
-	}
-	if mkr, ok := src.(DirMaker); ok {
-		err = mkr.MkdirAll(uri, mode)
-	}
-	return err
-}
-
-func getScheme(uri URI) string {
-	if uri.Scheme == "" {
-		return FileScheme
-	}
-	return uri.Scheme
+	return DefaultMuxSource.MkdirAll(uri, mode)
 }
diff --git a/pkg/iosrc/mux.go b/pkg/iosrc/mux.go
new file mode 100644
index 000000000..0f4329cc4
--- /dev/null
+++ b/pkg/iosrc/mux.go
@@ -0,0 +1,144 @@
+package iosrc
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"os"
+)
+
+// MuxSource is a Source that routes each function call to the correct Source
+// based off the provided URI's scheme.
+type MuxSource struct {
+	schemes map[string]Source
+}
+
+func NewMuxSource(m map[string]Source) *MuxSource {
+	return &MuxSource{m}
+}
+
+func (m *MuxSource) AddScheme(scheme string, source Source) {
+	m.schemes[scheme] = source
+}
+
+func getScheme(uri URI) string {
+	if uri.Scheme == "" {
+		return FileScheme
+	}
+	return uri.Scheme
+}
+
+func (m *MuxSource) GetSource(u URI) (Source, error) {
+	scheme := getScheme(u)
+	source, ok := m.schemes[scheme]
+	if !ok {
+		return nil, fmt.Errorf("unknown scheme: %q", scheme)
+	}
+	return source, nil
+}
+
+func (m *MuxSource) NewReader(ctx context.Context, uri URI) (Reader, error) {
+	source, err := m.GetSource(uri)
+	if err != nil {
+		return nil, err
+	}
+	return source.NewReader(ctx, uri)
+}
+
+func (m *MuxSource) NewWriter(ctx context.Context, uri URI) (io.WriteCloser, error) {
+	source, err := m.GetSource(uri)
+	if err != nil {
+		return nil, err
+	}
+	return source.NewWriter(ctx, uri)
+}
+
+func (m *MuxSource) ReadFile(ctx context.Context, uri URI) ([]byte, error) {
+	source, err := m.GetSource(uri)
+	if err != nil {
+		return nil, err
+	}
+	return source.ReadFile(ctx, uri)
+}
+
+func (m *MuxSource) WriteFile(ctx context.Context, uri URI, d []byte) error {
+	source, err := m.GetSource(uri)
+	if err != nil {
+		return err
+	}
+	return source.WriteFile(ctx, d, uri)
+}
+
+func (m *MuxSource) Exists(ctx context.Context, uri URI) (bool, error) {
+	source, err := m.GetSource(uri)
+	if err != nil {
+		return false, err
+	}
+	return source.Exists(ctx, uri)
+}
+
+func (m *MuxSource) Remove(ctx context.Context, uri URI) error {
+	source, err := m.GetSource(uri)
+	if err != nil {
+		return err
+	}
+	return source.Remove(ctx, uri)
+}
+
+func (m *MuxSource) RemoveAll(ctx context.Context, uri URI) error {
+	source, err := m.GetSource(uri)
+	if err != nil {
+		return err
+	}
+	return source.RemoveAll(ctx, uri)
+}
+
+func (m *MuxSource) Stat(ctx context.Context, uri URI) (Info, error) {
+	source, err := m.GetSource(uri)
+	if err != nil {
+		return nil, err
+	}
+	return source.Stat(ctx, uri)
+}
+
+func (m *MuxSource) ReadDir(ctx context.Context, uri URI) ([]Info, error) {
+	source, err := m.GetSource(uri)
+	if err != nil {
+		return nil, err
+	}
+	return source.ReadDir(ctx, uri)
+}
+
+func (m *MuxSource) Replace(ctx context.Context, uri URI, fn func(w io.Writer) error) error {
+	src, err := m.GetSource(uri)
+	if err != nil {
+		return err
+	}
+	replacerAble, ok := src.(ReplacerAble)
+	if !ok {
+		return errors.New("source does not support replacement")
+	}
+	r, err := replacerAble.NewReplacer(ctx, uri)
+	if err != nil {
+		return err
+	}
+	if err := fn(r); err != nil {
+		r.Abort()
+		return err
+	}
+	return r.Close()
+}
+
+// MkdirAll will run Source.MkdirAll on the provided URI if the URI's source
+// is a DirMaker, otherwise it will do nothing.
+func (m *MuxSource) MkdirAll(uri URI, mode os.FileMode) error {
+	src, err := m.GetSource(uri)
+	if err != nil {
+		return err
+	}
+	if mkr, ok := src.(DirMaker); ok {
+		err = mkr.MkdirAll(uri, mode)
+	}
+	return err
+}
diff --git a/ppl/archive/chunk/chunk.go b/ppl/archive/chunk/chunk.go
index 73cce744a..19eb27151 100644
--- a/ppl/archive/chunk/chunk.go
+++ b/ppl/archive/chunk/chunk.go
@@ -24,6 +24,19 @@ const (
 	FileKindSeek     FileKind = "ts"
 )
 
+func (k FileKind) Description() string {
+	switch k {
+	case FileKindData:
+		return "data"
+	case FileKindMetadata:
+		return "metadata"
+	case "FileKindSeek":
+		return "seekindex"
+	default:
+		return "unknown"
+	}
+}
+
 var fileRegex = regexp.MustCompile(`(d|m)-([0-9A-Za-z]{27}).zng$`)
 
 func FileMatch(s string) (kind FileKind, id ksuid.KSUID, ok bool) {
@@ -68,7 +81,11 @@ type Chunk struct {
 }
 
 func Open(ctx context.Context, dir iosrc.URI, id ksuid.KSUID, order zbuf.Order) (Chunk, error) {
-	meta, err := ReadMetadata(ctx, MetadataPath(dir, id), order)
+	b, err := iosrc.ReadFile(ctx, MetadataPath(dir, id))
+	if err != nil {
+		return Chunk{}, err
+	}
+	meta, err := UnmarshalMetadata(b, order)
 	if err != nil {
 		return Chunk{}, err
 	}
diff --git a/ppl/archive/chunk/metadata.go b/ppl/archive/chunk/metadata.go
index e39015b8b..70d563103 100644
--- a/ppl/archive/chunk/metadata.go
+++ b/ppl/archive/chunk/metadata.go
@@ -1,6 +1,7 @@
 package chunk
 
 import (
+	"bytes"
 	"context"
 	"fmt"
 
@@ -21,14 +22,9 @@ type Metadata struct {
 	Size        int64
 }
 
-func ReadMetadata(ctx context.Context, uri iosrc.URI, order zbuf.Order) (Metadata, error) {
-	in, err := iosrc.NewReader(ctx, uri)
-	if err != nil {
-		return Metadata{}, err
-	}
-	defer in.Close()
+func UnmarshalMetadata(b []byte, order zbuf.Order) (Metadata, error) {
 	zctx := resolver.NewContext()
-	zr := zngio.NewReader(in, zctx)
+	zr := zngio.NewReader(bytes.NewReader(b), zctx)
 	rec, err := zr.Read()
 	if err != nil {
 		return Metadata{}, err
@@ -37,7 +33,7 @@ func ReadMetadata(ctx context.Context, uri iosrc.URI, order zbuf.Order) (Metadat
 	if err := resolver.UnmarshalRecord(rec, &md); err != nil {
 		return Metadata{}, err
 	}
-	if err := mdTsOrderCheck(uri, "read", order, md.First, md.Last); err != nil {
+	if err := mdTsOrderCheck("read", order, md.First, md.Last); err != nil {
 		return Metadata{}, err
 	}
 	return md, nil
@@ -56,7 +52,7 @@ func (m Metadata) Chunk(dir iosrc.URI, id ksuid.KSUID) Chunk {
 }
 
 func (m Metadata) Write(ctx context.Context, uri iosrc.URI, order zbuf.Order) error {
-	if err := mdTsOrderCheck(uri, "write", order, m.First, m.Last); err != nil {
+	if err := mdTsOrderCheck("write", order, m.First, m.Last); err != nil {
 		return err
 	}
 	rec, err := resolver.NewMarshaler().MarshalRecord(m)
@@ -79,7 +75,7 @@ func MetadataPath(dir iosrc.URI, id ksuid.KSUID) iosrc.URI {
 	return dir.AppendPath(fmt.Sprintf("%s-%s.zng", FileKindMetadata, id))
 }
 
-func mdTsOrderCheck(u iosrc.URI, op string, order zbuf.Order, first, last nano.Ts) error {
+func mdTsOrderCheck(op string, order zbuf.Order, first, last nano.Ts) error {
 	x, y := first, last
 	if order == zbuf.OrderDesc {
 		x, y = y, x
@@ -87,5 +83,5 @@ func mdTsOrderCheck(u iosrc.URI, op string, order zbuf.Order, first, last nano.T
 	if x <= y {
 		return nil
 	}
-	return fmt.Errorf("metadata failed order check %s op %s order %s first %d last %d", u, op, order, first, last)
+	return fmt.Errorf("metadata failed order check op %s order %s first %d last %d", op, order, first, last)
 }
diff --git a/ppl/archive/immcache/immcache.go b/ppl/archive/immcache/immcache.go
new file mode 100644
index 000000000..a30d72437
--- /dev/null
+++ b/ppl/archive/immcache/immcache.go
@@ -0,0 +1,33 @@
+// Package immcache contains facilities for caching immutable files for an
+// archive.
+package immcache
+
+import (
+	"context"
+	"flag"
+
+	"github.com/brimsec/zq/pkg/iosrc"
+	"github.com/prometheus/client_golang/prometheus"
+)
+
+type ImmutableCache interface {
+	ReadFile(context.Context, iosrc.URI) ([]byte, error)
+}
+
+type Config struct {
+	// LocalCacheSize specifies the number of immutable files to keep in a
+	// local lru cache used to speed up searches. Values less than or equal to 0
+	// (default), disables local caching of immutable files.
+	LocalCacheSize int
+}
+
+func (c *Config) SetFlags(fs *flag.FlagSet) {
+	fs.IntVar(&c.LocalCacheSize, "immcache.localsize", 0, "enables local caching of up to N small files (disabled if 0)")
+}
+
+func New(conf Config, reg prometheus.Registerer) (ImmutableCache, error) {
+	if conf.LocalCacheSize > 0 {
+		return NewLocalCache(conf.LocalCacheSize, reg)
+	}
+	return nil, nil
+}
diff --git a/ppl/archive/immcache/local.go b/ppl/archive/immcache/local.go
new file mode 100644
index 000000000..223cb35cb
--- /dev/null
+++ b/ppl/archive/immcache/local.go
@@ -0,0 +1,61 @@
+package immcache
+
+import (
+	"context"
+	"path"
+
+	"github.com/brimsec/zq/pkg/iosrc"
+	"github.com/brimsec/zq/ppl/archive/chunk"
+	lru "github.com/hashicorp/golang-lru"
+	"github.com/prometheus/client_golang/prometheus"
+	"github.com/prometheus/client_golang/prometheus/promauto"
+)
+
+type LocalCache struct {
+	lru    *lru.ARCCache
+	hits   *prometheus.CounterVec
+	misses *prometheus.CounterVec
+}
+
+func NewLocalCache(size int, registerer prometheus.Registerer) (*LocalCache, error) {
+	lru, err := lru.NewARC(size)
+	if err != nil {
+		return nil, err
+	}
+	if registerer == nil {
+		registerer = prometheus.NewRegistry()
+	}
+	factory := promauto.With(registerer)
+	return &LocalCache{
+		lru: lru,
+		hits: factory.NewCounterVec(
+			prometheus.CounterOpts{
+				Name: "archive_cache_hits_total",
+				Help: "Number of hits for a cache lookup.",
+			},
+			[]string{"kind"},
+		),
+		misses: factory.NewCounterVec(
+			prometheus.CounterOpts{
+				Name: "archive_cache_misses_total",
+				Help: "Number of misses for a cache lookup.",
+			},
+			[]string{"kind"},
+		),
+	}, nil
+}
+
+func (c *LocalCache) ReadFile(ctx context.Context, u iosrc.URI) ([]byte, error) {
+	kind, _, _ := chunk.FileMatch(path.Base(u.Path))
+	if v, ok := c.lru.Get(u.String()); ok {
+		c.hits.WithLabelValues(kind.Description()).Inc()
+		return v.([]byte), nil
+	}
+	b, err := iosrc.ReadFile(ctx, u)
+	if err != nil {
+		return nil, err
+	}
+	c.lru.Add(u.String(), b)
+	c.misses.WithLabelValues(kind.Description()).Inc()
+	return b, nil
+}
diff --git a/ppl/archive/multisource.go b/ppl/archive/multisource.go
index f91037544..c201b99d4 100644
--- a/ppl/archive/multisource.go
+++ b/ppl/archive/multisource.go
@@ -153,7 +153,13 @@ func (m *spanMultiSource) SourceFromRequest(ctx context.Context, req *api.Worker
 		}
 		uri := tsdir.path(m.ark)
 		mdPath := chunk.MetadataPath(uri, id)
-		md, err := chunk.ReadMetadata(ctx, mdPath, m.ark.DataOrder)
+
+		b, err := m.ark.immfiles.ReadFile(ctx, mdPath)
+		if err != nil {
+			return nil, err
+		}
+
+		md, err := chunk.UnmarshalMetadata(b, m.ark.DataOrder)
 		if err != nil {
 			return nil, zqe.E("failed to read chunk metadata from %s: %w", mdPath.String(), err)
 		}
diff --git a/ppl/archive/schema.go b/ppl/archive/schema.go
index 77c6d73a3..e8e6c7879 100644
--- a/ppl/archive/schema.go
+++ b/ppl/archive/schema.go
@@ -8,7 +8,7 @@ import (
 	"time"
 
 	"github.com/brimsec/zq/pkg/iosrc"
-	"github.com/brimsec/zq/ppl/archive/chunk"
+	"github.com/brimsec/zq/ppl/archive/immcache"
 	"github.com/brimsec/zq/ppl/archive/index"
 	"github.com/brimsec/zq/zbuf"
 	"github.com/brimsec/zq/zqe"
@@ -95,6 +95,10 @@ type Archive struct {
 	DataOrder        zbuf.Order
 	LogSizeThreshold int64
 	LogFilter        []ksuid.KSUID
+
+	immfiles interface {
+		ReadFile(context.Context, iosrc.URI) ([]byte, error)
+	}
 }
 
 func (ark *Archive) metaWrite() error {
@@ -136,7 +140,7 @@ func (ark *Archive) ReadDefinitions(ctx context.Context) (index.Definitions, err
 }
 
 type OpenOptions struct {
-	LogFilter []string
+	ImmutableCache immcache.ImmutableCache
 }
 
 func OpenArchive(rpath string, oo *OpenOptions) (*Archive, error) {
@@ -169,16 +173,11 @@ func openArchive(ctx context.Context, root iosrc.URI, oo *OpenOptions) (*Archive
 		DataPath:         dpuri,
 		LogSizeThreshold: m.LogSizeThreshold,
 		Root:             root,
+		immfiles:         iosrc.DefaultMuxSource,
 	}
 
-	if oo != nil {
-		for _, l := range oo.LogFilter {
-			_, id, ok := chunk.FileMatch(l)
-			if !ok {
-				return nil, zqe.E(zqe.Invalid, "log filter %s not a chunk file name", l)
-			}
-			ark.LogFilter = append(ark.LogFilter, id)
-		}
+	if oo != nil && oo.ImmutableCache != nil {
+		ark.immfiles = oo.ImmutableCache
 	}
 
 	return ark, nil
diff --git a/ppl/archive/walk.go b/ppl/archive/walk.go
index a17eb77ff..0b474ee65 100644
--- a/ppl/archive/walk.go
+++ b/ppl/archive/walk.go
@@ -118,7 +118,12 @@ func tsDirEntriesToChunks(ctx context.Context, ark *Archive, filterSpan nano.Spa
 		}
 		dir := tsDir.path(ark)
 		mdPath := chunk.MetadataPath(dir, id)
-		md, err := chunk.ReadMetadata(ctx, mdPath, ark.DataOrder)
+		b, err := ark.immfiles.ReadFile(ctx, mdPath)
+		if err != nil {
+			return nil, err
+		}
+
+		md, err := chunk.UnmarshalMetadata(b, ark.DataOrder)
 		if err != nil {
 			if zqe.IsNotFound(err) {
 				continue
diff --git a/ppl/cmd/zqd/listen/command.go b/ppl/cmd/zqd/listen/command.go
index 936ceaf16..6e3e3a8f0 100644
--- a/ppl/cmd/zqd/listen/command.go
+++ b/ppl/cmd/zqd/listen/command.go
@@ -71,6 +71,7 @@ func New(parent charm.Command, f *flag.FlagSet) (charm.Command, error) {
 	c.conf.Auth.SetFlags(f)
 	c.conf.Worker.SetFlags(f)
 	c.conf.DB.SetFlags(f)
+	c.conf.ImmutableCache.SetFlags(f)
 	c.conf.Version = cli.Version
 	f.IntVar(&c.brimfd, "brimfd", -1, "pipe read fd passed by brim to signal brim closure")
 	f.StringVar(&c.configfile, "config", "", "path to zqd config file")
diff --git a/ppl/zqd/apiserver/manager.go b/ppl/zqd/apiserver/manager.go
index 09a39900f..68634c638 100644
--- a/ppl/zqd/apiserver/manager.go
+++ b/ppl/zqd/apiserver/manager.go
@@ -9,6 +9,7 @@ import (
 	"github.com/brimsec/zq/api"
 	"github.com/brimsec/zq/pkg/iosrc"
 	"github.com/brimsec/zq/pkg/nano"
+	"github.com/brimsec/zq/ppl/archive/immcache"
 	"github.com/brimsec/zq/ppl/zqd/auth"
 	"github.com/brimsec/zq/ppl/zqd/db"
 	"github.com/brimsec/zq/ppl/zqd/db/schema"
@@ -26,7 +27,9 @@ type Manager struct {
 	alphaFileMigrator *filestore.Migrator
 	compactor         *compactor
 	db                db.DB
+	immcache          immcache.ImmutableCache
 	logger            *zap.Logger
+	registerer        prometheus.Registerer
 	rootPath          iosrc.URI
 
 	// We keep instances of any loaded filestore because the seek indexes
@@ -40,12 +43,14 @@ type Manager struct {
 	deleted prometheus.Counter
 }
 
-func NewManager(ctx context.Context, logger *zap.Logger, registerer prometheus.Registerer, root iosrc.URI, db db.DB) (*Manager, error) {
+func NewManager(ctx context.Context, logger *zap.Logger, registerer prometheus.Registerer, root iosrc.URI, db db.DB, icache immcache.ImmutableCache) (*Manager, error) {
 	factory := promauto.With(registerer)
 	m := &Manager{
-		logger:   logger.Named("manager"),
-		rootPath: root,
-		db:       db,
+		db:         db,
+		immcache:   icache,
+		logger:     logger.Named("manager"),
+		registerer: registerer,
+		rootPath:   root,
 
 		alphaFileMigrator: filestore.NewMigrator(ctx),
 		filestores:        make(map[api.SpaceID]*filestore.Storage),
@@ -156,46 +161,6 @@ func (m *Manager) CreateSpace(ctx context.Context, req api.SpacePostRequest) (ap
 	return si, nil
 }
 
-func (m *Manager) CreateSubspace(ctx context.Context, parentID api.SpaceID, req api.SubspacePostRequest) (api.Space, error) {
-	if req.Name == "" {
-		return api.Space{}, zqe.E(zqe.Invalid, "cannot set name to an empty string")
-	}
-	if !schema.ValidSpaceName(req.Name) {
-		return api.Space{}, zqe.E(zqe.Invalid, "name may not contain '/' or non-printable characters")
-	}
-	parent, err := m.db.GetSpace(ctx, parentID)
-	if err != nil {
-		return api.Space{}, err
-	}
-	if parent.Storage.Kind != api.ArchiveStore {
-		return api.Space{}, zqe.E(zqe.Invalid, "space does not support creating subspaces")
-	}
-	id := schema.NewSpaceID()
-	cfg := api.StorageConfig{
-		Kind: api.ArchiveStore,
-		Archive: &api.ArchiveConfig{
-			OpenOptions: &req.OpenOptions,
-		},
-	}
-	if _, err := m.getStorage(ctx, id, parent.DataURI, cfg); err != nil {
-		return api.Space{}, zqe.ErrInvalid("invalid subspace storage config: %w", err)
-	}
-	ident := auth.IdentityFromContext(ctx)
-	row := schema.SpaceRow{
-		ID:       id,
-		ParentID: parentID,
-		Name:     req.Name,
-		DataURI:  parent.DataURI,
-		Storage:  cfg,
-		TenantID: ident.TenantID,
-	}
-	if err := m.db.CreateSubspace(ctx, row); err != nil {
-		return api.Space{}, err
-	}
-	m.created.Inc()
-	return rowToSpace(row), nil
-}
-
 func (m *Manager) GetStorage(ctx context.Context, id api.SpaceID) (storage.Storage, error) {
 	sr, err := m.getSpacePermCheck(ctx, id)
 	if err != nil {
@@ -238,7 +203,7 @@ func (m *Manager) getStorage(ctx context.Context, id api.SpaceID, daturi iosrc.U
 		return st, nil
 	case api.ArchiveStore:
 		wn := &writeNotifier{c: m.compactor, id: id}
-		return archivestore.Load(ctx, daturi, wn, cfg.Archive)
+		return archivestore.Load(ctx, daturi, wn, cfg.Archive, m.immcache)
 	default:
 		return nil, zqe.E(zqe.Invalid, "unknown storage kind: %s", cfg.Kind)
 	}
@@ -260,7 +225,6 @@ func rowToSpace(row schema.SpaceRow) api.Space {
 		ID:          row.ID,
 		DataPath:    row.DataURI,
 		Name:        row.Name,
-		ParentID:    row.ParentID,
 		StorageKind: row.Storage.Kind,
 	}
 }
diff --git a/ppl/zqd/core.go b/ppl/zqd/core.go
index 87dba532c..1896c1406 100644
--- a/ppl/zqd/core.go
+++ b/ppl/zqd/core.go
@@ -13,6 +13,7 @@ import (
 
 	"github.com/brimsec/zq/api"
 	"github.com/brimsec/zq/pkg/iosrc"
+	"github.com/brimsec/zq/ppl/archive/immcache"
 	"github.com/brimsec/zq/ppl/zqd/apiserver"
 	"github.com/brimsec/zq/ppl/zqd/db"
 	"github.com/brimsec/zq/ppl/zqd/pcapanalyzer"
@@ -37,13 +38,14 @@ const indexPage = `
 </html>`
 
 type Config struct {
-	Auth        AuthConfig
-	DB          db.Config
-	Logger      *zap.Logger
-	Personality string
-	Root        string
-	Version     string
-	Worker      worker.WorkerConfig
+	Auth           AuthConfig
+	DB             db.Config
+	ImmutableCache immcache.Config
+	Logger         *zap.Logger
+	Personality    string
+	Root           string
+	Version        string
+	Worker         worker.WorkerConfig
 
 	Suricata pcapanalyzer.Launcher
 	Zeek     pcapanalyzer.Launcher
@@ -156,7 +158,11 @@ func (c *Core) addAPIServerRoutes(ctx context.Context, conf Config) (err error)
 	if err != nil {
 		return err
 	}
-	if c.mgr, err = apiserver.NewManager(ctx, conf.Logger, c.registry, c.root, c.db); err != nil {
+	icache, err := immcache.New(conf.ImmutableCache, c.registry)
+	if err != nil {
+		return err
+	}
+	if c.mgr, err = apiserver.NewManager(ctx, conf.Logger, c.registry, c.root, c.db, icache); err != nil {
 		return err
 	}
 	c.authhandle("/ast", handleASTPost).Methods("POST")
@@ -176,7 +182,6 @@ func (c *Core) addAPIServerRoutes(ctx context.Context, conf Config) (err error)
 	c.authhandle("/space/{space}/log/paths", handleLogPost).Methods("POST")
 	c.authhandle("/space/{space}/pcap", handlePcapPost).Methods("POST")
 	c.authhandle("/space/{space}/pcap", handlePcapSearch).Methods("GET")
-	c.authhandle("/space/{space}/subspace", handleSubspacePost).Methods("POST")
 	return nil
 }
 
diff --git a/ppl/zqd/db/db.go b/ppl/zqd/db/db.go
index 37753d000..3b21ad5d5 100644
--- a/ppl/zqd/db/db.go
+++ b/ppl/zqd/db/db.go
@@ -16,7 +16,6 @@ import (
 
 type DB interface {
 	CreateSpace(context.Context, schema.SpaceRow) error
-	CreateSubspace(context.Context, schema.SpaceRow) error
 	GetSpace(context.Context, api.SpaceID) (schema.SpaceRow, error)
 	ListSpaces(context.Context, auth.TenantID) ([]schema.SpaceRow, error)
 	UpdateSpaceName(context.Context, api.SpaceID, string) error
diff --git a/ppl/zqd/db/filedb/filedb.go b/ppl/zqd/db/filedb/filedb.go
index 4718cd3f0..3fa176c75 100644
--- a/ppl/zqd/db/filedb/filedb.go
+++ b/ppl/zqd/db/filedb/filedb.go
@@ -89,43 +89,6 @@ func (db *FileDB) CreateSpace(ctx context.Context, row schema.SpaceRow) error {
 	return db.save(ctx, append(rows, row))
 }
 
-func (db *FileDB) CreateSubspace(ctx context.Context, row schema.SpaceRow) error {
-	if row.ID == "" {
-		return zqe.ErrInvalid("row must have an id")
-	}
-	if row.ParentID == "" {
-		return zqe.ErrInvalid("subspace must have parent id")
-	}
-
-	db.mu.Lock()
-	defer db.mu.Unlock()
-	rows, err := db.load(ctx)
-	if err != nil {
-		return err
-	}
-
-	parentIdx := -1
-	for i, r := range rows {
-		if row.ID == r.ID {
-			return zqe.ErrExists()
-		}
-		if r.TenantID != row.TenantID {
-			continue
-		}
-		if row.Name == r.Name {
-			return zqe.ErrConflict("space with name '%s' already exists", row.Name)
-		}
-		if row.ParentID == r.ID {
-			parentIdx = i
-		}
-	}
-	if parentIdx == -1 {
-		return zqe.ErrNotFound("subspace parent not found")
-	}
-
-	return db.save(ctx, append(rows, row))
-}
-
 func (db *FileDB) GetSpace(ctx context.Context, id api.SpaceID) (schema.SpaceRow, error) {
 	db.mu.Lock()
 	defer db.mu.Unlock()
@@ -201,9 +164,6 @@ func (db *FileDB) DeleteSpace(ctx context.Context, id api.SpaceID) error {
 		if rows[i].ID == id {
 			idx = i
 		}
-		if rows[i].ParentID == id {
-			return zqe.E(zqe.Conflict, "cannot delete space with subspaces")
-		}
 	}
 	if idx == -1 {
 		return zqe.ErrNotFound()
diff --git a/ppl/zqd/db/filedb/migrate.go b/ppl/zqd/db/filedb/migrate.go
index 6abee2091..dea53ba3b 100644
--- a/ppl/zqd/db/filedb/migrate.go
+++ b/ppl/zqd/db/filedb/migrate.go
@@ -15,7 +15,7 @@ import (
 	"go.uber.org/zap"
 )
 
-const currentVersion = 5
+const currentVersion = 6
 
 type dbdata struct {
 	Version   int               `json:"version"`
@@ -47,21 +47,6 @@ func migrateOldConfig(ctx context.Context, logger *zap.Logger, root iosrc.URI) e
 			DataURI: datauri,
 			Storage: config.Storage,
 		})
-		for _, subcfg := range config.Subspaces {
-			openopts := subcfg.OpenOptions
-			rows = append(rows, rowV4{
-				ID:       subcfg.ID,
-				ParentID: id,
-				Name:     subcfg.Name,
-				DataURI:  datauri,
-				Storage: api.StorageConfig{
-					Kind: api.ArchiveStore,
-					Archive: &api.ArchiveConfig{
-						OpenOptions: &openopts,
-					},
-				},
-			})
-		}
 	}
 	return iosrc.Replace(ctx, dburi, func(w io.Writer) error {
 		return json.NewEncoder(w).Encode(dbdataV4{
@@ -95,6 +80,8 @@ func migrateFileDatabase(ctx context.Context, dburi iosrc.URI) error {
 		switch version {
 		case 4:
 			migrator = migrateV5
+		case 5:
+			migrator = migrateV6
 		default:
 			return fmt.Errorf("unsupported database version %d", version)
 		}
@@ -124,11 +111,11 @@ func migrateV5(data []byte) (int, []byte, error) {
 	if err := json.Unmarshal(data, &dbv4); err != nil {
 		return 0, nil, err
 	}
-	var db dbdata
+	var db dbdataV5
 	db.Version = 5
-	db.SpaceRows = make([]schema.SpaceRow, 0, len(dbv4.SpaceRows))
+	db.SpaceRows = make([]rowV5, 0, len(dbv4.SpaceRows))
 	for _, r := range dbv4.SpaceRows {
-		db.SpaceRows = append(db.SpaceRows, schema.SpaceRow{
+		db.SpaceRows = append(db.SpaceRows, rowV5{
 			TenantID: auth.AnonymousTenantID,
 			ID:       r.ID,
 			DataURI:  r.DataURI,
@@ -140,3 +127,38 @@ func migrateV5(data []byte) (int, []byte, error) {
 	data, err := json.Marshal(db)
 	return 5, data, err
 }
+
+type dbdataV5 struct {
+	Version   int     `json:"version"`
+	SpaceRows []rowV5 `json:"space_rows"`
+}
+
+type rowV5 struct {
+	ID       api.SpaceID       `json:"id"`
+	DataURI  iosrc.URI         `json:"data_uri"`
+	Name     string            `json:"name"`
+	ParentID api.SpaceID       `json:"parent_id"`
+	Storage  api.StorageConfig `json:"storage"`
+	TenantID auth.TenantID     `json:"tenant_id"`
+}
+
+func migrateV6(data []byte) (int, []byte, error) {
+	var dbv5 dbdataV5
+	if err := json.Unmarshal(data, &dbv5); err != nil {
+		return 0, nil, err
+	}
+	var db dbdata
+	db.Version = 6
+	db.SpaceRows = make([]schema.SpaceRow, 0, len(dbv5.SpaceRows))
+	for _, r := range dbv5.SpaceRows {
+		db.SpaceRows = append(db.SpaceRows, schema.SpaceRow{
+			ID:       r.ID,
+			DataURI:  r.DataURI,
+			Name:     r.Name,
+			Storage:  r.Storage,
+			TenantID: r.TenantID,
+		})
+	}
+	data, err := json.Marshal(db)
+	return 6, data, err
+}
diff --git a/ppl/zqd/db/filedb/oldconfig/oldconfig.go b/ppl/zqd/db/filedb/oldconfig/oldconfig.go
index f3c02fff7..3fe422447 100644
--- a/ppl/zqd/db/filedb/oldconfig/oldconfig.go
+++ b/ppl/zqd/db/filedb/oldconfig/oldconfig.go
@@ -24,20 +24,18 @@ const (
 )
 
 type ConfigV3 struct {
-	Version   int               `json:"version"`
-	Name      string            `json:"name"`
-	DataURI   iosrc.URI         `json:"data_uri"`
-	Storage   api.StorageConfig `json:"storage"`
-	Subspaces []subspaceConfig  `json:"subspaces"`
+	Version int               `json:"version"`
+	Name    string            `json:"name"`
+	DataURI iosrc.URI         `json:"data_uri"`
+	Storage api.StorageConfig `json:"storage"`
 }
 
 type ConfigV2 struct {
-	Version   int               `json:"version"`
-	Name      string            `json:"name"`
-	DataURI   iosrc.URI         `json:"data_uri"`
-	PcapPath  string            `json:"pcap_path"`
-	Storage   api.StorageConfig `json:"storage"`
-	Subspaces []subspaceConfig  `json:"subspaces"`
+	Version  int               `json:"version"`
+	Name     string            `json:"name"`
+	DataURI  iosrc.URI         `json:"data_uri"`
+	PcapPath string            `json:"pcap_path"`
+	Storage  api.StorageConfig `json:"storage"`
 }
 
 type ConfigV1 struct {
@@ -46,9 +44,8 @@ type ConfigV1 struct {
 	DataPath string `json:"data_path"`
 	// XXX PcapPath should be named pcap_path in json land. To avoid having to
 	// do a migration we'll keep this as-is for now.
-	PcapPath  string            `json:"packet_path"`
-	Storage   api.StorageConfig `json:"storage"`
-	Subspaces []subspaceConfig  `json:"subspaces"`
+	PcapPath string            `json:"packet_path"`
+	Storage  api.StorageConfig `json:"storage"`
 }
 
 // versionCheck is used to establish the version of the loaded config file.
@@ -57,12 +54,6 @@ type versionCheck struct {
 	Version int `json:"version"`
 }
 
-type subspaceConfig struct {
-	ID          api.SpaceID            `json:"id"`
-	Name        string                 `json:"name"`
-	OpenOptions api.ArchiveOpenOptions `json:"open_options"`
-}
-
 type configMigrator struct {
 	names map[string]api.SpaceID
 }
@@ -162,11 +153,10 @@ func migrateConfigV3(data []byte, spaceuri iosrc.URI) (int, []byte, error) {
 		}
 	}
 	c := ConfigV3{
-		Version:   3,
-		Name:      v2.Name,
-		DataURI:   v2.DataURI,
-		Storage:   v2.Storage,
-		Subspaces: v2.Subspaces,
+		Version: 3,
+		Name:    v2.Name,
+		DataURI: v2.DataURI,
+		Storage: v2.Storage,
 	}
 	d, err := json.Marshal(c)
 	return 3, d, err
@@ -185,12 +175,11 @@ func migrateConfigV2(data []byte, _ iosrc.URI) (int, []byte, error) {
 		return 0, nil, err
 	}
 	c := ConfigV2{
-		Version:   2,
-		Name:      v1.Name,
-		DataURI:   du,
-		PcapPath:  v1.PcapPath,
-		Storage:   v1.Storage,
-		Subspaces: v1.Subspaces,
+		Version:  2,
+		Name:     v1.Name,
+		DataURI:  du,
+		PcapPath: v1.PcapPath,
+		Storage:  v1.Storage,
 	}
 	d, err := json.Marshal(c)
 	return 2, d, err
diff --git a/ppl/zqd/db/postgresdb/migrations/20210128175553_drop_parent_id.down.sql b/ppl/zqd/db/postgresdb/migrations/20210128175553_drop_parent_id.down.sql
new file mode 100644
index 000000000..ba31af679
--- /dev/null
+++ b/ppl/zqd/db/postgresdb/migrations/20210128175553_drop_parent_id.down.sql
@@ -0,0 +1,1 @@
+ALTER TABLE space ADD parent_id text references space(id)
diff --git a/ppl/zqd/db/postgresdb/migrations/20210128175553_drop_parent_id.up.sql b/ppl/zqd/db/postgresdb/migrations/20210128175553_drop_parent_id.up.sql
new file mode 100644
index 000000000..9e873a576
--- /dev/null
+++ b/ppl/zqd/db/postgresdb/migrations/20210128175553_drop_parent_id.up.sql
@@ -0,0 +1,1 @@
+ALTER TABLE space DROP parent_id;
diff --git a/ppl/zqd/db/postgresdb/postgresdb.go b/ppl/zqd/db/postgresdb/postgresdb.go
index 3c449f116..b85ddaa01 100644
--- a/ppl/zqd/db/postgresdb/postgresdb.go
+++ b/ppl/zqd/db/postgresdb/postgresdb.go
@@ -30,9 +30,6 @@ func (d *PostgresDB) CreateSpace(ctx context.Context, row schema.SpaceRow) error
 	if row.ID == "" {
 		return zqe.ErrInvalid("row must have an id")
 	}
-	if row.ParentID != "" {
-		return zqe.ErrInvalid("parent id cannot be set for non-subspace spaces")
-	}
 
 	_, err := d.db.ModelContext(ctx, &row).Insert()
 	if IsUniqueViolation(err) {
@@ -41,18 +38,6 @@ func (d *PostgresDB) CreateSpace(ctx context.Context, row schema.SpaceRow) error
 	return err
 }
 
-func (d *PostgresDB) CreateSubspace(ctx context.Context, row schema.SpaceRow) error {
-	if row.ParentID == "" {
-		return zqe.ErrInvalid("subspace must have parent id")
-	}
-
-	err := d.CreateSpace(ctx, row)
-	if IsForeignKeyViolation(err) {
-		return zqe.ErrNotFound("subspace parent not found")
-	}
-	return err
-}
-
 func (d *PostgresDB) GetSpace(ctx context.Context, id api.SpaceID) (schema.SpaceRow, error) {
 	var space schema.SpaceRow
 	_, err := d.db.QueryOneContext(ctx, &space, "SELECT * FROM space WHERE id = ?", id)
diff --git a/ppl/zqd/db/schema/spacerow.go b/ppl/zqd/db/schema/spacerow.go
index 4ba926832..d55af8bb3 100644
--- a/ppl/zqd/db/schema/spacerow.go
+++ b/ppl/zqd/db/schema/spacerow.go
@@ -16,7 +16,6 @@ type SpaceRow struct {
 	ID        api.SpaceID       `json:"id"`
 	DataURI   iosrc.URI         `json:"data_uri"`
 	Name      string            `json:"name"`
-	ParentID  api.SpaceID       `json:"parent_id"`
 	Storage   api.StorageConfig `json:"storage"`
 	TenantID  auth.TenantID     `json:"tenant_id"`
 }
diff --git a/ppl/zqd/handlers.go b/ppl/zqd/handlers.go
index 4957e5def..b2f51e3b7 100644
--- a/ppl/zqd/handlers.go
+++ b/ppl/zqd/handlers.go
@@ -219,24 +219,6 @@ func handleSpacePost(c *Core, w http.ResponseWriter, r *http.Request) {
 	respond(c, w, r, http.StatusOK, info)
 }
 
-func handleSubspacePost(c *Core, w http.ResponseWriter, r *http.Request) {
-	var req api.SubspacePostRequest
-	if !request(c, w, r, &req) {
-		return
-	}
-	id, ok := extractSpaceID(c, w, r)
-	if !ok {
-		return
-	}
-
-	info, err := c.mgr.CreateSubspace(r.Context(), id, req)
-	if err != nil {
-		respondError(c, w, r, err)
-		return
-	}
-	respond(c, w, r, http.StatusOK, info)
-}
-
 func handleSpacePut(c *Core, w http.ResponseWriter, r *http.Request) {
 	var req api.SpacePutRequest
 	if !request(c, w, r, &req) {
diff --git a/ppl/zqd/storage/archivestore/archivestore.go b/ppl/zqd/storage/archivestore/archivestore.go
index deb4c6249..d3c772654 100644
--- a/ppl/zqd/storage/archivestore/archivestore.go
+++ b/ppl/zqd/storage/archivestore/archivestore.go
@@ -10,6 +10,7 @@ import (
 	"github.com/brimsec/zq/pkg/iosrc"
 	"github.com/brimsec/zq/ppl/archive"
 	"github.com/brimsec/zq/ppl/archive/chunk"
+	"github.com/brimsec/zq/ppl/archive/immcache"
 	"github.com/brimsec/zq/ppl/archive/index"
 	"github.com/brimsec/zq/ppl/zqd/storage"
 	"github.com/brimsec/zq/zbuf"
@@ -18,14 +19,13 @@ import (
 	"go.uber.org/zap"
 )
 
-func Load(ctx context.Context, path iosrc.URI, notifier WriteNotifier, cfg *api.ArchiveConfig) (*Storage, error) {
+func Load(ctx context.Context, path iosrc.URI, notifier WriteNotifier, cfg *api.ArchiveConfig, immcache immcache.ImmutableCache) (*Storage, error) {
 	co := &archive.CreateOptions{}
 	if cfg != nil && cfg.CreateOptions != nil {
 		co.LogSizeThreshold = cfg.CreateOptions.LogSizeThreshold
 	}
-	oo := &archive.OpenOptions{}
-	if cfg != nil && cfg.OpenOptions != nil {
-		oo.LogFilter = cfg.OpenOptions.LogFilter
+	oo := &archive.OpenOptions{
+		ImmutableCache: immcache,
 	}
 	ark, err := archive.CreateOrOpenArchiveWithContext(ctx, path.String(), co, oo)
 	if err != nil {


Test patch:
diff --git a/expr/ztests/shape-nested.yaml b/expr/ztests/shape-nested.yaml
new file mode 100644
index 000000000..6a6e299a0
--- /dev/null
+++ b/expr/ztests/shape-nested.yaml
@@ -0,0 +1,13 @@
+zql: |
+   cut x = shape(., "({a:{a:float64,b:float64}})")
+
+input: |
+  {"a": {"a": 1}}
+  {"a": {"b": 1}}
+  {"a": {"c": 1, "b": 1}}
+
+output: |
+  #0:record[x:record[a:record[a:float64,b:float64]]]
+  0:[[[1;-;]]]
+  0:[[[-;1;]]]
+  0:[[[-;1;]]]
diff --git a/pkg/promtest/promtest.go b/pkg/promtest/promtest.go
new file mode 100644
index 000000000..5a9605f96
--- /dev/null
+++ b/pkg/promtest/promtest.go
@@ -0,0 +1,42 @@
+package promtest
+
+import (
+	"testing"
+
+	"github.com/prometheus/client_golang/prometheus"
+	dto "github.com/prometheus/client_model/go"
+)
+
+func CounterValue(t *testing.T, g prometheus.Gatherer, name string, labels prometheus.Labels) float64 {
+	metricFamilies, err := g.Gather()
+	if err != nil {
+		t.Error(err)
+		return 0
+	}
+
+	for _, mf := range metricFamilies {
+		if mf.GetName() != name {
+			continue
+		}
+		for _, m := range mf.GetMetric() {
+			if labelsEqual(m.GetLabel(), labels) {
+				return m.GetCounter().GetValue()
+			}
+		}
+	}
+
+	t.Errorf("metric %q not found", name)
+	return 0
+}
+
+func labelsEqual(pairs []*dto.LabelPair, labels prometheus.Labels) bool {
+	if len(pairs) != len(labels) {
+		return false
+	}
+	for _, pair := range pairs {
+		if name, ok := labels[pair.GetName()]; !ok || name != pair.GetValue() {
+			return false
+		}
+	}
+	return true
+}
diff --git a/ppl/archive/archive_test.go b/ppl/archive/archive_test.go
index f01d2b21f..5b54e32a4 100644
--- a/ppl/archive/archive_test.go
+++ b/ppl/archive/archive_test.go
@@ -3,8 +3,6 @@ package archive
 import (
 	"bytes"
 	"context"
-	"fmt"
-	"io/ioutil"
 	"os"
 	"path"
 	"path/filepath"
@@ -13,14 +11,18 @@ import (
 	"github.com/brimsec/zq/microindex"
 	"github.com/brimsec/zq/pkg/iosrc"
 	"github.com/brimsec/zq/pkg/nano"
+	"github.com/brimsec/zq/pkg/promtest"
 	"github.com/brimsec/zq/pkg/test"
 	"github.com/brimsec/zq/ppl/archive/chunk"
+	"github.com/brimsec/zq/ppl/archive/immcache"
 	"github.com/brimsec/zq/ppl/archive/index"
 	"github.com/brimsec/zq/zbuf"
 	"github.com/brimsec/zq/zio"
 	"github.com/brimsec/zq/zio/detector"
 	"github.com/brimsec/zq/zio/tzngio"
 	"github.com/brimsec/zq/zng/resolver"
+	"github.com/prometheus/client_golang/prometheus"
+	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
 
@@ -68,86 +70,30 @@ func indexQuery(t *testing.T, ark *Archive, patterns []string, opts ...FindOptio
 	return buf.String()
 }
 
-func TestOpenOptions(t *testing.T) {
-	datapath, err := ioutil.TempDir("", "")
+func TestMetadataCache(t *testing.T) {
+	datapath := t.TempDir()
+	createArchiveSpace(t, datapath, babble, nil)
+	reg := prometheus.NewRegistry()
+	icache, err := immcache.NewLocalCache(128, reg)
 	require.NoError(t, err)
-	defer os.RemoveAll(datapath)
-
-	thresh := int64(1000)
-	createArchiveSpace(t, datapath, babble, &CreateOptions{
-		LogSizeThreshold: &thresh,
-	})
 
-	_, err = OpenArchive(datapath, &OpenOptions{
-		LogFilter: []string{"foo"},
+	ark, err := OpenArchive(datapath, &OpenOptions{
+		ImmutableCache: icache,
 	})
-	require.Error(t, err)
-	require.Regexp(t, "not a chunk file name", err.Error())
-
-	indexArchiveSpace(t, datapath, ":int64")
-
-	ark1, err := OpenArchive(datapath, nil)
 	require.NoError(t, err)
 
-	// Verifying the complete index search response requires looking at the
-	// filesystem to find the uuids of the data files.
-	expFormat := `
-#zfile=string
-#0:record[key:int64,count:uint64,_log:zfile,first:time,last:time]
-0:[336;1;%s;1587517353.06239121;1587516769.06905117;]
-0:[336;1;%s;1587509477.06450528;1587508830.06852324;]
-`
-
-	first1 := nano.Ts(1587517353062391210)
-	first2 := nano.Ts(1587509477064505280)
-	var chunk1, chunk2 chunk.Chunk
-	err = filepath.Walk(datapath, func(p string, fi os.FileInfo, err error) error {
-		if err != nil {
-			return err
-		}
-		if k, id, ok := chunk.FileMatch(fi.Name()); ok && k == chunk.FileKindMetadata {
-			uri, err := iosrc.ParseURI(p)
-			if err != nil {
-				return err
-			}
-			uri.Path = path.Dir(uri.Path)
-			chunk, err := chunk.Open(context.Background(), uri, id, zbuf.OrderDesc)
-			if err != nil {
-				return err
-			}
-			switch chunk.First {
-			case first1:
-				chunk1 = chunk
-			case first2:
-				chunk2 = chunk
-			}
-		}
-		return nil
-	})
-	require.NoError(t, err)
-	if chunk1.Id.IsNil() || chunk2.Id.IsNil() {
-		t.Fatalf("expected data files not found")
+	for i := 0; i < 4; i++ {
+		count, err := RecordCount(context.Background(), ark)
+		require.NoError(t, err)
+		assert.EqualValues(t, 1000, count)
 	}
 
-	pattern := []string{":int64=336"}
-	out := indexQuery(t, ark1, pattern, AddPath(DefaultAddPathField, false))
-	require.Equal(t,
-		test.Trim(fmt.Sprintf(expFormat, ark1.Root.RelPath(chunk1.Path()), ark1.Root.RelPath(chunk2.Path()))),
-		out,
-	)
+	kind := prometheus.Labels{"kind": "metadata"}
+	misses := promtest.CounterValue(t, reg, "archive_cache_misses_total", kind)
+	hits := promtest.CounterValue(t, reg, "archive_cache_hits_total", kind)
 
-	ark2, err := OpenArchive(datapath, &OpenOptions{
-		LogFilter: []string{ark1.Root.RelPath(chunk1.Path())},
-	})
-	require.NoError(t, err)
-
-	expFormat = `
-#zfile=string
-#0:record[key:int64,count:uint64,_log:zfile,first:time,last:time]
-0:[336;1;%s;1587517353.06239121;1587516769.06905117;]
-`
-	out = indexQuery(t, ark2, pattern, AddPath(DefaultAddPathField, false))
-	require.Equal(t, test.Trim(fmt.Sprintf(expFormat, ark1.Root.RelPath(chunk1.Path()))), out)
+	assert.EqualValues(t, 2, misses)
+	assert.EqualValues(t, 6, hits)
 }
 
 func TestSeekIndex(t *testing.T) {
diff --git a/ppl/zqd/db/filedb/oldconfig/oldconfig_test.go b/ppl/zqd/db/filedb/oldconfig/oldconfig_test.go
index a59291eed..5e3d2b74e 100644
--- a/ppl/zqd/db/filedb/oldconfig/oldconfig_test.go
+++ b/ppl/zqd/db/filedb/oldconfig/oldconfig_test.go
@@ -144,7 +144,7 @@ func (tm *testMigration) manager() *apiserver.Manager {
 	if tm.mgr == nil {
 		filedb, err := filedb.Open(context.Background(), zap.NewNop(), tm.root)
 		require.NoError(tm.T, err)
-		mgr, err := apiserver.NewManager(context.Background(), zap.NewNop(), prometheus.NewRegistry(), tm.root, filedb)
+		mgr, err := apiserver.NewManager(context.Background(), zap.NewNop(), prometheus.NewRegistry(), tm.root, filedb, nil)
 		require.NoError(tm.T, err)
 		tm.mgr = mgr
 	}
diff --git a/ppl/zqd/db/postgresdb/ztests/subspace_noparent.yaml b/ppl/zqd/db/postgresdb/ztests/subspace_noparent.yaml
deleted file mode 100644
index 649dc5f77..000000000
--- a/ppl/zqd/db/postgresdb/ztests/subspace_noparent.yaml
+++ /dev/null
@@ -1,19 +0,0 @@
-tag: postgres
-
-env:
-  - PG_TEST
-
-script: |
-  source startup.sh
-  zapi -h $ZQD_HOST newsubspace -p doesnotexist -n testsp logid
-
-inputs:
-  - name: startup.sh
-    source: startup.sh
-  - name: migrations
-    symlink: ../migrations
-
-outputs:
-  - name: stderr
-    regexp: |
-      couldn't create subspace testsp: status code 404: subspace parent not found
diff --git a/ppl/zqd/handlers_test.go b/ppl/zqd/handlers_test.go
index deb8e4da2..2512b7bd4 100644
--- a/ppl/zqd/handlers_test.go
+++ b/ppl/zqd/handlers_test.go
@@ -23,7 +23,9 @@ import (
 	"github.com/brimsec/zq/driver"
 	"github.com/brimsec/zq/pkg/fs"
 	"github.com/brimsec/zq/pkg/nano"
+	"github.com/brimsec/zq/pkg/promtest"
 	"github.com/brimsec/zq/pkg/test"
+	"github.com/brimsec/zq/ppl/archive/immcache"
 	"github.com/brimsec/zq/ppl/zqd"
 	"github.com/brimsec/zq/ppl/zqd/pcapanalyzer"
 	"github.com/brimsec/zq/zbuf"
@@ -668,6 +670,37 @@ func TestCreateArchiveSpace(t *testing.T) {
 	require.Equal(t, test.Trim(exptzng), res)
 }
 
+func TestArchiveInProcessCache(t *testing.T) {
+	const expcount = `
+#0:record[count:uint64]
+0:[1000;]`
+
+	core, conn := newCoreWithConfig(t, zqd.Config{
+		ImmutableCache: immcache.Config{LocalCacheSize: 128},
+	})
+
+	sp, err := conn.SpacePost(context.Background(), api.SpacePostRequest{
+		Name:    "arktest",
+		Storage: &api.StorageConfig{Kind: api.ArchiveStore},
+	})
+	require.NoError(t, err)
+
+	_, err = conn.LogPost(context.Background(), sp.ID, nil, babbleSorted)
+	require.NoError(t, err)
+
+	for i := 0; i < 4; i++ {
+		res, _ := search(t, conn, sp.ID, "count()")
+		assert.Equal(t, test.Trim(expcount), res)
+	}
+
+	kind := prometheus.Labels{"kind": "metadata"}
+	misses := promtest.CounterValue(t, core.Registry(), "archive_cache_misses_total", kind)
+	hits := promtest.CounterValue(t, core.Registry(), "archive_cache_hits_total", kind)
+
+	assert.EqualValues(t, 2, misses)
+	assert.EqualValues(t, 8, hits)
+}
+
 func TestBlankNameSpace(t *testing.T) {
 	// Verify that spaces created before the zq#721 work have names.
 
@@ -729,164 +762,6 @@ func TestIndexSearch(t *testing.T) {
 	assert.Equal(t, test.Trim(exp), tzngCopy(t, "drop _log", res, "tzng"))
 }
 
-func TestSubspaceCreate(t *testing.T) {
-	thresh := int64(1000)
-
-	// Create server & the parent space
-	root := createTempDir(t)
-	_, conn := newCoreAtDir(t, root)
-
-	sp1, err := conn.SpacePost(context.Background(), api.SpacePostRequest{
-		Name: "TestSubspaceCreate",
-		Storage: &api.StorageConfig{
-			Kind: api.ArchiveStore,
-			Archive: &api.ArchiveConfig{
-				CreateOptions: &api.ArchiveCreateOptions{
-					LogSizeThreshold: &thresh,
-				},
-			},
-		},
-	})
-	require.NoError(t, err)
-	_, err = conn.LogPost(context.Background(), sp1.ID, nil, babbleSorted)
-	require.NoError(t, err)
-	err = conn.IndexPost(context.Background(), sp1.ID, api.IndexPostRequest{
-		Patterns: []string{":int64"},
-	})
-	require.NoError(t, err)
-
-	// Verify index search returns all logs
-	exp := `
-#0:record[key:int64,count:uint64,first:time,last:time]
-0:[336;1;1587517353.06239121;1587516769.06905117;]
-0:[336;1;1587509477.06450528;1587508830.06852324;]
-`
-	res, _ := indexSearch(t, conn, sp1.ID, "", []string{":int64=336"})
-	assert.Equal(t, test.Trim(exp), tzngCopy(t, "drop _log", res, "tzng"))
-
-	logId := strings.TrimSpace(tzngCopy(t, "first=1587509477.06450528 | pick _log", res, "text"))
-	// Create subspace
-	sp2, err := conn.SubspacePost(context.Background(), sp1.ID, api.SubspacePostRequest{
-		Name: "subspace",
-		OpenOptions: api.ArchiveOpenOptions{
-			LogFilter: []string{logId},
-		},
-	})
-	require.NoError(t, err)
-	assert.Equal(t, sp1.ID, sp2.ParentID)
-
-	// Verify index search only returns filtered logs
-	exp = `
-#0:record[key:int64,count:uint64,first:time,last:time]
-0:[336;1;1587509477.06450528;1587508830.06852324;]
-`
-	res, _ = indexSearch(t, conn, sp2.ID, "", []string{":int64=336"})
-	assert.Equal(t, test.Trim(exp), tzngCopy(t, "drop _log", res, "tzng"))
-
-	// Verify full search only returns filtered results
-	exp = `
-#0:record[ts:time,s:string,v:int64]
-0:[1587508861.0676317;gatelike-nucleolocentrosome;336;]
-`
-	res, _ = search(t, conn, sp2.ID, "v=336")
-	assert.Equal(t, test.Trim(exp), res)
-
-	// Verify delete on parent fails
-	err = conn.SpaceDelete(context.Background(), sp1.ID)
-	require.Error(t, err)
-	require.Regexp(t, "subspaces", err.Error())
-
-	// Delete subspace
-	err = conn.SpaceDelete(context.Background(), sp2.ID)
-	require.NoError(t, err)
-
-	// parent delete should now succeed
-	err = conn.SpaceDelete(context.Background(), sp1.ID)
-	require.NoError(t, err)
-}
-
-func TestSubspacePersist(t *testing.T) {
-	thresh := int64(1000)
-
-	// Create server & the parent space
-	root := createTempDir(t)
-	_, conn1 := newCoreAtDir(t, root)
-
-	sp1, err := conn1.SpacePost(context.Background(), api.SpacePostRequest{
-		Name: "TestSubspaceCreate",
-		Storage: &api.StorageConfig{
-			Kind: api.ArchiveStore,
-			Archive: &api.ArchiveConfig{
-				CreateOptions: &api.ArchiveCreateOptions{
-					LogSizeThreshold: &thresh,
-				},
-			},
-		},
-	})
-	require.NoError(t, err)
-	_, err = conn1.LogPost(context.Background(), sp1.ID, nil, babbleSorted)
-	require.NoError(t, err)
-	err = conn1.IndexPost(context.Background(), sp1.ID, api.IndexPostRequest{
-		Patterns: []string{":int64"},
-	})
-	require.NoError(t, err)
-
-	res, _ := indexSearch(t, conn1, sp1.ID, "", []string{":int64=336"})
-	logId := strings.TrimSpace(tzngCopy(t, "first=1587509477.06450528 | pick _log", res, "text"))
-
-	// Create subspace
-	sp2, err := conn1.SubspacePost(context.Background(), sp1.ID, api.SubspacePostRequest{
-		Name: "subspace",
-		OpenOptions: api.ArchiveOpenOptions{
-			LogFilter: []string{logId},
-		},
-	})
-	require.NoError(t, err)
-	assert.Equal(t, sp1.ID, sp2.ParentID)
-
-	// Verify index search only returns filtered logs
-	exp := `
-#0:record[key:int64,count:uint64,first:time,last:time]
-0:[336;1;1587509477.06450528;1587508830.06852324;]
-`
-	res, _ = indexSearch(t, conn1, sp2.ID, "", []string{":int64=336"})
-	assert.Equal(t, test.Trim(exp), tzngCopy(t, "drop _log", res, "tzng"))
-
-	// Verify name change works
-	err = conn1.SpacePut(context.Background(), sp2.ID, api.SpacePutRequest{Name: "newname"})
-	require.NoError(t, err)
-
-	si, err := conn1.SpaceInfo(context.Background(), sp2.ID)
-	require.NoError(t, err)
-	assert.Equal(t, sp2.ID, si.ID)
-	assert.Equal(t, sp1.ID, si.ParentID)
-	assert.Equal(t, "newname", si.Name)
-
-	// Verify subspace is present & has new name with new server
-	_, conn2 := newCoreAtDir(t, root)
-
-	si, err = conn2.SpaceInfo(context.Background(), sp2.ID)
-	require.NoError(t, err)
-	assert.Equal(t, sp2.ID, si.ID)
-	assert.Equal(t, sp1.ID, si.ParentID)
-	assert.Equal(t, "newname", si.Name)
-
-	// Delete subspace
-	err = conn2.SpaceDelete(context.Background(), sp2.ID)
-	require.NoError(t, err)
-
-	_, err = conn2.SpaceInfo(context.Background(), sp2.ID)
-	require.Error(t, err)
-	assert.Regexp(t, "not found", err.Error())
-
-	// Verify subspace gone with new server
-	_, conn3 := newCoreAtDir(t, root)
-
-	_, err = conn3.SpaceInfo(context.Background(), sp2.ID)
-	require.Error(t, err)
-	assert.Regexp(t, "not found", err.Error())
-}
-
 func TestArchiveStat(t *testing.T) {
 	thresh := int64(20 * 1024)
 	root := createTempDir(t)
diff --git a/ppl/zqd/ztests/archivestore/subspace.yaml b/ppl/zqd/ztests/archivestore/subspace.yaml
deleted file mode 100644
index 3ecd25848..000000000
--- a/ppl/zqd/ztests/archivestore/subspace.yaml
+++ /dev/null
@@ -1,19 +0,0 @@
-script: |
-  source services.sh
-  zapi -h $ZQD_HOST new -k archivestore s1
-  export SPACE_ID=$(zapi -h $ZQD_HOST ls -l s1 | grep "id:" | head -n 1 | awk  '{print $2}')
-  echo ===
-  zapi -h $ZQD_HOST newsubspace -p ${SPACE_ID} -n sub1 badlogid
-
-inputs:
-  - name: services.sh
-    source: ../services.sh
-
-outputs:
-  - name: stdout
-    data: |
-      s1: space created
-      ===
-  - name: stderr
-    regexp: |
-      .*badlogid not a chunk file name.*
diff --git a/ppl/zqd/ztests/cache-local.yaml b/ppl/zqd/ztests/cache-local.yaml
new file mode 100644
index 000000000..f9967f61f
--- /dev/null
+++ b/ppl/zqd/ztests/cache-local.yaml
@@ -0,0 +1,32 @@
+script: |
+  ZQD_EXTRA_FLAGS=-immcache.localsize=128 source services.sh
+  zapi -h $ZQD_HOST new -d ./root -k archivestore -thresh 20KiB testsp
+  zapi -h $ZQD_HOST -s testsp post babble.tzng >/dev/null
+  echo ===
+  zapi -h $ZQD_HOST -s testsp get -f text "count()"
+  echo ===
+  zapi -h $ZQD_HOST -s testsp get -f text "count()"
+  echo ===
+  curl $ZQD_HOST/metrics | grep archive_cache
+
+inputs:
+  - name: babble.tzng
+    source: ../../../ztests/suite/data/babble-sorted.tzng
+  - name: services.sh
+    source: services.sh
+
+outputs:
+  - name: stdout
+    data: |
+      testsp: space created
+      ===
+      1000
+      ===
+      1000
+      ===
+      # HELP archive_cache_hits_total Number of hits for a cache lookup.
+      # TYPE archive_cache_hits_total counter
+      archive_cache_hits_total{kind="metadata"} 4
+      # HELP archive_cache_misses_total Number of misses for a cache lookup.
+      # TYPE archive_cache_misses_total counter
+      archive_cache_misses_total{kind="metadata"} 2