Repository: prometheus/prometheus (language: go)

Issue:
promtool tsdb analyze maps whole db, but only looks at one block
### What did you do?

I ran `promtool tsdb analyze /path/to/data 01E8BRZKNZG99J3PJDW31MZ1WR`.

### What did you expect to see?

I expected promtool to `mmap` a single block I picked and analyze it.

### What did you see instead? Under which circumstances?

The whole 50TiB of my prometheus tsdb were `mmap`ed into memory over the 20 minute time span. Lots of disk I/O happened.

Similar things to happen when you don't supply the block ID argument: the whole tsdb is loaded, then the latest block is picked and analyzed.

### System information

Linux 5.15

### Prometheus version

```text
prometheus, version 2.36.0 (branch: master, revision: 0cb07b796a07b652485fc8b3504eb149a44af381)
  build user:       builder@k8s-pdx-managed-7pc7j.cluster.local
  build date:       20220607-08:35:43
  go version:       go1.18.2
  platform:         linux/amd64
```


### Prometheus configuration file

_No response_

### Alertmanager version

_No response_

### Alertmanager configuration file

_No response_

### Logs

_No response_


Discussion / hints:
Here's a dumb solution to speed things up if anyone is facing the same issue:

1. Remove `IsDir` check from `promtool`:
```diff
diff --git a/tsdb/db.go b/tsdb/db.go
index ab0822d73..b4ae9de20 100644
--- a/tsdb/db.go
+++ b/tsdb/db.go
@@ -1718,9 +1718,6 @@ func (db *DB) CleanTombstones() (err error) {
 }

 func isBlockDir(fi fs.DirEntry) bool {
-       if !fi.IsDir() {
-               return false
-       }
        _, err := ulid.ParseStrict(fi.Name())
        return err == nil
 }
```
2. Create a temporary directory, and symlink a single block there:
```
mkdir /tmp/derp
ln -s /disk/data/prometheus/performance/data/01E8BRZKNZG99J3PJDW31MZ1WR /tmp/derp/
```
3. Analyze your temporary directory:
```
/tmp/promtool tsdb analyze /tmp/derp 01E8BRZKNZG99J3PJDW31MZ1WR
```
@bobrik @gouthamve Hi! I'd like to work on this!

Fix patch:
diff --git a/cmd/promtool/tsdb.go b/cmd/promtool/tsdb.go
index 84aa43a9c40..b7fad5fe095 100644
--- a/cmd/promtool/tsdb.go
+++ b/cmd/promtool/tsdb.go
@@ -398,26 +398,20 @@ func openBlock(path, blockID string) (*tsdb.DBReadOnly, tsdb.BlockReader, error)
 	if err != nil {
 		return nil, nil, err
 	}
-	blocks, err := db.Blocks()
-	if err != nil {
-		return nil, nil, err
-	}
-	var block tsdb.BlockReader
-	switch {
-	case blockID != "":
-		for _, b := range blocks {
-			if b.Meta().ULID.String() == blockID {
-				block = b
-				break
-			}
+
+	if blockID == "" {
+		blockID, err = db.LastBlockID()
+		if err != nil {
+			return nil, nil, err
 		}
-	case len(blocks) > 0:
-		block = blocks[len(blocks)-1]
 	}
-	if block == nil {
-		return nil, nil, fmt.Errorf("block %s not found", blockID)
+
+	b, err := db.Block(blockID)
+	if err != nil {
+		return nil, nil, err
 	}
-	return db, block, nil
+
+	return db, b, nil
 }
 
 func analyzeBlock(path, blockID string, limit int, runExtended bool) error {
diff --git a/tsdb/db.go b/tsdb/db.go
index 61333c97206..2963dda5927 100644
--- a/tsdb/db.go
+++ b/tsdb/db.go
@@ -601,6 +601,60 @@ func (db *DBReadOnly) Blocks() ([]BlockReader, error) {
 	return blockReaders, nil
 }
 
+// LastBlockID returns the BlockID of latest block.
+func (db *DBReadOnly) LastBlockID() (string, error) {
+	entries, err := os.ReadDir(db.dir)
+	if err != nil {
+		return "", err
+	}
+
+	max := uint64(0)
+
+	lastBlockID := ""
+
+	for _, e := range entries {
+		// Check if dir is a block dir or not.
+		dirName := e.Name()
+		ulidObj, err := ulid.ParseStrict(dirName)
+		if err != nil {
+			continue // Not a block dir.
+		}
+		timestamp := ulidObj.Time()
+		if timestamp > max {
+			max = timestamp
+			lastBlockID = dirName
+		}
+	}
+
+	if lastBlockID == "" {
+		return "", errors.New("no blocks found")
+	}
+
+	return lastBlockID, nil
+}
+
+// Block returns a block reader by given block id.
+func (db *DBReadOnly) Block(blockID string) (BlockReader, error) {
+	select {
+	case <-db.closed:
+		return nil, ErrClosed
+	default:
+	}
+
+	_, err := os.Stat(filepath.Join(db.dir, blockID))
+	if os.IsNotExist(err) {
+		return nil, errors.Errorf("invalid block ID %s", blockID)
+	}
+
+	block, err := OpenBlock(db.logger, filepath.Join(db.dir, blockID), nil)
+	if err != nil {
+		return nil, err
+	}
+	db.closers = append(db.closers, block)
+
+	return block, nil
+}
+
 // Close all block readers.
 func (db *DBReadOnly) Close() error {
 	select {


Test patch:
diff --git a/tsdb/db_test.go b/tsdb/db_test.go
index 0b980f6ec64..427a3b7afab 100644
--- a/tsdb/db_test.go
+++ b/tsdb/db_test.go
@@ -2384,6 +2384,7 @@ func TestDBReadOnly(t *testing.T) {
 		dbDir     string
 		logger    = log.NewLogfmtLogger(log.NewSyncWriter(os.Stderr))
 		expBlocks []*Block
+		expBlock  *Block
 		expSeries map[string][]tsdbutil.Sample
 		expChunks map[string][][]tsdbutil.Sample
 		expDBHash []byte
@@ -2427,6 +2428,7 @@ func TestDBReadOnly(t *testing.T) {
 		require.NoError(t, app.Commit())
 
 		expBlocks = dbWritable.Blocks()
+		expBlock = expBlocks[0]
 		expDbSize, err := fileutil.DirSize(dbWritable.Dir())
 		require.NoError(t, err)
 		require.Greater(t, expDbSize, dbSizeBeforeAppend, "db size didn't increase after an append")
@@ -2455,7 +2457,22 @@ func TestDBReadOnly(t *testing.T) {
 			require.Equal(t, expBlock.Meta(), blocks[i].Meta(), "block meta mismatch")
 		}
 	})
-
+	t.Run("block", func(t *testing.T) {
+		blockID := expBlock.meta.ULID.String()
+		block, err := dbReadOnly.Block(blockID)
+		require.NoError(t, err)
+		require.Equal(t, expBlock.Meta(), block.Meta(), "block meta mismatch")
+	})
+	t.Run("invalid block ID", func(t *testing.T) {
+		blockID := "01GTDVZZF52NSWB5SXQF0P2PGF"
+		_, err := dbReadOnly.Block(blockID)
+		require.Error(t, err)
+	})
+	t.Run("last block ID", func(t *testing.T) {
+		blockID, err := dbReadOnly.LastBlockID()
+		require.NoError(t, err)
+		require.Equal(t, expBlocks[2].Meta().ULID.String(), blockID)
+	})
 	t.Run("querier", func(t *testing.T) {
 		// Open a read only db and ensure that the API returns the same result as the normal DB.
 		q, err := dbReadOnly.Querier(context.TODO(), math.MinInt64, math.MaxInt64)