Repository: VictoriaMetrics/VictoriaMetrics
Pull request #3973 (2023-03-17 11:41:42)

vmalert: support logs suppressing during config reloads
The change is mostly required for ENT version of vmalert, since it supports object-storage for config files. Reading data from object storage could be time-consuming, so vmalert emits logs to track the progress.

However, these logs are mostly needed on start or on manual config reload. Printing these logs each time `rule.configCheckInterval` is triggered would too verbose. So the change allows to control logs emitting during config reloads.

Now, logs are emitted during start up or when SIGHUP is receieved. For periodicall config checks logs emitted by config pkg are suppressed.


Patch:
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index a8cdbc6b5..7e4ba6b33 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -55,7 +55,7 @@ jobs:
         uses: actions/checkout@v3
 
       - name: Set up Go
-        uses: actions/setup-go@v3
+        uses: actions/setup-go@v4
         with:
           go-version: 1.20.2
           check-latest: true
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 649a9e8a0..779b1b729 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -30,7 +30,7 @@ jobs:
         uses: actions/checkout@v3
 
       - name: Setup Go
-        uses: actions/setup-go@v3
+        uses: actions/setup-go@v4
         with:
           go-version: 1.20.2
           check-latest: true
@@ -54,7 +54,7 @@ jobs:
         uses: actions/checkout@v3
 
       - name: Setup Go
-        uses: actions/setup-go@v3
+        uses: actions/setup-go@v4
         with:
           go-version: 1.20.2
           check-latest: true
@@ -79,7 +79,7 @@ jobs:
 
       - name: Setup Go
         id: go
-        uses: actions/setup-go@v3
+        uses: actions/setup-go@v4
         with:
           go-version: 1.20.2
           check-latest: true
diff --git a/Makefile b/Makefile
index 31875c24f..3cbb1bb7a 100644
--- a/Makefile
+++ b/Makefile
@@ -186,7 +186,8 @@ release-victoria-metrics: \
 	release-victoria-metrics-darwin-amd64 \
 	release-victoria-metrics-darwin-arm64 \
 	release-victoria-metrics-freebsd-amd64 \
-	release-victoria-metrics-openbsd-amd64
+	release-victoria-metrics-openbsd-amd64 \
+	release-victoria-metrics-windows-amd64
 
 # adds i386 arch
 release-victoria-metrics-linux-386:
@@ -213,6 +214,9 @@ release-victoria-metrics-freebsd-amd64:
 release-victoria-metrics-openbsd-amd64:
 	GOOS=openbsd GOARCH=amd64 $(MAKE) release-victoria-metrics-goos-goarch
 
+release-victoria-metrics-windows-amd64:
+	GOARCH=amd64 $(MAKE) release-victoria-metrics-windows-goarch
+
 release-victoria-metrics-goos-goarch: victoria-metrics-$(GOOS)-$(GOARCH)-prod
 	cd bin && \
 		tar --transform="flags=r;s|-$(GOOS)-$(GOARCH)||" -czf victoria-metrics-$(GOOS)-$(GOARCH)-$(PKG_TAG).tar.gz \
@@ -222,6 +226,16 @@ release-victoria-metrics-goos-goarch: victoria-metrics-$(GOOS)-$(GOARCH)-prod
 			| sed s/-$(GOOS)-$(GOARCH)-prod/-prod/ > victoria-metrics-$(GOOS)-$(GOARCH)-$(PKG_TAG)_checksums.txt
 	cd bin && rm -rf victoria-metrics-$(GOOS)-$(GOARCH)-prod
 
+release-victoria-metrics-windows-goarch: victoria-metrics-windows-$(GOARCH)-prod
+	cd bin && \
+		zip victoria-metrics-windows-$(GOARCH)-$(PKG_TAG).zip \
+			victoria-metrics-windows-$(GOARCH)-prod.exe \
+		&& sha256sum victoria-metrics-windows-$(GOARCH)-$(PKG_TAG).zip \
+			victoria-metrics-windows-$(GOARCH)-prod.exe \
+			> victoria-metrics-windows-$(GOARCH)-$(PKG_TAG)_checksums.txt
+	cd bin && rm -rf \
+		victoria-metrics-windows-$(GOARCH)-prod.exe
+
 release-vmutils: \
 	release-vmutils-linux-386 \
 	release-vmutils-linux-amd64 \
@@ -314,7 +328,6 @@ release-vmutils-windows-goarch: \
 		vmauth-windows-$(GOARCH)-prod.exe \
 		vmctl-windows-$(GOARCH)-prod.exe
 
-
 pprof-cpu:
 	go tool pprof -trim_path=github.com/VictoriaMetrics/VictoriaMetrics@ $(PPROF_FILE)
 
diff --git a/README.md b/README.md
index 842ae1b68..931aecfb9 100644
--- a/README.md
+++ b/README.md
@@ -1447,12 +1447,14 @@ can be configured with the `-inmemoryDataFlushInterval` command-line flag (note
 In-memory parts are persisted to disk into `part` directories under the `<-storageDataPath>/data/small/YYYY_MM/` folder,
 where `YYYY_MM` is the month partition for the stored data. For example, `2022_11` is the partition for `parts`
 with [raw samples](https://docs.victoriametrics.com/keyConcepts.html#raw-samples) from `November 2022`.
+Each partition directory contains `parts.json` file with the actual list of parts in the partition.
 
-The `part` directory has the following name pattern: `rowsCount_blocksCount_minTimestamp_maxTimestamp`, where:
+Every `part` directory contains `metadata.json` file with the following fields:
 
-- `rowsCount` - the number of [raw samples](https://docs.victoriametrics.com/keyConcepts.html#raw-samples) stored in the part
-- `blocksCount` - the number of blocks stored in the part (see details about blocks below)
-- `minTimestamp` and `maxTimestamp` - minimum and maximum timestamps across raw samples stored in the part
+- `RowsCount` - the number of [raw samples](https://docs.victoriametrics.com/keyConcepts.html#raw-samples) stored in the part
+- `BlocksCount` - the number of blocks stored in the part (see details about blocks below)
+- `MinTimestamp` and `MaxTimestamp` - minimum and maximum timestamps across raw samples stored in the part
+- `MinDedupInterval` - the [deduplication interval](#deduplication) applied to the given part.
 
 Each `part` consists of `blocks` sorted by internal time series id (aka `TSID`).
 Each `block` contains up to 8K [raw samples](https://docs.victoriametrics.com/keyConcepts.html#raw-samples),
@@ -1474,9 +1476,8 @@ for fast block lookups, which belong to the given `TSID` and cover the given tim
   and [freeing up disk space for the deleted time series](#how-to-delete-time-series) are performed during the merge
 
 Newly added `parts` either successfully appear in the storage or fail to appear.
-The newly added `parts` are being created in a temporary directory under `<-storageDataPath>/data/{small,big}/YYYY_MM/tmp` folder.
-When the newly added `part` is fully written and [fsynced](https://man7.org/linux/man-pages/man2/fsync.2.html)
-to a temporary directory, then it is atomically moved to the storage directory.
+The newly added `part` is atomically registered in the `parts.json` file under the corresponding partition
+after it is fully written and [fsynced](https://man7.org/linux/man-pages/man2/fsync.2.html) to the storage.
 Thanks to this alogrithm, storage never contains partially created parts, even if hardware power off
 occurrs in the middle of writing the `part` to disk - such incompletely written `parts`
 are automatically deleted on the next VictoriaMetrics start.
@@ -1505,8 +1506,7 @@ Retention is configured with the `-retentionPeriod` command-line flag, which tak
 
 Data is split in per-month partitions inside `<-storageDataPath>/data/{small,big}` folders.
 Data partitions outside the configured retention are deleted on the first day of the new month.
-Each partition consists of one or more data parts with the following name pattern `rowsCount_blocksCount_minTimestamp_maxTimestamp`.
-Data parts outside of the configured retention are eventually deleted during
+Each partition consists of one or more data parts. Data parts outside of the configured retention are eventually deleted during
 [background merge](https://medium.com/@valyala/how-victoriametrics-makes-instant-snapshots-for-multi-terabyte-time-series-data-e1f3fb0e0282).
 
 The maximum disk space usage for a given `-retentionPeriod` is going to be (`-retentionPeriod` + 1) months.
diff --git a/SECURITY.md b/SECURITY.md
index 052a44eda..fae1eb383 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -4,10 +4,10 @@
 
 | Version | Supported          |
 |---------|--------------------|
-| 1.81.x  | :white_check_mark: |
-| 1.80.x  | :x:                |
-| 1.79.x  | :white_check_mark: |
-| < 1.78  | :x:                |
+| [latest release](https://docs.victoriametrics.com/CHANGELOG.html) | :white_check_mark: |
+| v1.87.x LTS release | :white_check_mark: |
+| v1.79.x LTS release | :white_check_mark: |
+| other releases  | :x:                |
 
 ## Reporting a Vulnerability
 
diff --git a/app/victoria-metrics/Makefile b/app/victoria-metrics/Makefile
index baf91090c..f8fa3d089 100644
--- a/app/victoria-metrics/Makefile
+++ b/app/victoria-metrics/Makefile
@@ -39,6 +39,9 @@ victoria-metrics-freebsd-amd64-prod:
 victoria-metrics-openbsd-amd64-prod:
 	APP_NAME=victoria-metrics $(MAKE) app-via-docker-openbsd-amd64
 
+victoria-metrics-windows-amd64-prod:
+	APP_NAME=victoria-metrics $(MAKE) app-via-docker-windows-amd64
+
 package-victoria-metrics:
 	APP_NAME=victoria-metrics $(MAKE) package-via-docker
 
@@ -100,6 +103,9 @@ victoria-metrics-freebsd-amd64:
 victoria-metrics-openbsd-amd64:
 	APP_NAME=victoria-metrics CGO_ENABLED=0 GOOS=openbsd GOARCH=amd64 $(MAKE) app-local-goos-goarch
 
+victoria-metrics-windows-amd64:
+	GOARCH=amd64 APP_NAME=victoria-metrics $(MAKE) app-local-windows-goarch
+
 victoria-metrics-pure:
 	APP_NAME=victoria-metrics $(MAKE) app-local-pure
 
diff --git a/app/vmalert/config/config.go b/app/vmalert/config/config.go
index 2de93fc55..35e00978f 100644
--- a/app/vmalert/config/config.go
+++ b/app/vmalert/config/config.go
@@ -10,9 +10,9 @@ import (
 
 	"gopkg.in/yaml.v2"
 
+	"github.com/VictoriaMetrics/VictoriaMetrics/app/vmalert/config/log"
 	"github.com/VictoriaMetrics/VictoriaMetrics/app/vmalert/utils"
 	"github.com/VictoriaMetrics/VictoriaMetrics/lib/envtemplate"
-	"github.com/VictoriaMetrics/VictoriaMetrics/lib/logger"
 	"github.com/VictoriaMetrics/VictoriaMetrics/lib/promutils"
 )
 
@@ -199,9 +199,17 @@ func (r *Rule) Validate() error {
 // ValidateTplFn must validate the given annotations
 type ValidateTplFn func(annotations map[string]string) error
 
+// cLogger is a logger with support of logs suppressing.
+// it is used when logs emitted by config package needs
+// to be suppressed.
+var cLogger = &log.Logger{}
+
 // ParseSilent parses rule configs from given file patterns without emitting logs
 func ParseSilent(pathPatterns []string, validateTplFn ValidateTplFn, validateExpressions bool) ([]Group, error) {
-	files, err := readFromFS(pathPatterns, true)
+	cLogger.Suppress(true)
+	defer cLogger.Suppress(false)
+
+	files, err := readFromFS(pathPatterns)
 	if err != nil {
 		return nil, fmt.Errorf("failed to read from the config: %s", err)
 	}
@@ -210,7 +218,7 @@ func ParseSilent(pathPatterns []string, validateTplFn ValidateTplFn, validateExp
 
 // Parse parses rule configs from given file patterns
 func Parse(pathPatterns []string, validateTplFn ValidateTplFn, validateExpressions bool) ([]Group, error) {
-	files, err := readFromFS(pathPatterns, false)
+	files, err := readFromFS(pathPatterns)
 	if err != nil {
 		return nil, fmt.Errorf("failed to read from the config: %s", err)
 	}
@@ -219,7 +227,7 @@ func Parse(pathPatterns []string, validateTplFn ValidateTplFn, validateExpressio
 		return nil, fmt.Errorf("failed to parse %s: %s", pathPatterns, err)
 	}
 	if len(groups) < 1 {
-		logger.Warnf("no groups found in %s", strings.Join(pathPatterns, ";"))
+		cLogger.Warnf("no groups found in %s", strings.Join(pathPatterns, ";"))
 	}
 	return groups, nil
 }
diff --git a/app/vmalert/config/fs.go b/app/vmalert/config/fs.go
index f1aa8f511..372d8066f 100644
--- a/app/vmalert/config/fs.go
+++ b/app/vmalert/config/fs.go
@@ -4,9 +4,9 @@ import (
 	"fmt"
 	"strings"
 	"sync"
+	"time"
 
 	"github.com/VictoriaMetrics/VictoriaMetrics/app/vmalert/config/fslocal"
-	"github.com/VictoriaMetrics/VictoriaMetrics/lib/logger"
 )
 
 // FS represent a file system abstract for reading files.
@@ -36,10 +36,9 @@ var (
 // readFromFS returns an error if at least one FS failed to init.
 // The function can be called multiple times but each unique path
 // will be inited only once.
-// If silent == true, readFromFS will not emit any logs.
 //
 // It is allowed to mix different FS types in path list.
-func readFromFS(paths []string, silent bool) (map[string][]byte, error) {
+func readFromFS(paths []string) (map[string][]byte, error) {
 	var err error
 	result := make(map[string][]byte)
 	for _, path := range paths {
@@ -65,18 +64,19 @@ func readFromFS(paths []string, silent bool) (map[string][]byte, error) {
 			return nil, fmt.Errorf("failed to list files from %q", fs)
 		}
 
-		if !silent {
-			logger.Infof("found %d files to read from %q", len(list), fs)
-		}
+		cLogger.Infof("found %d files to read from %q", len(list), fs)
 
 		if len(list) < 1 {
 			continue
 		}
 
+		ts := time.Now()
 		files, err := fs.Read(list)
 		if err != nil {
 			return nil, fmt.Errorf("error while reading files from %q: %w", fs, err)
 		}
+		cLogger.Infof("finished reading %d files in %v from %q", len(list), time.Since(ts), fs)
+
 		for k, v := range files {
 			if _, ok := result[k]; ok {
 				return nil, fmt.Errorf("duplicate found for file name %q: file names must be unique", k)
diff --git a/app/vmalert/config/log/logger.go b/app/vmalert/config/log/logger.go
new file mode 100644
index 000000000..8b79e55ac
--- /dev/null
+++ b/app/vmalert/config/log/logger.go
@@ -0,0 +1,59 @@
+package log
+
+import (
+	"sync"
+
+	"github.com/VictoriaMetrics/VictoriaMetrics/lib/logger"
+)
+
+// Logger is using lib/logger for logging
+// but can be suppressed via Suppress method
+type Logger struct {
+	mu       sync.RWMutex
+	disabled bool
+}
+
+// Suppress whether to ignore message logging.
+// Once suppressed, logging continues to be ignored
+// until logger is un-suppressed.
+func (l *Logger) Suppress(v bool) {
+	l.mu.Lock()
+	l.disabled = v
+	l.mu.Unlock()
+}
+
+func (l *Logger) isDisabled() bool {
+	l.mu.RLock()
+	defer l.mu.RUnlock()
+	return l.disabled
+}
+
+// Errorf logs error message.
+func (l *Logger) Errorf(format string, args ...interface{}) {
+	if l.isDisabled() {
+		return
+	}
+	logger.Errorf(format, args...)
+}
+
+// Warnf logs warning message.
+func (l *Logger) Warnf(format string, args ...interface{}) {
+	if l.isDisabled() {
+		return
+	}
+	logger.Warnf(format, args...)
+}
+
+// Infof logs info message.
+func (l *Logger) Infof(format string, args ...interface{}) {
+	if l.isDisabled() {
+		return
+	}
+	logger.Infof(format, args...)
+}
+
+// Panicf logs panic message and panics.
+// Panicf can't be suppressed
+func (l *Logger) Panicf(format string, args ...interface{}) {
+	logger.Panicf(format, args...)
+}
diff --git a/app/vmalert/main.go b/app/vmalert/main.go
index 2d32a5d3b..28d4f5c17 100644
--- a/app/vmalert/main.go
+++ b/app/vmalert/main.go
@@ -319,6 +319,7 @@ func configReload(ctx context.Context, m *manager, groupsCfg []config.Group, sig
 	// init reload metrics with positive values to improve alerting conditions
 	configSuccess.Set(1)
 	configTimestamp.Set(fasttime.UnixTimestamp())
+	parseFn := config.Parse
 	for {
 		select {
 		case <-ctx.Done():
@@ -330,7 +331,11 @@ func configReload(ctx context.Context, m *manager, groupsCfg []config.Group, sig
 			}
 			logger.Infof("SIGHUP received. Going to reload rules %q %s...", *rulePath, tmplMsg)
 			configReloads.Inc()
+			// allow logs emitting during manual config reload
+			parseFn = config.Parse
 		case <-configCheckCh:
+			// disable logs emitting during per-interval config reload
+			parseFn = config.ParseSilent
 		}
 		if err := notifier.Reload(); err != nil {
 			configReloadErrors.Inc()
@@ -345,7 +350,7 @@ func configReload(ctx context.Context, m *manager, groupsCfg []config.Group, sig
 			logger.Errorf("failed to load new templates: %s", err)
 			continue
 		}
-		newGroupsCfg, err := config.ParseSilent(*rulePath, validateTplFn, *validateExpressions)
+		newGroupsCfg, err := parseFn(*rulePath, validateTplFn, *validateExpressions)
 		if err != nil {
 			configReloadErrors.Inc()
 			configSuccess.Set(0)
diff --git a/app/vmalert/notifier/alert.go b/app/vmalert/notifier/alert.go
index f26cfcfa3..89dca7e17 100644
--- a/app/vmalert/notifier/alert.go
+++ b/app/vmalert/notifier/alert.go
@@ -111,11 +111,7 @@ func (a *Alert) ExecTemplate(q templates.QueryFn, labels, annotations map[string
 		ActiveAt: a.ActiveAt,
 		For:      a.For,
 	}
-	tmpl, err := templates.GetWithFuncs(templates.FuncsWithQuery(q))
-	if err != nil {
-		return nil, fmt.Errorf("error getting a template: %w", err)
-	}
-	return templateAnnotations(annotations, tplData, tmpl, true)
+	return ExecTemplate(q, annotations, tplData)
 }
 
 // ExecTemplate executes the given template for given annotations map.
diff --git a/app/vmalert/notifier/init.go b/app/vmalert/notifier/init.go
index e85bc47f1..4ec773fbf 100644
--- a/app/vmalert/notifier/init.go
+++ b/app/vmalert/notifier/init.go
@@ -81,10 +81,6 @@ var (
 //
 // Init returns an error if both mods are used.
 func Init(gen AlertURLGenerator, extLabels map[string]string, extURL string) (func() []Notifier, error) {
-	if externalLabels != nil || externalURL != "" {
-		return nil, fmt.Errorf("BUG: notifier.Init was called multiple times")
-	}
-
 	externalURL = extURL
 	externalLabels = extLabels
 	eu, err := url.Parse(externalURL)
diff --git a/app/vmctl/main.go b/app/vmctl/main.go
index 6fc1c2dd9..a4efd2b6d 100644
--- a/app/vmctl/main.go
+++ b/app/vmctl/main.go
@@ -15,6 +15,7 @@ import (
 	"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/backoff"
 	"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/native"
 	"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/remoteread"
+	"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/terminal"
 	"github.com/urfave/cli/v2"
 
 	"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/influx"
@@ -71,7 +72,7 @@ func main() {
 					}
 
 					otsdbProcessor := newOtsdbProcessor(otsdbClient, importer, c.Int(otsdbConcurrency))
-					return otsdbProcessor.run(c.Bool(globalSilent), c.Bool(globalVerbose))
+					return otsdbProcessor.run(isNonInteractive(c), c.Bool(globalVerbose))
 				},
 			},
 			{
@@ -112,7 +113,7 @@ func main() {
 						c.String(influxMeasurementFieldSeparator),
 						c.Bool(influxSkipDatabaseLabel),
 						c.Bool(influxPrometheusMode))
-					return processor.run(c.Bool(globalSilent), c.Bool(globalVerbose))
+					return processor.run(isNonInteractive(c), c.Bool(globalVerbose))
 				},
 			},
 			{
@@ -152,7 +153,7 @@ func main() {
 						},
 						cc: c.Int(remoteReadConcurrency),
 					}
-					return rmp.run(ctx, c.Bool(globalSilent), c.Bool(globalVerbose))
+					return rmp.run(ctx, isNonInteractive(c), c.Bool(globalVerbose))
 				},
 			},
 			{
@@ -186,7 +187,7 @@ func main() {
 						im: importer,
 						cc: c.Int(promConcurrency),
 					}
-					return pp.run(c.Bool(globalSilent), c.Bool(globalVerbose))
+					return pp.run(isNonInteractive(c), c.Bool(globalVerbose))
 				},
 			},
 			{
@@ -244,7 +245,7 @@ func main() {
 						backoff: backoff.New(),
 						cc:      c.Int(vmConcurrency),
 					}
-					return p.run(ctx, c.Bool(globalSilent))
+					return p.run(ctx, isNonInteractive(c))
 				},
 			},
 			{
@@ -317,3 +318,8 @@ func initConfigVM(c *cli.Context) vm.Config {
 		DisableProgressBar: c.Bool(vmDisableProgressBar),
 	}
 }
+
+func isNonInteractive(c *cli.Context) bool {
+	isTerminal := terminal.IsTerminal(int(os.Stdout.Fd()))
+	return c.Bool(globalSilent) || !isTerminal
+}
diff --git a/app/vmctl/terminal/terminal.go b/app/vmctl/terminal/terminal.go
new file mode 100644
index 000000000..2f3e26eaa
--- /dev/null
+++ b/app/vmctl/terminal/terminal.go
@@ -0,0 +1,14 @@
+//go:build darwin || linux || solaris
+// +build darwin linux solaris
+
+package terminal
+
+import (
+	"golang.org/x/sys/unix"
+)
+
+// IsTerminal returns true if the file descriptor is terminal
+func IsTerminal(fd int) bool {
+	_, err := unix.IoctlGetTermios(fd, ioctlReadTermios)
+	return err == nil
+}
diff --git a/app/vmctl/terminal/unix.go b/app/vmctl/terminal/unix.go
new file mode 100644
index 000000000..610e17f8c
--- /dev/null
+++ b/app/vmctl/terminal/unix.go
@@ -0,0 +1,8 @@
+//go:build aix || linux || solaris || zos
+// +build aix linux solaris zos
+
+package terminal
+
+import "golang.org/x/sys/unix"
+
+const ioctlReadTermios = unix.TCGETS
diff --git a/app/vmctl/terminal/unix_bsd.go b/app/vmctl/terminal/unix_bsd.go
new file mode 100644
index 000000000..7f866d087
--- /dev/null
+++ b/app/vmctl/terminal/unix_bsd.go
@@ -0,0 +1,8 @@
+//go:build darwin
+// +build darwin
+
+package terminal
+
+import "golang.org/x/sys/unix"
+
+const ioctlReadTermios = unix.TIOCGETA
diff --git a/app/vmui/packages/vmui/package-lock.json b/app/vmui/packages/vmui/package-lock.json
index b04161b45..e697d8030 100644
--- a/app/vmui/packages/vmui/package-lock.json
+++ b/app/vmui/packages/vmui/package-lock.json
@@ -18,6 +18,7 @@
         "@types/marked": "^4.0.2",
         "@types/node": "^17.0.21",
         "@types/qs": "^6.9.7",
+        "@types/react-input-mask": "^3.0.2",
         "@types/react-router-dom": "^5.3.3",
         "@types/webpack-env": "^1.16.3",
         "classnames": "^2.3.2",
@@ -28,6 +29,7 @@
         "marked": "^4.0.14",
         "preact": "^10.7.1",
         "qs": "^6.10.3",
+        "react-input-mask": "^2.0.4",
         "react-router-dom": "^6.3.0",
         "sass": "^1.56.0",
         "typescript": "~4.6.2",
@@ -4392,6 +4394,14 @@
         "@types/react": "*"
       }
     },
+    "node_modules/@types/react-input-mask": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@types/react-input-mask/-/react-input-mask-3.0.2.tgz",
+      "integrity": "sha512-WTli3kUyvUqqaOLYG/so2pLqUvRb+n4qnx2He5klfqZDiQmRyD07jVIt/bco/1BrcErkPMtpOm+bHii4Oed6cQ==",
+      "dependencies": {
+        "@types/react": "*"
+      }
+    },
     "node_modules/@types/react-router": {
       "version": "5.1.20",
       "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz",
@@ -10236,6 +10246,14 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/invariant": {
+      "version": "2.2.4",
+      "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
+      "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
+      "dependencies": {
+        "loose-envify": "^1.0.0"
+      }
+    },
     "node_modules/ipaddr.js": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.0.1.tgz",
@@ -16403,6 +16421,19 @@
       "dev": true,
       "peer": true
     },
+    "node_modules/react-input-mask": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/react-input-mask/-/react-input-mask-2.0.4.tgz",
+      "integrity": "sha512-1hwzMr/aO9tXfiroiVCx5EtKohKwLk/NT8QlJXHQ4N+yJJFyUuMT+zfTpLBwX/lK3PkuMlievIffncpMZ3HGRQ==",
+      "dependencies": {
+        "invariant": "^2.2.4",
+        "warning": "^4.0.2"
+      },
+      "peerDependencies": {
+        "react": ">=0.14.0",
+        "react-dom": ">=0.14.0"
+      }
+    },
     "node_modules/react-is": {
       "version": "17.0.2",
       "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@@ -18811,6 +18842,14 @@
         "makeerror": "1.0.12"
       }
     },
+    "node_modules/warning": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
+      "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
+      "dependencies": {
+        "loose-envify": "^1.0.0"
+      }
+    },
     "node_modules/watchpack": {
       "version": "2.4.0",
       "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",
@@ -18851,9 +18890,9 @@
       }
     },
     "node_modules/webpack": {
-      "version": "5.75.0",
-      "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.75.0.tgz",
-      "integrity": "sha512-piaIaoVJlqMsPtX/+3KTTO6jfvrSYgauFVdt8cr9LTHKmcq/AMd4mhzsiP7ZF/PGRNPGA8336jldh9l2Kt2ogQ==",
+      "version": "5.76.2",
+      "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.76.2.tgz",
+      "integrity": "sha512-Th05ggRm23rVzEOlX8y67NkYCHa9nTNcwHPBhdg+lKG+mtiW7XgggjAeeLnADAe7mLjJ6LUNfgHAuRRh+Z6J7w==",
       "dev": true,
       "peer": true,
       "dependencies": {
diff --git a/app/vmui/packages/vmui/package.json b/app/vmui/packages/vmui/package.json
index e4cbaae2b..f846c31c8 100644
--- a/app/vmui/packages/vmui/package.json
+++ b/app/vmui/packages/vmui/package.json
@@ -14,6 +14,7 @@
     "@types/marked": "^4.0.2",
     "@types/node": "^17.0.21",
     "@types/qs": "^6.9.7",
+    "@types/react-input-mask": "^3.0.2",
     "@types/react-router-dom": "^5.3.3",
     "@types/webpack-env": "^1.16.3",
     "classnames": "^2.3.2",
@@ -24,6 +25,7 @@
     "marked": "^4.0.14",
     "preact": "^10.7.1",
     "qs": "^6.10.3",
+    "react-input-mask": "^2.0.4",
     "react-router-dom": "^6.3.0",
     "sass": "^1.56.0",
     "typescript": "~4.6.2",
diff --git a/app/vmui/packages/vmui/src/components/Configurators/TimeRangeSettings/TimeDurationSelector/style.scss b/app/vmui/packages/vmui/src/components/Configurators/TimeRangeSettings/TimeDurationSelector/style.scss
index 8e0fbb09c..9a4fb4377 100644
--- a/app/vmui/packages/vmui/src/components/Configurators/TimeRangeSettings/TimeDurationSelector/style.scss
+++ b/app/vmui/packages/vmui/src/components/Configurators/TimeRangeSettings/TimeDurationSelector/style.scss
@@ -1,7 +1,7 @@
 @use "src/styles/variables" as *;
 
 .vm-time-duration {
-  max-height: 200px;
+  max-height: 227px;
   overflow: auto;
   font-size: $font-size;
 
diff --git a/app/vmui/packages/vmui/src/components/Configurators/TimeRangeSettings/TimeSelector/TimeSelector.tsx b/app/vmui/packages/vmui/src/components/Configurators/TimeRangeSettings/TimeSelector/TimeSelector.tsx
index 0fcf4dc27..368545f89 100644
--- a/app/vmui/packages/vmui/src/components/Configurators/TimeRangeSettings/TimeSelector/TimeSelector.tsx
+++ b/app/vmui/packages/vmui/src/components/Configurators/TimeRangeSettings/TimeSelector/TimeSelector.tsx
@@ -4,18 +4,18 @@ import TimeDurationSelector from "../TimeDurationSelector/TimeDurationSelector";
 import dayjs from "dayjs";
 import { getAppModeEnable } from "../../../../utils/app-mode";
 import { useTimeDispatch, useTimeState } from "../../../../state/time/TimeStateContext";
-import { AlarmIcon, ArrowDownIcon, CalendarIcon, ClockIcon } from "../../../Main/Icons";
+import { AlarmIcon, ArrowDownIcon, ClockIcon } from "../../../Main/Icons";
 import Button from "../../../Main/Button/Button";
 import Popper from "../../../Main/Popper/Popper";
 import Tooltip from "../../../Main/Tooltip/Tooltip";
 import { DATE_TIME_FORMAT } from "../../../../constants/date";
 import useResize from "../../../../hooks/useResize";
-import DatePicker from "../../../Main/DatePicker/DatePicker";
 import "./style.scss";
 import useClickOutside from "../../../../hooks/useClickOutside";
 import classNames from "classnames";
 import { useAppState } from "../../../../state/common/StateContext";
 import useDeviceDetect from "../../../../hooks/useDeviceDetect";
+import DateTimeInput from "../../../Main/DatePicker/DateTimeInput/DateTimeInput";
 
 export const TimeSelector: FC = () => {
   const { isMobile } = useDeviceDetect();
@@ -27,9 +27,6 @@ export const TimeSelector: FC = () => {
   const [until, setUntil] = useState<string>();
   const [from, setFrom] = useState<string>();
 
-  const formFormat = useMemo(() => dayjs.tz(from).format(DATE_TIME_FORMAT), [from]);
-  const untilFormat = useMemo(() => dayjs.tz(until).format(DATE_TIME_FORMAT), [until]);
-
   const { period: { end, start }, relativeTime, timezone, duration } = useTimeState();
   const dispatch = useTimeDispatch();
   const appModeEnable = getAppModeEnable();
@@ -55,10 +52,7 @@ export const TimeSelector: FC = () => {
   const formatRange = useMemo(() => {
     const startFormat = dayjs.tz(dateFromSeconds(start)).format(DATE_TIME_FORMAT);
     const endFormat = dayjs.tz(dateFromSeconds(end)).format(DATE_TIME_FORMAT);
-    return {
-      start: startFormat,
-      end: endFormat
-    };
+    return { start: startFormat, end: endFormat };
   }, [start, end, timezone]);
 
   const dateTitle = useMemo(() => {
@@ -66,8 +60,6 @@ export const TimeSelector: FC = () => {
     return isRelativeTime ? relativeTime.replace(/_/g, " ") : `${formatRange.start} - ${formatRange.end}`;
   }, [relativeTime, formatRange]);
 
-  const fromRef = useRef<HTMLDivElement>(null);
-  const untilRef = useRef<HTMLDivElement>(null);
   const fromPickerRef = useRef<HTMLDivElement>(null);
   const untilPickerRef = useRef<HTMLDivElement>(null);
   const [openOptions, setOpenOptions] = useState(false);
@@ -82,11 +74,6 @@ export const TimeSelector: FC = () => {
     }
     setOpenOptions(false);
   };
-  const handleFromChange = (from: string) => setFrom(from);
-
-  const handleUntilChange = (until: string) => setUntil(until);
-
-  const onApplyClick = () => setTimeAndClosePicker();
 
   const onSwitchToNow = () => dispatch({ type: "RUN_QUERY_TO_NOW" });
 
@@ -116,11 +103,9 @@ export const TimeSelector: FC = () => {
   useClickOutside(wrapperRef, (e) => {
     if (isMobile) return;
     const target = e.target as HTMLElement;
-    const isFromButton = fromRef?.current && fromRef.current.contains(target);
-    const isUntilButton = untilRef?.current && untilRef.current.contains(target);
     const isFromPicker = fromPickerRef?.current && fromPickerRef?.current?.contains(target);
     const isUntilPicker = untilPickerRef?.current && untilPickerRef?.current?.contains(target);
-    if (isFromButton || isUntilButton || isFromPicker || isUntilPicker) return;
+    if (isFromPicker || isUntilPicker) return;
     handleCloseOptions();
   });
 
@@ -174,38 +159,22 @@ export const TimeSelector: FC = () => {
               "vm-time-selector-left-inputs_dark": isDarkTheme
             })}
           >
-            <div
-              className="vm-time-selector-left-inputs__date"
-              ref={fromRef}
-            >
-              <label>From:</label>
-              <span>{formFormat}</span>
-              <CalendarIcon/>
-              <DatePicker
-                label={"Date From"}
-                ref={fromPickerRef}
-                date={from || ""}
-                onChange={handleFromChange}
-                targetRef={fromRef}
-                timepicker={true}
-              />
-            </div>
-            <div
-              className="vm-time-selector-left-inputs__date"
-              ref={untilRef}
-            >
-              <label>To:</label>
-              <span>{untilFormat}</span>
-              <CalendarIcon/>
-              <DatePicker
-                label={"Date To"}
-                ref={untilPickerRef}
-                date={until || ""}
-                onChange={handleUntilChange}
-                targetRef={untilRef}
-                timepicker={true}
-              />
-            </div>
+            <DateTimeInput
+              value={from}
+              label="From:"
+              pickerLabel="Date From"
+              pickerRef={fromPickerRef}
+              onChange={setFrom}
+              onEnter={setTimeAndClosePicker}
+            />
+            <DateTimeInput
+              value={until}
+              label="To:"
+              pickerLabel="Date To"
+              pickerRef={untilPickerRef}
+              onChange={setUntil}
+              onEnter={setTimeAndClosePicker}
+            />
           </div>
           <div className="vm-time-selector-left-timezone">
             <div className="vm-time-selector-left-timezone__title">{activeTimezone.region}</div>
@@ -228,7 +197,7 @@ export const TimeSelector: FC = () => {
             </Button>
             <Button
               color="primary"
-              onClick={onApplyClick}
+              onClick={setTimeAndClosePicker}
             >
               Apply
             </Button>
diff --git a/app/vmui/packages/vmui/src/components/Configurators/TimeRangeSettings/TimeSelector/style.scss b/app/vmui/packages/vmui/src/components/Configurators/TimeRangeSettings/TimeSelector/style.scss
index 46bf69aec..9d79cb151 100644
--- a/app/vmui/packages/vmui/src/components/Configurators/TimeRangeSettings/TimeSelector/style.scss
+++ b/app/vmui/packages/vmui/src/components/Configurators/TimeRangeSettings/TimeSelector/style.scss
@@ -31,48 +31,6 @@
       display: grid;
       align-items: flex-start;
       justify-content: stretch;
-
-      &_dark &__date {
-        border-color: $color-text-disabled;
-      }
-
-      &__date {
-        display: grid;
-        grid-template-columns: 1fr 14px;
-        gap: $padding-small;
-        align-items: center;
-        justify-content: center;
-        padding-bottom: $padding-small;
-        margin-bottom: $padding-global;
-        border-bottom: $border-divider;
-        cursor: pointer;
-        transition: color 200ms ease-in-out, border-bottom-color 300ms ease;
-
-        &:last-child {
-          margin-bottom: 0;
-        }
-
-        &:hover {
-          border-bottom-color: $color-primary;
-        }
-
-        &:hover svg,
-        &:hover {
-          color: $color-primary;
-        }
-
-        label {
-          grid-column: 1/3;
-          font-size: $font-size-small;
-          color: $color-text-secondary;
-          user-select: none;
-        }
-
-        svg {
-          color: $color-text-secondary;
-          transition: color 200ms ease-in-out;
-        }
-      }
     }
 
     &-timezone {
diff --git a/app/vmui/packages/vmui/src/components/Main/DatePicker/Calendar/Calendar.tsx b/app/vmui/packages/vmui/src/components/Main/DatePicker/Calendar/Calendar.tsx
index 22d160a60..ec809a858 100644
--- a/app/vmui/packages/vmui/src/components/Main/DatePicker/Calendar/Calendar.tsx
+++ b/app/vmui/packages/vmui/src/components/Main/DatePicker/Calendar/Calendar.tsx
@@ -3,65 +3,45 @@ import dayjs, { Dayjs } from "dayjs";
 import CalendarHeader from "./CalendarHeader/CalendarHeader";
 import CalendarBody from "./CalendarBody/CalendarBody";
 import YearsList from "./YearsList/YearsList";
-import TimePicker from "../TImePicker/TimePicker";
 import { DATE_TIME_FORMAT } from "../../../../constants/date";
 import "./style.scss";
-import { CalendarIcon, ClockIcon } from "../../Icons";
-import Tabs from "../../Tabs/Tabs";
 import useDeviceDetect from "../../../../hooks/useDeviceDetect";
 import classNames from "classnames";
+import MonthsList from "./MonthsList/MonthsList";
 
 interface DatePickerProps {
   date: Date | Dayjs
   format?: string
-  timepicker?: boolean,
   onChange: (date: string) => void
-  onClose?: () => void
 }
 
-const tabs = [
-  { value: "date", icon: <CalendarIcon/> },
-  { value: "time", icon: <ClockIcon/> }
-];
+enum CalendarTypeView {
+  "days",
+  "months",
+  "years"
+}
 
 const Calendar: FC<DatePickerProps> = ({
   date,
-  timepicker = false,
   format = DATE_TIME_FORMAT,
   onChange,
-  onClose
 }) => {
-  const [displayYears, setDisplayYears] = useState(false);
+  const [viewType, setViewType] = useState<CalendarTypeView>(CalendarTypeView.days);
   const [viewDate, setViewDate] = useState(dayjs.tz(date));
   const [selectDate, setSelectDate] = useState(dayjs.tz(date));
-  const [tab, setTab] = useState(tabs[0].value);
   const { isMobile } = useDeviceDetect();
 
   const toggleDisplayYears = () => {
-    setDisplayYears(prev => !prev);
+    setViewType(prev => prev === CalendarTypeView.years ? CalendarTypeView.days : CalendarTypeView.years);
   };
 
   const handleChangeViewDate = (date: Dayjs) => {
     setViewDate(date);
-    setDisplayYears(false);
+    setViewType(prev => prev === CalendarTypeView.years ? CalendarTypeView.months : CalendarTypeView.days);
   };
 
   const handleChangeSelectDate = (date: Dayjs) => {
     setSelectDate(date);
-    if (timepicker) setTab("time");
-  };
-
-  const handleChangeTime = (time: string) => {
-    const [hour, minute, second] = time.split(":");
-    setSelectDate(prev => prev.set("hour", +hour).set("minute", +minute).set("second", +second));
-  };
-
-  const handleChangeTab = (value: string) => {
-    setTab(value);
-  };
-
-  const handleClose = () => {
-    onClose && onClose();
   };
 
   useEffect(() => {
@@ -69,6 +49,12 @@ const Calendar: FC<DatePickerProps> = ({
     onChange(selectDate.format(format));
   }, [selectDate]);
 
+  useEffect(() => {
+    const value = dayjs.tz(date);
+    setViewDate(value);
+    setSelectDate(value);
+  }, [date]);
+
   return (
     <div
       className={classNames({
@@ -76,51 +62,32 @@ const Calendar: FC<DatePickerProps> = ({
         "vm-calendar_mobile": isMobile,
       })}
     >
-      {tab === "date" && (
-        <CalendarHeader
+      <CalendarHeader
+        viewDate={viewDate}
+        onChangeViewDate={handleChangeViewDate}
+        toggleDisplayYears={toggleDisplayYears}
+        showArrowNav={viewType === CalendarTypeView.days}
+      />
+      {viewType === CalendarTypeView.days && (
+        <CalendarBody
           viewDate={viewDate}
-          onChangeViewDate={handleChangeViewDate}
-          toggleDisplayYears={toggleDisplayYears}
-          displayYears={displayYears}
+          selectDate={selectDate}
+          onChangeSelectDate={handleChangeSelectDate}
         />
       )}
-
-      {tab === "date" && (
-        <>
-          {!displayYears && (
-            <CalendarBody
-              viewDate={viewDate}
-              selectDate={selectDate}
-              onChangeSelectDate={handleChangeSelectDate}
-            />
-          )}
-          {displayYears && (
-            <YearsList
-              viewDate={viewDate}
-              onChangeViewDate={handleChangeViewDate}
-            />
-          )}
-        </>
+      {viewType === CalendarTypeView.years && (
+        <YearsList
+          viewDate={viewDate}
+          onChangeViewDate={handleChangeViewDate}
+        />
       )}
-
-      {tab === "time" && (
-        <TimePicker
+      {viewType === CalendarTypeView.months && (
+        <MonthsList
           selectDate={selectDate}
-          onChangeTime={handleChangeTime}
-          onClose={handleClose}
+          viewDate={viewDate}
+          onChangeViewDate={handleChangeViewDate}
         />
       )}
-
-      {timepicker && (
-        <div className="vm-calendar__tabs">
-          <Tabs
-            activeItem={tab}
-            items={tabs}
-            onChange={handleChangeTab}
-            indicatorPlacement="top"
-          />
-        </div>
-      )}
     </div>
   );
 };
diff --git a/app/vmui/packages/vmui/src/components/Main/DatePicker/Calendar/CalendarHeader/CalendarHeader.tsx b/app/vmui/packages/vmui/src/components/Main/DatePicker/Calendar/CalendarHeader/CalendarHeader.tsx
index 3a106b7dd..efff17314 100644
--- a/app/vmui/packages/vmui/src/components/Main/DatePicker/Calendar/CalendarHeader/CalendarHeader.tsx
+++ b/app/vmui/packages/vmui/src/components/Main/DatePicker/Calendar/CalendarHeader/CalendarHeader.tsx
@@ -5,11 +5,11 @@ import { ArrowDownIcon, ArrowDropDownIcon } from "../../../Icons";
 interface CalendarHeaderProps {
   viewDate: Dayjs
   onChangeViewDate: (date: Dayjs) => void
-  displayYears: boolean
+  showArrowNav: boolean
   toggleDisplayYears: () => void
 }
 
-const CalendarHeader: FC<CalendarHeaderProps> = ({ viewDate, displayYears, onChangeViewDate, toggleDisplayYears }) => {
+const CalendarHeader: FC<CalendarHeaderProps> = ({ viewDate, showArrowNav, onChangeViewDate, toggleDisplayYears }) => {
 
   const setPrevMonth = () => {
     onChangeViewDate(viewDate.subtract(1, "month"));
@@ -32,7 +32,7 @@ const CalendarHeader: FC<CalendarHeaderProps> = ({ viewDate, displayYears, onCha
           <ArrowDropDownIcon/>
         </div>
       </div>
-      {!displayYears && (
+      {showArrowNav && (
         <div className="vm-calendar-header-right">
           <div
             className="vm-calendar-header-right__prev"
diff --git a/app/vmui/packages/vmui/src/components/Main/DatePicker/Calendar/MonthsList/MonthsList.tsx b/app/vmui/packages/vmui/src/components/Main/DatePicker/Calendar/MonthsList/MonthsList.tsx
new file mode 100644
index 000000000..d1a5ab97c
--- /dev/null
+++ b/app/vmui/packages/vmui/src/components/Main/DatePicker/Calendar/MonthsList/MonthsList.tsx
@@ -0,0 +1,50 @@
+import React, { FC, useEffect, useMemo } from "preact/compat";
+import dayjs, { Dayjs } from "dayjs";
+import classNames from "classnames";
+
+interface CalendarMonthsProps {
+  viewDate: Dayjs,
+  selectDate: Dayjs
+
+  onChangeViewDate: (date: Dayjs) => void
+}
+
+const MonthsList: FC<CalendarMonthsProps> = ({ viewDate, selectDate, onChangeViewDate }) => {
+
+  const today = dayjs().format("MM");
+  const currentMonths = useMemo(() => selectDate.format("MM"), [selectDate]);
+  const months: Dayjs[] = useMemo(() => {
+    return new Array(12).fill("").map((d, i) => dayjs(viewDate).month(i));
+  }, [viewDate]);
+
+  useEffect(() => {
+    const selectedEl = document.getElementById(`vm-calendar-year-${currentMonths}`);
+    if (!selectedEl) return;
+    selectedEl.scrollIntoView({ block: "center" });
+  }, []);
+
+  const createHandlerClick = (date: Dayjs) => () => {
+    onChangeViewDate(date);
+  };
+
+  return (
+    <div className="vm-calendar-years">
+      {months.map(m => (
+        <div
+          className={classNames({
+            "vm-calendar-years__year": true,
+            "vm-calendar-years__year_selected": m.format("MM") === currentMonths,
+            "vm-calendar-years__year_today": m.format("MM") === today
+          })}
+          id={`vm-calendar-year-${m.format("MM")}`}
+          key={m.format("MM")}
+          onClick={createHandlerClick(m)}
+        >
+          {m.format("MMMM")}
+        </div>
+      ))}
+    </div>
+  );
+};
+
+export default MonthsList;
diff --git a/app/vmui/packages/vmui/src/components/Main/DatePicker/Calendar/YearsList/YearsList.tsx b/app/vmui/packages/vmui/src/components/Main/DatePicker/Calendar/YearsList/YearsList.tsx
index 4adc77e4d..db7a82b10 100644
--- a/app/vmui/packages/vmui/src/components/Main/DatePicker/Calendar/YearsList/YearsList.tsx
+++ b/app/vmui/packages/vmui/src/components/Main/DatePicker/Calendar/YearsList/YearsList.tsx
@@ -9,9 +9,10 @@ interface CalendarYearsProps {
 
 const YearsList: FC<CalendarYearsProps> = ({ viewDate, onChangeViewDate }) => {
 
+  const today = dayjs().format("YYYY");
   const currentYear = useMemo(() => viewDate.format("YYYY"), [viewDate]);
   const years: Dayjs[] = useMemo(() => {
-    const displayYears = 206;
+    const displayYears = 18;
     const year = dayjs();
     const startYear = year.subtract(displayYears/2, "year");
     return new Array(displayYears).fill(startYear).map((d, i) => d.add(i, "year"));
@@ -33,7 +34,8 @@ const YearsList: FC<CalendarYearsProps> = ({ viewDate, onChangeViewDate }) => {
         <div
           className={classNames({
             "vm-calendar-years__year": true,
-            "vm-calendar-years__year_selected": y.format("YYYY") === currentYear
+            "vm-calendar-years__year_selected": y.format("YYYY") === currentYear,
+            "vm-calendar-years__year_today": y.format("YYYY") === today
           })}
           id={`vm-calendar-year-${y.format("YYYY")}`}
           key={y.format("YYYY")}
diff --git a/app/vmui/packages/vmui/src/components/Main/DatePicker/Calendar/style.scss b/app/vmui/packages/vmui/src/components/Main/DatePicker/Calendar/style.scss
index 1a3c517f5..f3b8f91ce 100644
--- a/app/vmui/packages/vmui/src/components/Main/DatePicker/Calendar/style.scss
+++ b/app/vmui/packages/vmui/src/components/Main/DatePicker/Calendar/style.scss
@@ -13,12 +13,6 @@
     padding: 0 $padding-global;
   }
 
-  &__tabs {
-    margin: 0 0-$padding-global 0-$padding-global;
-    border-top: $border-divider;
-    margin-top: $padding-global;
-  }
-
   &-header {
     display: grid;
     grid-template-columns: 1fr auto;
@@ -88,14 +82,14 @@
   &-body {
     display: grid;
     grid-template-columns: repeat(7, 32px);
-    grid-template-rows: repeat(6, 32px);
+    grid-template-rows: repeat(7, 32px);
     align-items: center;
     justify-content: center;
     gap: 2px;
 
     @media (max-width: 500px) {
       grid-template-columns: repeat(7, calc((100vw - ($padding-global * 2) - (6 * 2px))/7));
-      grid-template-rows: repeat(6, calc((100vw - ($padding-global * 2) - (5 * 2px))/7));
+      grid-template-rows: repeat(7, calc((100vw - ($padding-global * 2) - (6 * 2px))/7));
     }
 
     &-cell {
@@ -166,143 +160,9 @@
           background-color: $color-primary;
         }
       }
-    }
-  }
-
-  &-time-picker {
-    display: flex;
-    flex-direction: column;
-    align-items: center;
-    justify-content: center;
-
-    &-clock {
-      $clock-size: 230px;
-      $clock-offset: 42px;
-
-      position: relative;
-      height: $clock-size;
-      width: $clock-size;
-      border-radius: 50%;
-      border: $border-divider;
-      box-shadow: $box-shadow;
-      box-sizing: content-box;
 
-      &:after {
-        content: '';
-        position: absolute;
-        top: 50%;
-        left: 50%;
-        transform: translate(-50%, -50%);
-        width: 6px;
-        height: 6px;
-        background-color: $color-primary;
-        border-radius: 50%;
-      }
-
-      &__arrow {
-        position: absolute;
-        top: 0;
-        left: calc(($clock-size/2) - 1px);
-        width: 2px;
-        margin-top: $padding-small;
-        height: calc(($clock-size/2) - $padding-small);
-        background-color: $color-primary;
-        transform-origin: bottom;
-        transition: transform 200ms ease-in-out;
-        opacity: 0.8;
-        z-index: 0;
-
-        &_offset {
-          margin-top: $clock-offset;
-          height: calc(($clock-size/2) - $clock-offset);
-          z-index: 2;
-        }
-
-        &:after {
-          content: '';
-          position: absolute;
-          top: 0;
-          left: 50%;
-          transform: translateX(-50%);
-          width: 30px;
-          height: 30px;
-          background-color: $color-primary;
-          border-radius: 50%;
-        }
-      }
-
-      &__time {
-        display: flex;
-        align-items: flex-start;
-        justify-content: center;
-        text-align: center;
-        padding-top: $padding-small;
-        position: absolute;
-        top: 0;
-        width: 30px;
-        left: calc(($clock-size/2) - 15px);
-        height: calc($clock-size/2);
-        transform-origin: bottom;
-        cursor: pointer;
-        z-index: 1;
-
-        &_hide {
-          display: none;
-        }
-
-        &_offset {
-          padding: 0;
-          margin-top: $clock-offset;
-          height: calc(($clock-size/2) - $clock-offset);
-          z-index: 2;
-        }
-
-        &:hover span {
-          background-color: rgba($color-black, 0.1);
-        }
-
-        span {
-          position: relative;
-          display: grid;
-          align-items: center;
-          justify-content: center;
-          min-width: 30px;
-          min-height: 30px;
-          border-radius: 50%;
-          transform-origin: center;
-          transition: background-color 300ms ease;
-        }
-      }
-    }
-
-    &-fields {
-      display: flex;
-      align-items: center;
-      justify-content: space-between;
-      margin-top: $padding-global;
-
-      &_dark &__input {
-        border-color: $color-text-disabled;
-      }
-
-      span {
-        margin: 0 $padding-small;
-      }
-
-      &__input {
-        width: 64px;
-        height: 32px;
-        border: $border-divider;
-        border-radius: $border-radius-small;
-        font-size: $font-size-medium;
-        padding: 2px $padding-small;
-        text-align: center;
-        background-color: transparent;
-        color: $color-text;
-
-        &:focus {
-          border-color: $color-primary;
-        }
+      &_today {
+        border: 1px solid $color-primary;
       }
     }
   }
diff --git a/app/vmui/packages/vmui/src/components/Main/DatePicker/DatePicker.tsx b/app/vmui/packages/vmui/src/components/Main/DatePicker/DatePicker.tsx
index 15c15e491..92a61fee6 100644
--- a/app/vmui/packages/vmui/src/components/Main/DatePicker/DatePicker.tsx
+++ b/app/vmui/packages/vmui/src/components/Main/DatePicker/DatePicker.tsx
@@ -9,7 +9,6 @@ interface DatePickerProps {
   date: string | Date | Dayjs,
   targetRef: Ref<HTMLElement>
   format?: string
-  timepicker?: boolean
   label?: string
   onChange: (val: string) => void
 }
@@ -18,12 +17,11 @@ const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(({
   date,
   targetRef,
   format = DATE_TIME_FORMAT,
-  timepicker,
   onChange,
   label
 }, ref) => {
   const [openCalendar, setOpenCalendar] = useState(false);
-  const dateDayjs = useMemo(() => date ? dayjs.tz(date) : dayjs().tz(), [date]);
+  const dateDayjs = useMemo(() => dayjs(date).isValid() ? dayjs.tz(date) : dayjs().tz(), [date]);
   const { isMobile } = useDeviceDetect();
 
   const toggleOpenCalendar = () => {
@@ -35,8 +33,8 @@ const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(({
   };
 
   const handleChangeDate = (val: string) => {
-    if (!timepicker) handleCloseCalendar();
     onChange(val);
+    handleCloseCalendar();
   };
 
   const handleKeyUp = (e: KeyboardEvent) => {
@@ -71,9 +69,7 @@ const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(({
         <Calendar
           date={dateDayjs}
           format={format}
-          timepicker={timepicker}
           onChange={handleChangeDate}
-          onClose={handleCloseCalendar}
         />
       </div>
     </Popper>
diff --git a/app/vmui/packages/vmui/src/components/Main/DatePicker/DateTimeInput/DateTimeInput.tsx b/app/vmui/packages/vmui/src/components/Main/DatePicker/DateTimeInput/DateTimeInput.tsx
new file mode 100644
index 000000000..6299c0144
--- /dev/null
+++ b/app/vmui/packages/vmui/src/components/Main/DatePicker/DateTimeInput/DateTimeInput.tsx
@@ -0,0 +1,127 @@
+import React, { FC, useEffect, useRef, useState } from "preact/compat";
+import { ChangeEvent, KeyboardEvent } from "react";
+import { CalendarIcon } from "../../Icons";
+import DatePicker from "../DatePicker";
+import Button from "../../Button/Button";
+import { DATE_TIME_FORMAT } from "../../../../constants/date";
+import InputMask from "react-input-mask";
+import dayjs from "dayjs";
+import classNames from "classnames";
+import "./style.scss";
+
+const formatStringDate = (val: string) => {
+  return dayjs(val).isValid() ? dayjs.tz(val).format(DATE_TIME_FORMAT) : val;
+};
+
+interface DateTimeInputProps {
+  value?:  string;
+  label: string;
+  pickerLabel: string;
+  pickerRef: React.RefObject<HTMLDivElement>;
+  onChange: (date: string) => void;
+  onEnter: () => void;
+}
+
+const DateTimeInput: FC<DateTimeInputProps> = ({
+  value = "",
+  label,
+  pickerLabel,
+  pickerRef,
+  onChange,
+  onEnter
+}) => {
+  const wrapperRef = useRef<HTMLDivElement>(null);
+  const [inputRef, setInputRef] = useState<HTMLInputElement | null>(null);
+
+  const [maskedValue, setMaskedValue] = useState(formatStringDate(value));
+  const [focusToTime, setFocusToTime] = useState(false);
+  const [awaitChangeForEnter, setAwaitChangeForEnter] = useState(false);
+  const error = dayjs(maskedValue).isValid() ? "" : "Expected format: YYYY-MM-DD HH:mm:ss";
+
+  const handleMaskedChange = (e: ChangeEvent<HTMLInputElement>) => {
+    setMaskedValue(e.currentTarget.value);
+  };
+
+  const handleBlur = () => {
+    onChange(maskedValue);
+  };
+
+  const handleKeyUp = (e: KeyboardEvent) => {
+    if (e.key === "Enter") {
+      onChange(maskedValue);
+      setAwaitChangeForEnter(true);
+    }
+  };
+
+  const handleChangeDate = (val: string) => {
+    setMaskedValue(val);
+    setFocusToTime(true);
+  };
+
+  useEffect(() => {
+    const newValue = formatStringDate(value);
+    if (newValue !== maskedValue) {
+      setMaskedValue(newValue);
+    }
+
+    if (awaitChangeForEnter) {
+      onEnter();
+      setAwaitChangeForEnter(false);
+    }
+  }, [value]);
+
+  useEffect(() => {
+    if (focusToTime && inputRef) {
+      inputRef.focus();
+      inputRef.setSelectionRange(11, 11);
+      setFocusToTime(false);
+    }
+  }, [focusToTime]);
+
+  return (
+    <div
+      className={classNames({
+        "vm-date-time-input": true,
+        "vm-date-time-input_error": error
+      })}
+    >
+      <label>{label}</label>
+      <InputMask
+        tabIndex={1}
+        inputRef={setInputRef}
+        mask="9999-99-99 99:99:99"
+        placeholder="YYYY-MM-DD HH:mm:ss"
+        value={maskedValue}
+        autoCapitalize={"none"}
+        inputMode={"numeric"}
+        maskChar={null}
+        onChange={handleMaskedChange}
+        onBlur={handleBlur}
+        onKeyUp={handleKeyUp}
+      />
+      {error && (
+        <span className="vm-date-time-input__error-text">{error}</span>
+      )}
+      <div
+        className="vm-date-time-input__icon"
+        ref={wrapperRef}
+      >
+        <Button
+          variant="text"
+          color="gray"
+          size="small"
+          startIcon={<CalendarIcon/>}
+        />
+      </div>
+      <DatePicker
+        label={pickerLabel}
+        ref={pickerRef}
+        date={maskedValue}
+        onChange={handleChangeDate}
+        targetRef={wrapperRef}
+      />
+    </div>
+  );
+};
+
+export default DateTimeInput;
diff --git a/app/vmui/packages/vmui/src/components/Main/DatePicker/DateTimeInput/style.scss b/app/vmui/packages/vmui/src/components/Main/DatePicker/DateTimeInput/style.scss
new file mode 100644
index 000000000..1e5cc23be
--- /dev/null
+++ b/app/vmui/packages/vmui/src/components/Main/DatePicker/DateTimeInput/style.scss
@@ -0,0 +1,61 @@
+@use "src/styles/variables" as *;
+
+.vm-date-time-input {
+  position: relative;
+  display: grid;
+  grid-template-columns: 1fr;
+  gap: $padding-small 0;
+  align-items: center;
+  justify-content: center;
+  margin-bottom: $padding-global;
+  cursor: pointer;
+  transition: color 200ms ease-in-out, border-bottom-color 300ms ease;
+
+  &:hover input {
+    border-bottom-color: $color-primary;
+  }
+
+  label {
+    grid-column: 1/3;
+    width: 100%;
+    font-size: $font-size-small;
+    color: $color-text-secondary;
+    user-select: none;
+  }
+
+  &__icon {
+    position: absolute;
+    bottom: 2px;
+    right: 0;
+  }
+
+  input {
+    padding: 0 0 $padding-small;
+    border-bottom: $border-divider;
+    border-top: none;
+    border-left: none;
+    border-right: none;
+    background: transparent;
+    color: $color-text;
+
+    &:focus {
+      border-bottom-color: $color-primary;
+    }
+  }
+
+  &_error input {
+    border-color: $color-error;
+
+    &:focus {
+      border-bottom-color: $color-error;
+    }
+  }
+
+  &__error-text {
+    color: $color-error;
+    font-size: $font-size-small;
+    position: absolute;
+    left: 0;
+    bottom: -$font-size-small;
+  }
+}
diff --git a/app/vmui/packages/vmui/src/components/TraceQuery/NestedNav/NestedNav.tsx b/app/vmui/packages/vmui/src/components/TraceQuery/NestedNav/NestedNav.tsx
index 73d081ed6..89528fcb3 100644
--- a/app/vmui/packages/vmui/src/components/TraceQuery/NestedNav/NestedNav.tsx
+++ b/app/vmui/packages/vmui/src/components/TraceQuery/NestedNav/NestedNav.tsx
@@ -1,4 +1,5 @@
-import React, { FC, useState } from "preact/compat";
+import React, { FC, useEffect, useRef, useState } from "preact/compat";
+import { MouseEvent } from "react";
 import LineProgress from "../../Main/LineProgress/LineProgress";
 import Trace from "../Trace";
 import { ArrowDownIcon } from "../../Main/Icons";
@@ -6,6 +7,7 @@ import "./style.scss";
 import classNames from "classnames";
 import { useAppState } from "../../../state/common/StateContext";
 import useDeviceDetect from "../../../hooks/useDeviceDetect";
+import Button from "../../Main/Button/Button";
 
 interface RecursiveProps {
   trace: Trace;
@@ -20,6 +22,23 @@ const NestedNav: FC<RecursiveProps> = ({ trace, totalMsec })  => {
   const { isDarkTheme } = useAppState();
   const { isMobile } = useDeviceDetect();
   const [openLevels, setOpenLevels] = useState({} as OpenLevels);
+  const messageRef = useRef<HTMLDivElement>(null);
+
+  const [isExpanded, setIsExpanded] = useState(false);
+  const [showFullMessage, setShowFullMessage] = useState(false);
+
+  useEffect(() => {
+    if (!messageRef.current) return;
+    const contentElement = messageRef.current;
+    const child = messageRef.current.children[0];
+    const { height } = child.getBoundingClientRect();
+    setIsExpanded(height > contentElement.clientHeight);
+  }, [trace]);
+
+  const handleClickShowMore = (e: MouseEvent<HTMLButtonElement>) => {
+    e.stopPropagation();
+    setShowFullMessage(prev => !prev);
+  };
 
   const handleListClick = (level: number) => () => {
     setOpenLevels((prevState:OpenLevels) => {
@@ -54,11 +73,28 @@ const NestedNav: FC<RecursiveProps> = ({ trace, totalMsec })  => {
         <div className="vm-nested-nav-header__progress">
           <LineProgress value={progress}/>
         </div>
-        <div className="vm-nested-nav-header__message">
-          {trace.message}
+        <div
+          className={classNames({
+            "vm-nested-nav-header__message": true,
+            "vm-nested-nav-header__message_show-full": showFullMessage,
+          })}
+          ref={messageRef}
+        >
+          <span>{trace.message}</span>
         </div>
-        <div className="vm-nested-nav-header__duration">
-          {`duration: ${trace.duration} ms`}
+        <div className="vm-nested-nav-header-bottom">
+          <div className="vm-nested-nav-header-bottom__duration">
+            {`duration: ${trace.duration} ms`}
+          </div>
+          {(isExpanded || showFullMessage) && (
+            <Button
+              variant="text"
+              size="small"
+              onClick={handleClickShowMore}
+            >
+              {showFullMessage ? "Hide" : "Show more"}
+            </Button>
+          )}
         </div>
       </div>
       {openLevels[trace.idValue] && <div>
diff --git a/app/vmui/packages/vmui/src/components/TraceQuery/NestedNav/style.scss b/app/vmui/packages/vmui/src/components/TraceQuery/NestedNav/style.scss
index 932dffd01..05f35b6b6 100644
--- a/app/vmui/packages/vmui/src/components/TraceQuery/NestedNav/style.scss
+++ b/app/vmui/packages/vmui/src/components/TraceQuery/NestedNav/style.scss
@@ -43,12 +43,33 @@
     }
 
     &__message {
+      position: relative;
       grid-column: 2;
+      line-height: 130%;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      display: -moz-box;
+      -moz-box-orient: vertical;
+      display: -webkit-box;
+      -webkit-line-clamp: 3;
+      -webkit-box-orient: vertical;
+      line-clamp: 3;
+
+      &_show-full {
+        display: block;
+        overflow: visible;
+      }
     }
 
-    &__duration {
+    &-bottom {
+      display: grid;
+      grid-template-columns: 1fr auto;
+      align-items: center;
       grid-column: 2;
-      color: $color-text-secondary;
+
+      &__duration {
+        color: $color-text-secondary;
+      }
     }
   }
 }
diff --git a/app/vmui/packages/vmui/src/hooks/useDropzone.ts b/app/vmui/packages/vmui/src/hooks/useDropzone.ts
new file mode 100644
index 000000000..7395a53bd
--- /dev/null
+++ b/app/vmui/packages/vmui/src/hooks/useDropzone.ts
@@ -0,0 +1,68 @@
+import { useState, useEffect } from "preact/compat";
+
+const useDropzone = (node: HTMLElement | null): {dragging: boolean, files: File[]} => {
+  const [files, setFiles] = useState<File[]>([]);
+  const [dragging, setDragging] = useState(false);
+
+  const handleAddFiles = (fileList: FileList) => {
+    const filesArray = Array.from(fileList || []);
+    setFiles(filesArray);
+  };
+
+  // handle drag events
+  const handleDrag = (e: DragEvent) => {
+    e.preventDefault();
+    e.stopPropagation();
+
+    if (e.type === "dragenter" || e.type === "dragover") {
+      setDragging(true);
+    } else if (e.type === "dragleave") {
+      setDragging(false);
+    }
+  };
+
+  // triggers when file is dropped
+  const handleDrop = (e: DragEvent) => {
+    e.preventDefault();
+    e.stopPropagation();
+
+    setDragging(false);
+    if (e?.dataTransfer?.files && e.dataTransfer.files[0]) {
+      handleAddFiles(e.dataTransfer.files);
+    }
+  };
+
+  // triggers when file is pasted
+  const handlePaste = (e: ClipboardEvent) => {
+    const items = e.clipboardData?.items;
+    if (!items) return;
+    const jsonFiles = Array.from(items)
+      .filter(item => item.type === "application/json")
+      .map(item => item.getAsFile())
+      .filter(file => file !== null) as File[];
+    setFiles(jsonFiles);
+  };
+
+  useEffect(() => {
+    node?.addEventListener("dragenter", handleDrag);
+    node?.addEventListener("dragleave", handleDrag);
+    node?.addEventListener("dragover", handleDrag);
+    node?.addEventListener("drop", handleDrop);
+    node?.addEventListener("paste", handlePaste);
+
+    return () => {
+      node?.removeEventListener("dragenter", handleDrag);
+      node?.removeEventListener("dragleave", handleDrag);
+      node?.removeEventListener("dragover", handleDrag);
+      node?.removeEventListener("drop", handleDrop);
+      node?.removeEventListener("paste", handlePaste);
+    };
+  }, [node]);
+
+  return {
+    files,
+    dragging,
+  };
+};
+
+export default useDropzone;
diff --git a/app/vmui/packages/vmui/src/pages/TracePage/TraceUploadButtons/TraceUploadButtons.tsx b/app/vmui/packages/vmui/src/pages/TracePage/TraceUploadButtons/TraceUploadButtons.tsx
new file mode 100644
index 000000000..a4ed9606d
--- /dev/null
+++ b/app/vmui/packages/vmui/src/pages/TracePage/TraceUploadButtons/TraceUploadButtons.tsx
@@ -0,0 +1,35 @@
+import React, { FC } from "preact/compat";
+import Button from "../../../components/Main/Button/Button";
+import Tooltip from "../../../components/Main/Tooltip/Tooltip";
+import { ChangeEvent } from "react";
+
+interface TraceUploadButtonsProps {
+  onOpenModal: () => void;
+  onChange: (e: ChangeEvent<HTMLInputElement>) => void;
+}
+
+const TraceUploadButtons: FC<TraceUploadButtonsProps> = ({ onOpenModal, onChange }) => (
+  <div className="vm-trace-page-controls">
+    <Button
+      variant="outlined"
+      onClick={onOpenModal}
+    >
+      Paste JSON
+    </Button>
+    <Tooltip title="The file must contain tracing information in JSON format">
+      <Button>
+        Upload Files
+        <input
+          id="json"
+          type="file"
+          accept="application/json"
+          multiple
+          title=" "
+          onChange={onChange}
+        />
+      </Button>
+    </Tooltip>
+  </div>
+);
+
+export default TraceUploadButtons;
diff --git a/app/vmui/packages/vmui/src/pages/TracePage/index.tsx b/app/vmui/packages/vmui/src/pages/TracePage/index.tsx
index b8307fa23..b3e6429a0 100644
--- a/app/vmui/packages/vmui/src/pages/TracePage/index.tsx
+++ b/app/vmui/packages/vmui/src/pages/TracePage/index.tsx
@@ -2,7 +2,6 @@ import React, { FC, useEffect, useMemo, useState } from "preact/compat";
 import { ChangeEvent } from "react";
 import Trace from "../../components/TraceQuery/Trace";
 import TracingsView from "../../components/TraceQuery/TracingsView";
-import Tooltip from "../../components/Main/Tooltip/Tooltip";
 import Button from "../../components/Main/Button/Button";
 import Alert from "../../components/Main/Alert/Alert";
 import "./style.scss";
@@ -11,6 +10,8 @@ import Modal from "../../components/Main/Modal/Modal";
 import JsonForm from "./JsonForm/JsonForm";
 import { ErrorTypes } from "../../types";
 import { useSearchParams } from "react-router-dom";
+import useDropzone from "../../hooks/useDropzone";
+import TraceUploadButtons from "./TraceUploadButtons/TraceUploadButtons";
 
 const TracePage: FC = () => {
   const [openModal, setOpenModal] = useState(false);
@@ -46,9 +47,7 @@ const TracePage: FC = () => {
     }
   };
 
-  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
-    setErrors([]);
-    const files = Array.from(e.target.files || []);
+  const handleReadFiles = (files: File[]) => {
     files.map(f => {
       const reader = new FileReader();
       const filename = f?.name || "";
@@ -58,6 +57,12 @@ const TracePage: FC = () => {
       };
       reader.readAsText(f);
     });
+  };
+
+  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
+    setErrors([]);
+    const files = Array.from(e.target.files || []);
+    handleReadFiles(files);
     e.target.value = "";
   };
 
@@ -78,29 +83,12 @@ const TracePage: FC = () => {
     setSearchParams({});
   }, []);
 
-  const UploadButtons = () => (
-    <div className="vm-trace-page-controls">
-      <Button
-        variant="outlined"
-        onClick={handleOpenModal}
-      >
-        Paste JSON
-      </Button>
-      <Tooltip title="The file must contain tracing information in JSON format">
-        <Button>
-          Upload Files
-          <input
-            id="json"
-            type="file"
-            accept="application/json"
-            multiple
-            title=" "
-            onChange={handleChange}
-          />
-        </Button>
-      </Tooltip>
-    </div>
-  );
+
+  const { files, dragging } = useDropzone(document.body);
+
+  useEffect(() => {
+    handleReadFiles(files);
+  }, [files]);
 
   return (
     <div className="vm-trace-page">
@@ -126,7 +114,12 @@ const TracePage: FC = () => {
           ))}
         </div>
         <div>
-          {hasTraces && <UploadButtons/>}
+          {hasTraces && (
+            <TraceUploadButtons
+              onOpenModal={handleOpenModal}
+              onChange={handleChange}
+            />
+          )}
         </div>
       </div>
 
@@ -158,8 +151,13 @@ const TracePage: FC = () => {
             </a>
             {"\n"}
             Tracing graph will be displayed after file upload.
+            {"\n"}
+            Attach files by dragging & dropping, selecting or pasting them.
           </p>
-          <UploadButtons/>
+          <TraceUploadButtons
+            onOpenModal={handleOpenModal}
+            onChange={handleChange}
+          />
         </div>
       )}
 
@@ -177,6 +175,10 @@ const TracePage: FC = () => {
           />
         </Modal>
       )}
+
+      {dragging && (
+        <div className="vm-trace-page__dropzone"/>
+      )}
     </div>
   );
 };
diff --git a/app/vmui/packages/vmui/src/pages/TracePage/style.scss b/app/vmui/packages/vmui/src/pages/TracePage/style.scss
index 56e05b0c6..33e8eba09 100644
--- a/app/vmui/packages/vmui/src/pages/TracePage/style.scss
+++ b/app/vmui/packages/vmui/src/pages/TracePage/style.scss
@@ -75,4 +75,19 @@
       line-height: 1.8;
     }
   }
+
+  &__dropzone {
+    position: fixed;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    box-shadow: inset $color-primary 0 0 10px;
+    opacity: 0.5;
+    z-index: 100;
+    pointer-events: none;
+  }
 }
diff --git a/deployment/docker/Makefile b/deployment/docker/Makefile
index 8a16f2cda..3df5df6e9 100644
--- a/deployment/docker/Makefile
+++ b/deployment/docker/Makefile
@@ -105,13 +105,9 @@ app-via-docker-linux-arm:
 	CGO_ENABLED=0 GOOS=linux GOARCH=arm $(MAKE) app-via-docker-goos-goarch
 
 app-via-docker-linux-arm64:
-ifeq ($(APP_NAME),vmagent)
-	CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(MAKE) app-via-docker-goos-goarch
-else
 	APP_SUFFIX='-linux-arm64' \
 	DOCKER_OPTS='--env CGO_ENABLED=1 --env GOOS=linux --env GOARCH=arm64 --env CC=/opt/cross-builder/aarch64-linux-musl-cross/bin/aarch64-linux-musl-gcc' \
 	$(MAKE) app-via-docker
-endif
 
 app-via-docker-linux-ppc64le:
 	CGO_ENABLED=0 GOOS=linux GOARCH=ppc64le $(MAKE) app-via-docker-goos-goarch
@@ -132,7 +128,7 @@ app-via-docker-openbsd-amd64:
 	CGO_ENABLED=0 GOOS=openbsd GOARCH=amd64 $(MAKE) app-via-docker-goos-goarch
 
 app-via-docker-windows-amd64:
-	APP_SUFFIX='-$(GOARCH)' \
+	APP_SUFFIX='-amd64' \
 	DOCKER_OPTS='--env CGO_ENABLED=0 --env GOOS=windows --env GOARCH=amd64' \
 	$(MAKE) app-via-docker-windows
 
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 2a0814656..d11189715 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -15,10 +15,21 @@ The following tip changes can be tested by building VictoriaMetrics components f
 
 ## tip
 
+**Update note: this release contains backwards-incompatible change in storage data format,
+so the previous versions of VictoriaMetrics will exit with the `unexpected number of substrings in the part name` error when trying to run them on the data
+created by v1.90.0 or newer versions. The solution is to upgrade to v1.90.0 or newer releases**
+
+* FEATURE: publish VictoriaMetrics binaries for Windows. See [this](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3236), [this](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3821) and [this](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/70) issues.
 * FEATURE: log metrics with truncated labels if the length of label value in the ingested metric exceeds `-maxLabelValueLen`. This should simplify debugging for this case.
 * FEATURE: [vmagent](https://docs.victoriametrics.com/vmagent.html): add support for [VictoriaMetrics remote write protocol](https://docs.victoriametrics.com/vmagent.html#victoriametrics-remote-write-protocol) when [sending / receiving data to / from Kafka](https://docs.victoriametrics.com/vmagent.html#kafka-integration). This protocol allows saving egress network bandwidth costs when sending data from `vmagent` to `Kafka` located in another datacenter or availability zone. See [this feature request](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/1225).
 * FEATURE: [vmagent](https://docs.victoriametrics.com/vmagent.html): add `--kafka.consumer.topic.concurrency` command-line flag. It controls the number of Kafka consumer workers to use by `vmagent`. It should eliminate the need to start multiple `vmagent` instances to improve data transfer rate. See [this feature request](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/1957).
 * FEATURE: [vmagent](https://docs.victoriametrics.com/vmagent.html): add support for [Kafka producer and consumer](https://docs.victoriametrics.com/vmagent.html#kafka-integration) on `arm64` machines. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/2271).
+* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): add support for drag'n'drop and paste from clipboard in the "Trace analyzer" page. See [this pull request](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/3971).
+* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): Hide messages longer than 3 lines in the trace. You can view the full message by clicking on the `show more` button. See [this pull request](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/3971).
+* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): Add the ability to manually input date and time when selecting a time range. See [this pull request](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/3968).
+* FEATURE: [vmctl](https://docs.victoriametrics.com/vmctl.html): `vmctl` check whether TTY is available and disable prompt automatically and progress bar when TTY not available. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3823).
+
+* BUGFIX: prevent from slow [snapshot creating](https://docs.victoriametrics.com/#how-to-work-with-snapshots) under high data ingestion rate. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3551).
 
 ## [v1.89.1](https://github.com/VictoriaMetrics/VictoriaMetrics/releases/tag/v1.89.1)
 
diff --git a/docs/README.md b/docs/README.md
index 455094ef0..7817851bd 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -1448,12 +1448,14 @@ can be configured with the `-inmemoryDataFlushInterval` command-line flag (note
 In-memory parts are persisted to disk into `part` directories under the `<-storageDataPath>/data/small/YYYY_MM/` folder,
 where `YYYY_MM` is the month partition for the stored data. For example, `2022_11` is the partition for `parts`
 with [raw samples](https://docs.victoriametrics.com/keyConcepts.html#raw-samples) from `November 2022`.
+Each partition directory contains `parts.json` file with the actual list of parts in the partition.
 
-The `part` directory has the following name pattern: `rowsCount_blocksCount_minTimestamp_maxTimestamp`, where:
+Every `part` directory contains `metadata.json` file with the following fields:
 
-- `rowsCount` - the number of [raw samples](https://docs.victoriametrics.com/keyConcepts.html#raw-samples) stored in the part
-- `blocksCount` - the number of blocks stored in the part (see details about blocks below)
-- `minTimestamp` and `maxTimestamp` - minimum and maximum timestamps across raw samples stored in the part
+- `RowsCount` - the number of [raw samples](https://docs.victoriametrics.com/keyConcepts.html#raw-samples) stored in the part
+- `BlocksCount` - the number of blocks stored in the part (see details about blocks below)
+- `MinTimestamp` and `MaxTimestamp` - minimum and maximum timestamps across raw samples stored in the part
+- `MinDedupInterval` - the [deduplication interval](#deduplication) applied to the given part.
 
 Each `part` consists of `blocks` sorted by internal time series id (aka `TSID`).
 Each `block` contains up to 8K [raw samples](https://docs.victoriametrics.com/keyConcepts.html#raw-samples),
@@ -1475,9 +1477,8 @@ for fast block lookups, which belong to the given `TSID` and cover the given tim
   and [freeing up disk space for the deleted time series](#how-to-delete-time-series) are performed during the merge
 
 Newly added `parts` either successfully appear in the storage or fail to appear.
-The newly added `parts` are being created in a temporary directory under `<-storageDataPath>/data/{small,big}/YYYY_MM/tmp` folder.
-When the newly added `part` is fully written and [fsynced](https://man7.org/linux/man-pages/man2/fsync.2.html)
-to a temporary directory, then it is atomically moved to the storage directory.
+The newly added `part` is atomically registered in the `parts.json` file under the corresponding partition
+after it is fully written and [fsynced](https://man7.org/linux/man-pages/man2/fsync.2.html) to the storage.
 Thanks to this alogrithm, storage never contains partially created parts, even if hardware power off
 occurrs in the middle of writing the `part` to disk - such incompletely written `parts`
 are automatically deleted on the next VictoriaMetrics start.
@@ -1506,8 +1507,7 @@ Retention is configured with the `-retentionPeriod` command-line flag, which tak
 
 Data is split in per-month partitions inside `<-storageDataPath>/data/{small,big}` folders.
 Data partitions outside the configured retention are deleted on the first day of the new month.
-Each partition consists of one or more data parts with the following name pattern `rowsCount_blocksCount_minTimestamp_maxTimestamp`.
-Data parts outside of the configured retention are eventually deleted during
+Each partition consists of one or more data parts. Data parts outside of the configured retention are eventually deleted during
 [background merge](https://medium.com/@valyala/how-victoriametrics-makes-instant-snapshots-for-multi-terabyte-time-series-data-e1f3fb0e0282).
 
 The maximum disk space usage for a given `-retentionPeriod` is going to be (`-retentionPeriod` + 1) months.
diff --git a/docs/Single-server-VictoriaMetrics.md b/docs/Single-server-VictoriaMetrics.md
index 814ead981..625d67898 100644
--- a/docs/Single-server-VictoriaMetrics.md
+++ b/docs/Single-server-VictoriaMetrics.md
@@ -1451,12 +1451,14 @@ can be configured with the `-inmemoryDataFlushInterval` command-line flag (note
 In-memory parts are persisted to disk into `part` directories under the `<-storageDataPath>/data/small/YYYY_MM/` folder,
 where `YYYY_MM` is the month partition for the stored data. For example, `2022_11` is the partition for `parts`
 with [raw samples](https://docs.victoriametrics.com/keyConcepts.html#raw-samples) from `November 2022`.
+Each partition directory contains `parts.json` file with the actual list of parts in the partition.
 
-The `part` directory has the following name pattern: `rowsCount_blocksCount_minTimestamp_maxTimestamp`, where:
+Every `part` directory contains `metadata.json` file with the following fields:
 
-- `rowsCount` - the number of [raw samples](https://docs.victoriametrics.com/keyConcepts.html#raw-samples) stored in the part
-- `blocksCount` - the number of blocks stored in the part (see details about blocks below)
-- `minTimestamp` and `maxTimestamp` - minimum and maximum timestamps across raw samples stored in the part
+- `RowsCount` - the number of [raw samples](https://docs.victoriametrics.com/keyConcepts.html#raw-samples) stored in the part
+- `BlocksCount` - the number of blocks stored in the part (see details about blocks below)
+- `MinTimestamp` and `MaxTimestamp` - minimum and maximum timestamps across raw samples stored in the part
+- `MinDedupInterval` - the [deduplication interval](#deduplication) applied to the given part.
 
 Each `part` consists of `blocks` sorted by internal time series id (aka `TSID`).
 Each `block` contains up to 8K [raw samples](https://docs.victoriametrics.com/keyConcepts.html#raw-samples),
@@ -1478,9 +1480,8 @@ for fast block lookups, which belong to the given `TSID` and cover the given tim
   and [freeing up disk space for the deleted time series](#how-to-delete-time-series) are performed during the merge
 
 Newly added `parts` either successfully appear in the storage or fail to appear.
-The newly added `parts` are being created in a temporary directory under `<-storageDataPath>/data/{small,big}/YYYY_MM/tmp` folder.
-When the newly added `part` is fully written and [fsynced](https://man7.org/linux/man-pages/man2/fsync.2.html)
-to a temporary directory, then it is atomically moved to the storage directory.
+The newly added `part` is atomically registered in the `parts.json` file under the corresponding partition
+after it is fully written and [fsynced](https://man7.org/linux/man-pages/man2/fsync.2.html) to the storage.
 Thanks to this alogrithm, storage never contains partially created parts, even if hardware power off
 occurrs in the middle of writing the `part` to disk - such incompletely written `parts`
 are automatically deleted on the next VictoriaMetrics start.
@@ -1509,8 +1510,7 @@ Retention is configured with the `-retentionPeriod` command-line flag, which tak
 
 Data is split in per-month partitions inside `<-storageDataPath>/data/{small,big}` folders.
 Data partitions outside the configured retention are deleted on the first day of the new month.
-Each partition consists of one or more data parts with the following name pattern `rowsCount_blocksCount_minTimestamp_maxTimestamp`.
-Data parts outside of the configured retention are eventually deleted during
+Each partition consists of one or more data parts. Data parts outside of the configured retention are eventually deleted during
 [background merge](https://medium.com/@valyala/how-victoriametrics-makes-instant-snapshots-for-multi-terabyte-time-series-data-e1f3fb0e0282).
 
 The maximum disk space usage for a given `-retentionPeriod` is going to be (`-retentionPeriod` + 1) months.
diff --git a/lib/fs/fs.go b/lib/fs/fs.go
index f346b7953..d438ddc90 100644
--- a/lib/fs/fs.go
+++ b/lib/fs/fs.go
@@ -247,21 +247,16 @@ var atomicDirRemoveCounter = uint64(time.Now().UnixNano())
 //
 // Such directories may be left on unclean shutdown during MustRemoveDirAtomic call.
 func MustRemoveTemporaryDirs(dir string) {
-	d, err := os.Open(dir)
-	if err != nil {
-		logger.Panicf("FATAL: cannot open dir: %s", err)
-	}
-	defer MustClose(d)
-	fis, err := d.Readdir(-1)
+	des, err := os.ReadDir(dir)
 	if err != nil {
-		logger.Panicf("FATAL: cannot read dir %q: %s", dir, err)
+		logger.Panicf("FATAL: cannot read dir: %s", err)
 	}
-	for _, fi := range fis {
-		if !IsDirOrSymlink(fi) {
+	for _, de := range des {
+		if !IsDirOrSymlink(de) {
 			// Skip non-directories
 			continue
 		}
-		dirName := fi.Name()
+		dirName := de.Name()
 		if IsScheduledForRemoval(dirName) {
 			fullPath := dir + "/" + dirName
 			MustRemoveAll(fullPath)
@@ -276,26 +271,16 @@ func HardLinkFiles(srcDir, dstDir string) error {
 		return fmt.Errorf("cannot create dstDir=%q: %w", dstDir, err)
 	}
 
-	d, err := os.Open(srcDir)
+	des, err := os.ReadDir(srcDir)
 	if err != nil {
-		return fmt.Errorf("cannot open srcDir: %w", err)
+		return fmt.Errorf("cannot read files in scrDir: %w", err)
 	}
-	defer func() {
-		if err := d.Close(); err != nil {
-			logger.Panicf("FATAL: cannot close %q: %s", srcDir, err)
-		}
-	}()
-
-	fis, err := d.Readdir(-1)
-	if err != nil {
-		return fmt.Errorf("cannot read files in scrDir=%q: %w", srcDir, err)
-	}
-	for _, fi := range fis {
-		if IsDirOrSymlink(fi) {
+	for _, de := range des {
+		if IsDirOrSymlink(de) {
 			// Skip directories.
 			continue
 		}
-		fn := fi.Name()
+		fn := de.Name()
 		srcPath := srcDir + "/" + fn
 		dstPath := dstDir + "/" + fn
 		if err := os.Link(srcPath, dstPath); err != nil {
@@ -307,9 +292,9 @@ func HardLinkFiles(srcDir, dstDir string) error {
 	return nil
 }
 
-// IsDirOrSymlink returns true if fi is directory or symlink.
-func IsDirOrSymlink(fi os.FileInfo) bool {
-	return fi.IsDir() || (fi.Mode()&os.ModeSymlink == os.ModeSymlink)
+// IsDirOrSymlink returns true if de is directory or symlink.
+func IsDirOrSymlink(de os.DirEntry) bool {
+	return de.IsDir() || (de.Type()&os.ModeSymlink == os.ModeSymlink)
 }
 
 // SymlinkRelative creates relative symlink for srcPath in dstPath.
diff --git a/lib/mergeset/block_stream_reader.go b/lib/mergeset/block_stream_reader.go
index 528a89017..08d4733e0 100644
--- a/lib/mergeset/block_stream_reader.go
+++ b/lib/mergeset/block_stream_reader.go
@@ -143,8 +143,8 @@ func (bsr *blockStreamReader) InitFromFilePart(path string) error {
 
 	path = filepath.Clean(path)
 
-	if err := bsr.ph.ParseFromPath(path); err != nil {
-		return fmt.Errorf("cannot parse partHeader data from %q: %w", path, err)
+	if err := bsr.ph.ReadMetadata(path); err != nil {
+		return fmt.Errorf("cannot read metadata from %q: %w", path, err)
 	}
 
 	metaindexPath := path + "/metaindex.bin"
diff --git a/lib/mergeset/part.go b/lib/mergeset/part.go
index f8f45f1b8..090b3f116 100644
--- a/lib/mergeset/part.go
+++ b/lib/mergeset/part.go
@@ -2,7 +2,6 @@ package mergeset
 
 import (
 	"fmt"
-	"path/filepath"
 	"sync"
 	"unsafe"
 
@@ -68,11 +67,9 @@ type part struct {
 }
 
 func openFilePart(path string) (*part, error) {
-	path = filepath.Clean(path)
-
 	var ph partHeader
-	if err := ph.ParseFromPath(path); err != nil {
-		return nil, fmt.Errorf("cannot parse path to part: %w", err)
+	if err := ph.ReadMetadata(path); err != nil {
+		return nil, fmt.Errorf("cannot read part metadata: %w", err)
 	}
 
 	metaindexPath := path + "/metaindex.bin"
diff --git a/lib/mergeset/part_header.go b/lib/mergeset/part_header.go
index 43a5ea43b..4489d947c 100644
--- a/lib/mergeset/part_header.go
+++ b/lib/mergeset/part_header.go
@@ -5,11 +5,9 @@ import (
 	"encoding/json"
 	"fmt"
 	"os"
-	"path/filepath"
-	"strconv"
-	"strings"
 
 	"github.com/VictoriaMetrics/VictoriaMetrics/lib/fs"
+	"github.com/VictoriaMetrics/VictoriaMetrics/lib/logger"
 )
 
 type partHeader struct {
@@ -79,50 +77,10 @@ func (ph *partHeader) CopyFrom(src *partHeader) {
 	ph.lastItem = append(ph.lastItem[:0], src.lastItem...)
 }
 
-func (ph *partHeader) ParseFromPath(partPath string) error {
+func (ph *partHeader) ReadMetadata(partPath string) error {
 	ph.Reset()
 
-	partPath = filepath.Clean(partPath)
-
-	// Extract encoded part name.
-	n := strings.LastIndexByte(partPath, '/')
-	if n < 0 {
-		return fmt.Errorf("cannot find encoded part name in the path %q", partPath)
-	}
-	partName := partPath[n+1:]
-
-	// PartName must have the following form:
-	// itemsCount_blocksCount_Garbage
-	a := strings.Split(partName, "_")
-	if len(a) != 3 {
-		return fmt.Errorf("unexpected number of substrings in the part name %q: got %d; want %d", partName, len(a), 3)
-	}
-
-	// Read itemsCount from partName.
-	itemsCount, err := strconv.ParseUint(a[0], 10, 64)
-	if err != nil {
-		return fmt.Errorf("cannot parse itemsCount from partName %q: %w", partName, err)
-	}
-	ph.itemsCount = itemsCount
-	if ph.itemsCount <= 0 {
-		return fmt.Errorf("part %q cannot contain zero items", partPath)
-	}
-
-	// Read blocksCount from partName.
-	blocksCount, err := strconv.ParseUint(a[1], 10, 64)
-	if err != nil {
-		return fmt.Errorf("cannot parse blocksCount from partName %q: %w", partName, err)
-	}
-	ph.blocksCount = blocksCount
-	if ph.blocksCount <= 0 {
-		return fmt.Errorf("part %q cannot contain zero blocks", partPath)
-	}
-	if ph.blocksCount > ph.itemsCount {
-		return fmt.Errorf("the number of blocks cannot exceed the number of items in the part %q; got blocksCount=%d, itemsCount=%d",
-			partPath, ph.blocksCount, ph.itemsCount)
-	}
-
-	// Read other ph fields from metadata.
+	// Read ph fields from metadata.
 	metadataPath := partPath + "/metadata.json"
 	metadata, err := os.ReadFile(metadataPath)
 	if err != nil {
@@ -133,12 +91,20 @@ func (ph *partHeader) ParseFromPath(partPath string) error {
 	if err := json.Unmarshal(metadata, &phj); err != nil {
 		return fmt.Errorf("cannot parse %q: %w", metadataPath, err)
 	}
-	if ph.itemsCount != phj.ItemsCount {
-		return fmt.Errorf("invalid ItemsCount in %q; got %d; want %d", metadataPath, phj.ItemsCount, ph.itemsCount)
+
+	if phj.ItemsCount <= 0 {
+		return fmt.Errorf("part %q cannot contain zero items", partPath)
+	}
+	ph.itemsCount = phj.ItemsCount
+
+	if phj.BlocksCount <= 0 {
+		return fmt.Errorf("part %q cannot contain zero blocks", partPath)
 	}
-	if ph.blocksCount != phj.BlocksCount {
-		return fmt.Errorf("invalid BlocksCount in %q; got %d; want %d", metadataPath, phj.BlocksCount, ph.blocksCount)
+	if phj.BlocksCount > phj.ItemsCount {
+		return fmt.Errorf("the number of blocks cannot exceed the number of items in the part %q; got blocksCount=%d, itemsCount=%d",
+			partPath, phj.BlocksCount, phj.ItemsCount)
 	}
+	ph.blocksCount = phj.BlocksCount
 
 	ph.firstItem = append(ph.firstItem[:0], phj.FirstItem...)
 	ph.lastItem = append(ph.lastItem[:0], phj.LastItem...)
@@ -146,11 +112,6 @@ func (ph *partHeader) ParseFromPath(partPath string) error {
 	return nil
 }
 
-func (ph *partHeader) Path(tablePath string, suffix uint64) string {
-	tablePath = filepath.Clean(tablePath)
-	return fmt.Sprintf("%s/%d_%d_%016X", tablePath, ph.itemsCount, ph.blocksCount, suffix)
-}
-
 func (ph *partHeader) WriteMetadata(partPath string) error {
 	phj := &partHeaderJSON{
 		ItemsCount:  ph.itemsCount,
@@ -158,9 +119,9 @@ func (ph *partHeader) WriteMetadata(partPath string) error {
 		FirstItem:   append([]byte{}, ph.firstItem...),
 		LastItem:    append([]byte{}, ph.lastItem...),
 	}
-	metadata, err := json.MarshalIndent(&phj, "", "\t")
+	metadata, err := json.Marshal(&phj)
 	if err != nil {
-		return fmt.Errorf("cannot marshal metadata: %w", err)
+		logger.Panicf("BUG: cannot marshal partHeader metadata: %s", err)
 	}
 	metadataPath := partPath + "/metadata.json"
 	if err := fs.WriteFileAtomically(metadataPath, metadata, false); err != nil {
diff --git a/lib/mergeset/table.go b/lib/mergeset/table.go
index 76c60cac8..f42b7ad7c 100644
--- a/lib/mergeset/table.go
+++ b/lib/mergeset/table.go
@@ -1,6 +1,7 @@
 package mergeset
 
 import (
+	"encoding/json"
 	"errors"
 	"fmt"
 	"os"
@@ -12,7 +13,6 @@ import (
 	"time"
 	"unsafe"
 
-	"github.com/VictoriaMetrics/VictoriaMetrics/lib/bytesutil"
 	"github.com/VictoriaMetrics/VictoriaMetrics/lib/cgroup"
 	"github.com/VictoriaMetrics/VictoriaMetrics/lib/fasttime"
 	"github.com/VictoriaMetrics/VictoriaMetrics/lib/fs"
@@ -141,8 +141,6 @@ type Table struct {
 	// which may need to be merged.
 	needMergeCh chan struct{}
 
-	snapshotLock sync.RWMutex
-
 	flockF *os.File
 
 	stopCh chan struct{}
@@ -274,7 +272,9 @@ type partWrapper struct {
 
 	mp *inmemoryPart
 
-	refCount uint64
+	refCount uint32
+
+	mustBeDeleted uint32
 
 	isInMerge bool
 
@@ -283,18 +283,22 @@ type partWrapper struct {
 }
 
 func (pw *partWrapper) incRef() {
-	atomic.AddUint64(&pw.refCount, 1)
+	atomic.AddUint32(&pw.refCount, 1)
 }
 
 func (pw *partWrapper) decRef() {
-	n := atomic.AddUint64(&pw.refCount, ^uint64(0))
-	if int64(n) < 0 {
-		logger.Panicf("BUG: pw.refCount must be bigger than 0; got %d", int64(n))
+	n := atomic.AddUint32(&pw.refCount, ^uint32(0))
+	if int32(n) < 0 {
+		logger.Panicf("BUG: pw.refCount must be bigger than 0; got %d", int32(n))
 	}
 	if n > 0 {
 		return
 	}
 
+	deletePath := ""
+	if pw.mp == nil && atomic.LoadUint32(&pw.mustBeDeleted) != 0 {
+		deletePath = pw.p.path
+	}
 	if pw.mp != nil {
 		// Do not return pw.mp to pool via putInmemoryPart(),
 		// since pw.mp size may be too big compared to other entries stored in the pool.
@@ -303,6 +307,10 @@ func (pw *partWrapper) decRef() {
 	}
 	pw.p.MustClose()
 	pw.p = nil
+
+	if deletePath != "" {
+		fs.MustRemoveAll(deletePath)
+	}
 }
 
 // OpenTable opens a table on the given path.
@@ -512,7 +520,7 @@ func (tb *Table) UpdateMetrics(m *TableMetrics) {
 		m.InmemoryBlocksCount += p.ph.blocksCount
 		m.InmemoryItemsCount += p.ph.itemsCount
 		m.InmemorySizeBytes += p.size
-		m.PartsRefCount += atomic.LoadUint64(&pw.refCount)
+		m.PartsRefCount += uint64(atomic.LoadUint32(&pw.refCount))
 	}
 
 	m.FilePartsCount += uint64(len(tb.fileParts))
@@ -521,7 +529,7 @@ func (tb *Table) UpdateMetrics(m *TableMetrics) {
 		m.FileBlocksCount += p.ph.blocksCount
 		m.FileItemsCount += p.ph.itemsCount
 		m.FileSizeBytes += p.size
-		m.PartsRefCount += atomic.LoadUint64(&pw.refCount)
+		m.PartsRefCount += uint64(atomic.LoadUint32(&pw.refCount))
 	}
 	tb.partsLock.Unlock()
 
@@ -662,35 +670,20 @@ func (tb *Table) flushInmemoryItems() {
 }
 
 func (tb *Table) flushInmemoryParts(isFinal bool) {
-	for {
-		currentTime := time.Now()
-		var pws []*partWrapper
+	currentTime := time.Now()
+	var pws []*partWrapper
 
-		tb.partsLock.Lock()
-		for _, pw := range tb.inmemoryParts {
-			if !pw.isInMerge && (isFinal || pw.flushToDiskDeadline.Before(currentTime)) {
-				pw.isInMerge = true
-				pws = append(pws, pw)
-			}
+	tb.partsLock.Lock()
+	for _, pw := range tb.inmemoryParts {
+		if !pw.isInMerge && (isFinal || pw.flushToDiskDeadline.Before(currentTime)) {
+			pw.isInMerge = true
+			pws = append(pws, pw)
 		}
-		tb.partsLock.Unlock()
+	}
+	tb.partsLock.Unlock()
 
-		if err := tb.mergePartsOptimal(pws); err != nil {
-			logger.Panicf("FATAL: cannot merge in-memory parts: %s", err)
-		}
-		if !isFinal {
-			return
-		}
-		tb.partsLock.Lock()
-		n := len(tb.inmemoryParts)
-		tb.partsLock.Unlock()
-		if n == 0 {
-			// All the in-memory parts were flushed to disk.
-			return
-		}
-		// Some parts weren't flushed to disk because they were being merged.
-		// Sleep for a while and try flushing them again.
-		time.Sleep(10 * time.Millisecond)
+	if err := tb.mergePartsOptimal(pws); err != nil {
+		logger.Panicf("FATAL: cannot merge in-memory parts: %s", err)
 	}
 }
 
@@ -1089,21 +1082,19 @@ func (tb *Table) mergeParts(pws []*partWrapper, stopCh <-chan struct{}, isFinal
 
 	// Initialize destination paths.
 	dstPartType := getDstPartType(pws, isFinal)
-	tmpPartPath, mergeIdx := tb.getDstPartPaths(dstPartType)
+	mergeIdx := tb.nextMergeIdx()
+	dstPartPath := ""
+	if dstPartType == partFile {
+		dstPartPath = fmt.Sprintf("%s/%016X", tb.path, mergeIdx)
+	}
 
 	if isFinal && len(pws) == 1 && pws[0].mp != nil {
 		// Fast path: flush a single in-memory part to disk.
 		mp := pws[0].mp
-		if tmpPartPath == "" {
-			logger.Panicf("BUG: tmpPartPath must be non-empty")
-		}
-		if err := mp.StoreToDisk(tmpPartPath); err != nil {
-			return fmt.Errorf("cannot store in-memory part to %q: %w", tmpPartPath, err)
-		}
-		pwNew, err := tb.openCreatedPart(&mp.ph, pws, nil, tmpPartPath, mergeIdx)
-		if err != nil {
-			return fmt.Errorf("cannot atomically register the created part: %w", err)
+		if err := mp.StoreToDisk(dstPartPath); err != nil {
+			logger.Panicf("FATAL: cannot store in-memory part to %s: %s", dstPartPath, err)
 		}
+		pwNew := tb.openCreatedPart(pws, nil, dstPartPath)
 		tb.swapSrcWithDstParts(pws, pwNew, dstPartType)
 		return nil
 	}
@@ -1111,7 +1102,7 @@ func (tb *Table) mergeParts(pws []*partWrapper, stopCh <-chan struct{}, isFinal
 	// Prepare BlockStreamReaders for source parts.
 	bsrs, err := openBlockStreamReaders(pws)
 	if err != nil {
-		return err
+		logger.Panicf("FATAL: cannot open source parts for merging: %s", err)
 	}
 	closeBlockStreamReaders := func() {
 		for _, bsr := range bsrs {
@@ -1136,45 +1127,30 @@ func (tb *Table) mergeParts(pws []*partWrapper, stopCh <-chan struct{}, isFinal
 		mpNew = &inmemoryPart{}
 		bsw.InitFromInmemoryPart(mpNew, compressLevel)
 	} else {
-		if tmpPartPath == "" {
-			logger.Panicf("BUG: tmpPartPath must be non-empty")
-		}
 		nocache := srcItemsCount > maxItemsPerCachedPart()
-		if err := bsw.InitFromFilePart(tmpPartPath, nocache, compressLevel); err != nil {
-			closeBlockStreamReaders()
-			return fmt.Errorf("cannot create destination part at %q: %w", tmpPartPath, err)
+		if err := bsw.InitFromFilePart(dstPartPath, nocache, compressLevel); err != nil {
+			logger.Panicf("FATAL: cannot create destination part at %s: %s", dstPartPath, err)
 		}
 	}
 
 	// Merge source parts to destination part.
-	ph, err := tb.mergePartsInternal(tmpPartPath, bsw, bsrs, dstPartType, stopCh)
+	ph, err := tb.mergePartsInternal(dstPartPath, bsw, bsrs, dstPartType, stopCh)
 	putBlockStreamWriter(bsw)
 	closeBlockStreamReaders()
 	if err != nil {
-		return fmt.Errorf("cannot merge %d parts: %w", len(pws), err)
+		return err
 	}
 	if mpNew != nil {
 		// Update partHeader for destination inmemory part after the merge.
 		mpNew.ph = *ph
 	}
 
-	// Atomically move the created part from tmpPartPath to its destination
-	// and swap the source parts with the newly created part.
-	pwNew, err := tb.openCreatedPart(ph, pws, mpNew, tmpPartPath, mergeIdx)
-	if err != nil {
-		return fmt.Errorf("cannot atomically register the created part: %w", err)
-	}
-	dstItemsCount := uint64(0)
-	dstBlocksCount := uint64(0)
-	dstSize := uint64(0)
-	dstPartPath := ""
-	if pwNew != nil {
-		pDst := pwNew.p
-		dstItemsCount = pDst.ph.itemsCount
-		dstBlocksCount = pDst.ph.blocksCount
-		dstSize = pDst.size
-		dstPartPath = pDst.path
-	}
+	// Atomically swap the source parts with the newly created part.
+	pwNew := tb.openCreatedPart(pws, mpNew, dstPartPath)
+	pDst := pwNew.p
+	dstItemsCount := pDst.ph.itemsCount
+	dstBlocksCount := pDst.ph.blocksCount
+	dstSize := pDst.size
 
 	tb.swapSrcWithDstParts(pws, pwNew, dstPartType)
 
@@ -1222,19 +1198,6 @@ func getDstPartType(pws []*partWrapper, isFinal bool) partType {
 	return partInmemory
 }
 
-func (tb *Table) getDstPartPaths(dstPartType partType) (string, uint64) {
-	tmpPartPath := ""
-	mergeIdx := tb.nextMergeIdx()
-	switch dstPartType {
-	case partInmemory:
-	case partFile:
-		tmpPartPath = fmt.Sprintf("%s/tmp/%016X", tb.path, mergeIdx)
-	default:
-		logger.Panicf("BUG: unknown partType=%d", dstPartType)
-	}
-	return tmpPartPath, mergeIdx
-}
-
 func openBlockStreamReaders(pws []*partWrapper) ([]*blockStreamReader, error) {
 	bsrs := make([]*blockStreamReader, 0, len(pws))
 	for _, pw := range pws {
@@ -1254,7 +1217,7 @@ func openBlockStreamReaders(pws []*partWrapper) ([]*blockStreamReader, error) {
 	return bsrs, nil
 }
 
-func (tb *Table) mergePartsInternal(tmpPartPath string, bsw *blockStreamWriter, bsrs []*blockStreamReader, dstPartType partType, stopCh <-chan struct{}) (*partHeader, error) {
+func (tb *Table) mergePartsInternal(dstPartPath string, bsw *blockStreamWriter, bsrs []*blockStreamReader, dstPartType partType, stopCh <-chan struct{}) (*partHeader, error) {
 	var ph partHeader
 	var itemsMerged *uint64
 	var mergesCount *uint64
@@ -1276,56 +1239,34 @@ func (tb *Table) mergePartsInternal(tmpPartPath string, bsw *blockStreamWriter,
 	atomic.AddUint64(activeMerges, ^uint64(0))
 	atomic.AddUint64(mergesCount, 1)
 	if err != nil {
-		return nil, fmt.Errorf("cannot merge parts to %q: %w", tmpPartPath, err)
+		return nil, fmt.Errorf("cannot merge %d parts to %s: %w", len(bsrs), dstPartPath, err)
 	}
-	if tmpPartPath != "" {
-		if err := ph.WriteMetadata(tmpPartPath); err != nil {
-			return nil, fmt.Errorf("cannot write metadata to destination part %q: %w", tmpPartPath, err)
+	if dstPartPath != "" {
+		if err := ph.WriteMetadata(dstPartPath); err != nil {
+			logger.Panicf("FATAL: cannot write metadata to %s: %s", dstPartPath, err)
 		}
 	}
 	return &ph, nil
 }
 
-func (tb *Table) openCreatedPart(ph *partHeader, pws []*partWrapper, mpNew *inmemoryPart, tmpPartPath string, mergeIdx uint64) (*partWrapper, error) {
-	dstPartPath := ""
-	if mpNew == nil || !areAllInmemoryParts(pws) {
-		// Either source or destination parts are located on disk.
-		// Create a transaction for atomic deleting of old parts and moving new part to its destination on disk.
-		var bb bytesutil.ByteBuffer
-		for _, pw := range pws {
-			if pw.mp == nil {
-				fmt.Fprintf(&bb, "%s\n", pw.p.path)
-			}
-		}
-		dstPartPath = ph.Path(tb.path, mergeIdx)
-		fmt.Fprintf(&bb, "%s -> %s\n", tmpPartPath, dstPartPath)
-		txnPath := fmt.Sprintf("%s/txn/%016X", tb.path, mergeIdx)
-		if err := fs.WriteFileAtomically(txnPath, bb.B, false); err != nil {
-			return nil, fmt.Errorf("cannot create transaction file %q: %w", txnPath, err)
-		}
-
-		// Run the created transaction.
-		if err := runTransaction(&tb.snapshotLock, tb.path, txnPath); err != nil {
-			return nil, fmt.Errorf("cannot execute transaction %q: %w", txnPath, err)
-		}
-	}
+func (tb *Table) openCreatedPart(pws []*partWrapper, mpNew *inmemoryPart, dstPartPath string) *partWrapper {
 	// Open the created part.
 	if mpNew != nil {
 		// Open the created part from memory.
 		flushToDiskDeadline := getFlushToDiskDeadline(pws)
 		pwNew := newPartWrapperFromInmemoryPart(mpNew, flushToDiskDeadline)
-		return pwNew, nil
+		return pwNew
 	}
 	// Open the created part from disk.
 	pNew, err := openFilePart(dstPartPath)
 	if err != nil {
-		return nil, fmt.Errorf("cannot open merged part %q: %w", dstPartPath, err)
+		logger.Panicf("FATAL: cannot open the merged part: %s", err)
 	}
 	pwNew := &partWrapper{
 		p:        pNew,
 		refCount: 1,
 	}
-	return pwNew, nil
+	return pwNew
 }
 
 func areAllInmemoryParts(pws []*partWrapper) bool {
@@ -1350,19 +1291,26 @@ func (tb *Table) swapSrcWithDstParts(pws []*partWrapper, pwNew *partWrapper, dst
 	removedFileParts := 0
 
 	tb.partsLock.Lock()
+
 	tb.inmemoryParts, removedInmemoryParts = removeParts(tb.inmemoryParts, m)
 	tb.fileParts, removedFileParts = removeParts(tb.fileParts, m)
-	if pwNew != nil {
-		switch dstPartType {
-		case partInmemory:
-			tb.inmemoryParts = append(tb.inmemoryParts, pwNew)
-		case partFile:
-			tb.fileParts = append(tb.fileParts, pwNew)
-		default:
-			logger.Panicf("BUG: unknown partType=%d", dstPartType)
-		}
-		tb.notifyBackgroundMergers()
+	switch dstPartType {
+	case partInmemory:
+		tb.inmemoryParts = append(tb.inmemoryParts, pwNew)
+	case partFile:
+		tb.fileParts = append(tb.fileParts, pwNew)
+	default:
+		logger.Panicf("BUG: unknown partType=%d", dstPartType)
 	}
+	tb.notifyBackgroundMergers()
+
+	// Atomically store the updated list of file-based parts on disk.
+	// This must be performed under partsLock in order to prevent from races
+	// when multiple concurrently running goroutines update the list.
+	if removedFileParts > 0 || dstPartType == partFile {
+		mustWritePartNames(tb.fileParts, tb.path)
+	}
+
 	tb.partsLock.Unlock()
 
 	removedParts := removedInmemoryParts + removedFileParts
@@ -1370,8 +1318,10 @@ func (tb *Table) swapSrcWithDstParts(pws []*partWrapper, pwNew *partWrapper, dst
 		logger.Panicf("BUG: unexpected number of parts removed; got %d, want %d", removedParts, len(m))
 	}
 
-	// Remove references from old parts.
+	// Mark old parts as must be deleted and decrement reference count,
+	// so they are eventually closed and deleted.
 	for _, pw := range pws {
+		atomic.StoreUint32(&pw.mustBeDeleted, 1)
 		pw.decRef()
 	}
 }
@@ -1426,56 +1376,41 @@ func openParts(path string) ([]*partWrapper, error) {
 		return nil, err
 	}
 	fs.MustRemoveTemporaryDirs(path)
-	d, err := os.Open(path)
-	if err != nil {
-		return nil, fmt.Errorf("cannot open difrectory: %w", err)
-	}
-	defer fs.MustClose(d)
-
-	// Run remaining transactions and cleanup /txn and /tmp directories.
-	// Snapshots cannot be created yet, so use fakeSnapshotLock.
-	var fakeSnapshotLock sync.RWMutex
-	if err := runTransactions(&fakeSnapshotLock, path); err != nil {
-		return nil, fmt.Errorf("cannot run transactions: %w", err)
-	}
-
-	txnDir := path + "/txn"
-	fs.MustRemoveDirAtomic(txnDir)
-	if err := fs.MkdirAllFailIfExist(txnDir); err != nil {
-		return nil, fmt.Errorf("cannot create %q: %w", txnDir, err)
-	}
 
-	tmpDir := path + "/tmp"
-	fs.MustRemoveDirAtomic(tmpDir)
-	if err := fs.MkdirAllFailIfExist(tmpDir); err != nil {
-		return nil, fmt.Errorf("cannot create %q: %w", tmpDir, err)
-	}
+	// Remove txn and tmp directories, which may be left after the upgrade
+	// to v1.90.0 and newer versions.
+	fs.MustRemoveAll(path + "/txn")
+	fs.MustRemoveAll(path + "/tmp")
 
-	fs.MustSyncPath(path)
+	partNames := mustReadPartNames(path)
 
-	// Open parts.
-	fis, err := d.Readdir(-1)
+	// Remove dirs missing in partNames. These dirs may be left after unclean shutdown
+	// or after the update from versions prior to v1.90.0.
+	des, err := os.ReadDir(path)
 	if err != nil {
-		return nil, fmt.Errorf("cannot read directory: %w", err)
+		return nil, fmt.Errorf("cannot read mergetree table dir: %w", err)
 	}
-	var pws []*partWrapper
-	for _, fi := range fis {
-		if !fs.IsDirOrSymlink(fi) {
+	m := make(map[string]struct{}, len(partNames))
+	for _, partName := range partNames {
+		m[partName] = struct{}{}
+	}
+	for _, de := range des {
+		if !fs.IsDirOrSymlink(de) {
 			// Skip non-directories.
 			continue
 		}
-		fn := fi.Name()
-		if isSpecialDir(fn) {
-			// Skip special dirs.
-			continue
-		}
-		partPath := path + "/" + fn
-		if fs.IsEmptyDir(partPath) {
-			// Remove empty directory, which can be left after unclean shutdown on NFS.
-			// See https://github.com/VictoriaMetrics/VictoriaMetrics/issues/1142
-			fs.MustRemoveDirAtomic(partPath)
-			continue
+		fn := de.Name()
+		if _, ok := m[fn]; !ok {
+			deletePath := path + "/" + fn
+			fs.MustRemoveAll(deletePath)
 		}
+	}
+	fs.MustSyncPath(path)
+
+	// Open parts
+	var pws []*partWrapper
+	for _, partName := range partNames {
+		partPath := path + "/" + partName
 		p, err := openFilePart(partPath)
 		if err != nil {
 			mustCloseParts(pws)
@@ -1502,8 +1437,7 @@ func mustCloseParts(pws []*partWrapper) {
 
 // CreateSnapshotAt creates tb snapshot in the given dstDir.
 //
-// Snapshot is created using linux hard links, so it is usually created
-// very quickly.
+// Snapshot is created using linux hard links, so it is usually created very quickly.
 //
 // If deadline is reached before snapshot is created error is returned.
 //
@@ -1529,42 +1463,27 @@ func (tb *Table) CreateSnapshotAt(dstDir string, deadline uint64) error {
 	// Flush inmemory items to disk.
 	tb.flushInmemoryItems()
 
-	// The snapshot must be created under the lock in order to prevent from
-	// concurrent modifications via runTransaction.
-	tb.snapshotLock.Lock()
-	defer tb.snapshotLock.Unlock()
-
 	if err := fs.MkdirAllFailIfExist(dstDir); err != nil {
 		return fmt.Errorf("cannot create snapshot dir %q: %w", dstDir, err)
 	}
 
-	d, err := os.Open(srcDir)
-	if err != nil {
-		return fmt.Errorf("cannot open difrectory: %w", err)
-	}
-	defer fs.MustClose(d)
+	pws := tb.getParts(nil)
+	defer tb.putParts(pws)
 
-	fis, err := d.Readdir(-1)
-	if err != nil {
-		return fmt.Errorf("cannot read directory: %w", err)
-	}
-
-	for _, fi := range fis {
-		if deadline > 0 && fasttime.UnixTimestamp() > deadline {
-			return fmt.Errorf("cannot create snapshot for %q: timeout exceeded", tb.path)
-		}
+	// Create a file with part names at dstDir
+	mustWritePartNames(pws, dstDir)
 
-		fn := fi.Name()
-		if !fs.IsDirOrSymlink(fi) {
-			// Skip non-directories.
+	// Make hardlinks for pws at dstDir
+	for _, pw := range pws {
+		if pw.mp != nil {
+			// Skip in-memory parts
 			continue
 		}
-		if isSpecialDir(fn) {
-			// Skip special dirs.
-			continue
+		if deadline > 0 && fasttime.UnixTimestamp() > deadline {
+			return fmt.Errorf("cannot create snapshot for %q: timeout exceeded", tb.path)
 		}
-		srcPartPath := srcDir + "/" + fn
-		dstPartPath := dstDir + "/" + fn
+		srcPartPath := pw.p.path
+		dstPartPath := dstDir + "/" + filepath.Base(srcPartPath)
 		if err := fs.HardLinkFiles(srcPartPath, dstPartPath); err != nil {
 			return fmt.Errorf("cannot create hard links from %q to %q: %w", srcPartPath, dstPartPath, err)
 		}
@@ -1578,135 +1497,60 @@ func (tb *Table) CreateSnapshotAt(dstDir string, deadline uint64) error {
 	return nil
 }
 
-func runTransactions(txnLock *sync.RWMutex, path string) error {
-	// Wait until all the previous pending transaction deletions are finished.
-	pendingTxnDeletionsWG.Wait()
-
-	// Make sure all the current transaction deletions are finished before exiting.
-	defer pendingTxnDeletionsWG.Wait()
-
-	txnDir := path + "/txn"
-	d, err := os.Open(txnDir)
-	if err != nil {
-		if os.IsNotExist(err) {
-			return nil
-		}
-		return fmt.Errorf("cannot open transaction dir: %w", err)
-	}
-	defer fs.MustClose(d)
-
-	fis, err := d.Readdir(-1)
-	if err != nil {
-		return fmt.Errorf("cannot read directory %q: %w", d.Name(), err)
-	}
-
-	// Sort transaction files by id, since transactions must be ordered.
-	sort.Slice(fis, func(i, j int) bool {
-		return fis[i].Name() < fis[j].Name()
-	})
-
-	for _, fi := range fis {
-		fn := fi.Name()
-		if fs.IsTemporaryFileName(fn) {
-			// Skip temporary files, which could be left after unclean shutdown.
+func mustWritePartNames(pws []*partWrapper, dstDir string) {
+	partNames := make([]string, 0, len(pws))
+	for _, pw := range pws {
+		if pw.mp != nil {
+			// Skip in-memory parts
 			continue
 		}
-		txnPath := txnDir + "/" + fn
-		if err := runTransaction(txnLock, path, txnPath); err != nil {
-			return fmt.Errorf("cannot run transaction from %q: %w", txnPath, err)
-		}
+		partName := filepath.Base(pw.p.path)
+		partNames = append(partNames, partName)
 	}
-	return nil
-}
-
-func runTransaction(txnLock *sync.RWMutex, pathPrefix, txnPath string) error {
-	// The transaction must run under read lock in order to provide
-	// consistent snapshots with Table.CreateSnapshot().
-	txnLock.RLock()
-	defer txnLock.RUnlock()
-
-	data, err := os.ReadFile(txnPath)
+	sort.Strings(partNames)
+	data, err := json.Marshal(partNames)
 	if err != nil {
-		return fmt.Errorf("cannot read transaction file: %w", err)
-	}
-	if len(data) > 0 && data[len(data)-1] == '\n' {
-		data = data[:len(data)-1]
-	}
-	paths := strings.Split(string(data), "\n")
-
-	if len(paths) == 0 {
-		return fmt.Errorf("empty transaction")
+		logger.Panicf("BUG: cannot marshal partNames to JSON: %s", err)
 	}
-	rmPaths := paths[:len(paths)-1]
-	mvPaths := strings.Split(paths[len(paths)-1], " -> ")
-	if len(mvPaths) != 2 {
-		return fmt.Errorf("invalid last line in the transaction file: got %q; must contain `srcPath -> dstPath`", paths[len(paths)-1])
+	partNamesPath := dstDir + "/parts.json"
+	if err := fs.WriteFileAtomically(partNamesPath, data, true); err != nil {
+		logger.Panicf("FATAL: cannot update %s: %s", partNamesPath, err)
 	}
+}
 
-	// Remove old paths. It is OK if certain paths don't exist.
-	for _, path := range rmPaths {
-		path, err := validatePath(pathPrefix, path)
-		if err != nil {
-			return fmt.Errorf("invalid path to remove: %w", err)
+func mustReadPartNames(srcDir string) []string {
+	partNamesPath := srcDir + "/parts.json"
+	data, err := os.ReadFile(partNamesPath)
+	if err == nil {
+		var partNames []string
+		if err := json.Unmarshal(data, &partNames); err != nil {
+			logger.Panicf("FATAL: cannot parse %s: %s", partNamesPath, err)
 		}
-		fs.MustRemoveDirAtomic(path)
+		return partNames
 	}
-
-	// Move the new part to new directory.
-	srcPath := mvPaths[0]
-	dstPath := mvPaths[1]
-	srcPath, err = validatePath(pathPrefix, srcPath)
-	if err != nil {
-		return fmt.Errorf("invalid source path to rename: %w", err)
+	if !os.IsNotExist(err) {
+		logger.Panicf("FATAL: cannot read parts.json file: %s", err)
 	}
-	dstPath, err = validatePath(pathPrefix, dstPath)
+	// The parts.json is missing. This is the upgrade from versions previous to v1.90.0.
+	// Read part names from directories under srcDir
+	des, err := os.ReadDir(srcDir)
 	if err != nil {
-		return fmt.Errorf("invalid destination path to rename: %w", err)
+		logger.Panicf("FATAL: cannot read mergeset table dir: %s", err)
 	}
-	if fs.IsPathExist(srcPath) {
-		if err := os.Rename(srcPath, dstPath); err != nil {
-			return fmt.Errorf("cannot rename %q to %q: %w", srcPath, dstPath, err)
+	var partNames []string
+	for _, de := range des {
+		if !fs.IsDirOrSymlink(de) {
+			// Skip non-directories.
+			continue
 		}
-	} else if !fs.IsPathExist(dstPath) {
-		// Emit info message for the expected condition after unclean shutdown on NFS disk.
-		// The dstPath part may be missing because it could be already merged into bigger part
-		// while old source parts for the current txn weren't still deleted due to NFS locks.
-		logger.Infof("cannot find both source and destination paths: %q -> %q; this may be the case after unclean shutdown (OOM, `kill -9`, hard reset) on NFS disk",
-			srcPath, dstPath)
-	}
-
-	// Flush pathPrefix directory metadata to the underlying storage.
-	fs.MustSyncPath(pathPrefix)
-
-	pendingTxnDeletionsWG.Add(1)
-	go func() {
-		defer pendingTxnDeletionsWG.Done()
-		if err := os.Remove(txnPath); err != nil {
-			logger.Errorf("cannot remove transaction file %q: %s", txnPath, err)
+		partName := de.Name()
+		if isSpecialDir(partName) {
+			// Skip special dirs.
+			continue
 		}
-	}()
-
-	return nil
-}
-
-var pendingTxnDeletionsWG syncwg.WaitGroup
-
-func validatePath(pathPrefix, path string) (string, error) {
-	var err error
-
-	pathPrefix, err = filepath.Abs(pathPrefix)
-	if err != nil {
-		return path, fmt.Errorf("cannot determine absolute path for pathPrefix=%q: %w", pathPrefix, err)
-	}
-
-	path, err = filepath.Abs(path)
-	if err != nil {
-		return path, fmt.Errorf("cannot determine absolute path for %q: %w", path, err)
-	}
-	if !strings.HasPrefix(path, pathPrefix+"/") {
-		return path, fmt.Errorf("invalid path %q; must start with %q", path, pathPrefix+"/")
+		partNames = append(partNames, partName)
 	}
-	return path, nil
+	return partNames
 }
 
 // getPartsToMerge returns optimal parts to merge from pws.
diff --git a/lib/storage/block_stream_reader.go b/lib/storage/block_stream_reader.go
index 2f9e6fc59..e08817f28 100644
--- a/lib/storage/block_stream_reader.go
+++ b/lib/storage/block_stream_reader.go
@@ -141,7 +141,7 @@ func (bsr *blockStreamReader) InitFromFilePart(path string) error {
 
 	path = filepath.Clean(path)
 
-	if err := bsr.ph.ParseFromPath(path); err != nil {
+	if err := bsr.ph.ReadMetadata(path); err != nil {
 		return fmt.Errorf("cannot parse path to part: %w", err)
 	}
 
diff --git a/lib/storage/inmemory_part.go b/lib/storage/inmemory_part.go
index 70f05c15f..3886795de 100644
--- a/lib/storage/inmemory_part.go
+++ b/lib/storage/inmemory_part.go
@@ -56,8 +56,8 @@ func (mp *inmemoryPart) StoreToDisk(path string) error {
 	if err := fs.WriteFileAndSync(metaindexPath, mp.metaindexData.B); err != nil {
 		return fmt.Errorf("cannot store metaindex: %w", err)
 	}
-	if err := mp.ph.writeMinDedupInterval(path); err != nil {
-		return fmt.Errorf("cannot store min dedup interval: %w", err)
+	if err := mp.ph.WriteMetadata(path); err != nil {
+		return fmt.Errorf("cannot store metadata: %w", err)
 	}
 	// Sync parent directory in order to make sure the written files remain visible after hardware reset
 	parentDirPath := filepath.Dir(path)
diff --git a/lib/storage/part.go b/lib/storage/part.go
index 956ab7bae..2deccbe48 100644
--- a/lib/storage/part.go
+++ b/lib/storage/part.go
@@ -50,7 +50,7 @@ func openFilePart(path string) (*part, error) {
 	path = filepath.Clean(path)
 
 	var ph partHeader
-	if err := ph.ParseFromPath(path); err != nil {
+	if err := ph.ReadMetadata(path); err != nil {
 		return nil, fmt.Errorf("cannot parse path to part: %w", err)
 	}
 
diff --git a/lib/storage/part_header.go b/lib/storage/part_header.go
index 8896dacd8..ee61724fb 100644
--- a/lib/storage/part_header.go
+++ b/lib/storage/part_header.go
@@ -1,6 +1,7 @@
 package storage
 
 import (
+	"encoding/json"
 	"errors"
 	"fmt"
 	"os"
@@ -10,6 +11,7 @@ import (
 	"time"
 
 	"github.com/VictoriaMetrics/VictoriaMetrics/lib/fs"
+	"github.com/VictoriaMetrics/VictoriaMetrics/lib/logger"
 	"github.com/VictoriaMetrics/VictoriaMetrics/lib/promutils"
 )
 
@@ -33,12 +35,35 @@ type partHeader struct {
 
 // String returns string representation of ph.
 func (ph *partHeader) String() string {
-	return fmt.Sprintf("%d_%d_%s_%s", ph.RowsCount, ph.BlocksCount, toUserReadableTimestamp(ph.MinTimestamp), toUserReadableTimestamp(ph.MaxTimestamp))
+	return fmt.Sprintf("partHeader{rowsCount=%d,blocksCount=%d,minTimestamp=%d,maxTimestamp=%d}", ph.RowsCount, ph.BlocksCount, ph.MinTimestamp, ph.MaxTimestamp)
 }
 
-func toUserReadableTimestamp(timestamp int64) string {
-	t := timestampToTime(timestamp)
-	return t.Format(userReadableTimeFormat)
+// Reset resets the ph.
+func (ph *partHeader) Reset() {
+	ph.RowsCount = 0
+	ph.BlocksCount = 0
+	ph.MinTimestamp = (1 << 63) - 1
+	ph.MaxTimestamp = -1 << 63
+	ph.MinDedupInterval = 0
+}
+
+func (ph *partHeader) readMinDedupInterval(partPath string) error {
+	filePath := partPath + "/min_dedup_interval"
+	data, err := os.ReadFile(filePath)
+	if err != nil {
+		if errors.Is(err, os.ErrNotExist) {
+			// The minimum dedup interval may not exist for old parts.
+			ph.MinDedupInterval = 0
+			return nil
+		}
+		return fmt.Errorf("cannot read %q: %w", filePath, err)
+	}
+	dedupInterval, err := promutils.ParseDuration(string(data))
+	if err != nil {
+		return fmt.Errorf("cannot parse minimum dedup interval %q at %q: %w", data, filePath, err)
+	}
+	ph.MinDedupInterval = dedupInterval.Milliseconds()
+	return nil
 }
 
 func fromUserReadableTimestamp(s string) (int64, error) {
@@ -51,15 +76,6 @@ func fromUserReadableTimestamp(s string) (int64, error) {
 
 const userReadableTimeFormat = "20060102150405.000"
 
-// Path returns a path to part header with the given prefix and suffix.
-//
-// Suffix must be random.
-func (ph *partHeader) Path(prefix string, suffix uint64) string {
-	prefix = filepath.Clean(prefix)
-	s := ph.String()
-	return fmt.Sprintf("%s/%s_%016X", prefix, s, suffix)
-}
-
 // ParseFromPath extracts ph info from the given path.
 func (ph *partHeader) ParseFromPath(path string) error {
 	ph.Reset()
@@ -119,40 +135,48 @@ func (ph *partHeader) ParseFromPath(path string) error {
 	return nil
 }
 
-// Reset resets the ph.
-func (ph *partHeader) Reset() {
-	ph.RowsCount = 0
-	ph.BlocksCount = 0
-	ph.MinTimestamp = (1 << 63) - 1
-	ph.MaxTimestamp = -1 << 63
-	ph.MinDedupInterval = 0
-}
+func (ph *partHeader) ReadMetadata(partPath string) error {
+	ph.Reset()
 
-func (ph *partHeader) readMinDedupInterval(partPath string) error {
-	filePath := partPath + "/min_dedup_interval"
-	data, err := os.ReadFile(filePath)
+	metadataPath := partPath + "/metadata.json"
+	metadata, err := os.ReadFile(metadataPath)
 	if err != nil {
-		if errors.Is(err, os.ErrNotExist) {
-			// The minimum dedup interval may not exist for old parts.
-			ph.MinDedupInterval = 0
-			return nil
+		if os.IsNotExist(err) {
+			// This is a part created before v1.90.0.
+			// Fall back to reading the metadata from the partPath itsel
+			return ph.ParseFromPath(partPath)
 		}
-		return fmt.Errorf("cannot read %q: %w", filePath, err)
+		return fmt.Errorf("cannot read %q: %w", metadataPath, err)
 	}
-	dedupInterval, err := promutils.ParseDuration(string(data))
-	if err != nil {
-		return fmt.Errorf("cannot parse minimum dedup interval %q at %q: %w", data, filePath, err)
+	if err := json.Unmarshal(metadata, ph); err != nil {
+		return fmt.Errorf("cannot parse %q: %w", metadataPath, err)
 	}
-	ph.MinDedupInterval = dedupInterval.Milliseconds()
+
+	// Perform various checks
+	if ph.MinTimestamp > ph.MaxTimestamp {
+		return fmt.Errorf("minTimestamp cannot exceed maxTimestamp; got %d vs %d", ph.MinTimestamp, ph.MaxTimestamp)
+	}
+	if ph.RowsCount <= 0 {
+		return fmt.Errorf("rowsCount must be greater than 0; got %d", ph.RowsCount)
+	}
+	if ph.BlocksCount <= 0 {
+		return fmt.Errorf("blocksCount must be greater than 0; got %d", ph.BlocksCount)
+	}
+	if ph.BlocksCount > ph.RowsCount {
+		return fmt.Errorf("blocksCount cannot be bigger than rowsCount; got blocksCount=%d, rowsCount=%d", ph.BlocksCount, ph.RowsCount)
+	}
+
 	return nil
 }
 
-func (ph *partHeader) writeMinDedupInterval(partPath string) error {
-	filePath := partPath + "/min_dedup_interval"
-	dedupInterval := time.Duration(ph.MinDedupInterval) * time.Millisecond
-	data := dedupInterval.String()
-	if err := fs.WriteFileAtomically(filePath, []byte(data), false); err != nil {
-		return fmt.Errorf("cannot create %q: %w", filePath, err)
+func (ph *partHeader) WriteMetadata(partPath string) error {
+	metadata, err := json.Marshal(ph)
+	if err != nil {
+		logger.Panicf("BUG: cannot marshal partHeader metadata: %s", err)
+	}
+	metadataPath := partPath + "/metadata.json"
+	if err := fs.WriteFileAtomically(metadataPath, metadata, false); err != nil {
+		return fmt.Errorf("cannot create %q: %w", metadataPath, err)
 	}
 	return nil
 }
diff --git a/lib/storage/partition.go b/lib/storage/partition.go
index 570b44c25..f9919f616 100644
--- a/lib/storage/partition.go
+++ b/lib/storage/partition.go
@@ -1,6 +1,7 @@
 package storage
 
 import (
+	"encoding/json"
 	"errors"
 	"fmt"
 	"os"
@@ -12,7 +13,6 @@ import (
 	"time"
 	"unsafe"
 
-	"github.com/VictoriaMetrics/VictoriaMetrics/lib/bytesutil"
 	"github.com/VictoriaMetrics/VictoriaMetrics/lib/cgroup"
 	"github.com/VictoriaMetrics/VictoriaMetrics/lib/encoding"
 	"github.com/VictoriaMetrics/VictoriaMetrics/lib/fasttime"
@@ -20,7 +20,6 @@ import (
 	"github.com/VictoriaMetrics/VictoriaMetrics/lib/logger"
 	"github.com/VictoriaMetrics/VictoriaMetrics/lib/memory"
 	"github.com/VictoriaMetrics/VictoriaMetrics/lib/mergeset"
-	"github.com/VictoriaMetrics/VictoriaMetrics/lib/syncwg"
 )
 
 // The maximum size of big part.
@@ -158,8 +157,6 @@ type partition struct {
 	// which may need to be merged.
 	needMergeCh chan struct{}
 
-	snapshotLock sync.RWMutex
-
 	stopCh chan struct{}
 
 	wg sync.WaitGroup
@@ -167,11 +164,11 @@ type partition struct {
 
 // partWrapper is a wrapper for the part.
 type partWrapper struct {
-	// Put atomic counters to the top of struct, so they are aligned to 8 bytes on 32-bit arch.
-	// See https://github.com/VictoriaMetrics/VictoriaMetrics/issues/212
-
 	// The number of references to the part.
-	refCount uint64
+	refCount uint32
+
+	// The flag, which is set when the part must be deleted after refCount reaches zero.
+	mustBeDeleted uint32
 
 	// The part itself.
 	p *part
@@ -187,24 +184,32 @@ type partWrapper struct {
 }
 
 func (pw *partWrapper) incRef() {
-	atomic.AddUint64(&pw.refCount, 1)
+	atomic.AddUint32(&pw.refCount, 1)
 }
 
 func (pw *partWrapper) decRef() {
-	n := atomic.AddUint64(&pw.refCount, ^uint64(0))
-	if int64(n) < 0 {
-		logger.Panicf("BUG: pw.refCount must be bigger than 0; got %d", int64(n))
+	n := atomic.AddUint32(&pw.refCount, ^uint32(0))
+	if int32(n) < 0 {
+		logger.Panicf("BUG: pw.refCount must be bigger than 0; got %d", int32(n))
 	}
 	if n > 0 {
 		return
 	}
 
+	deletePath := ""
+	if pw.mp == nil && atomic.LoadUint32(&pw.mustBeDeleted) != 0 {
+		deletePath = pw.p.path
+	}
 	if pw.mp != nil {
 		putInmemoryPart(pw.mp)
 		pw.mp = nil
 	}
 	pw.p.MustClose()
 	pw.p = nil
+
+	if deletePath != "" {
+		fs.MustRemoveAll(deletePath)
+	}
 }
 
 // createPartition creates new partition for the given timestamp and the given paths
@@ -215,11 +220,11 @@ func createPartition(timestamp int64, smallPartitionsPath, bigPartitionsPath str
 	bigPartsPath := filepath.Clean(bigPartitionsPath) + "/" + name
 	logger.Infof("creating a partition %q with smallPartsPath=%q, bigPartsPath=%q", name, smallPartsPath, bigPartsPath)
 
-	if err := createPartitionDirs(smallPartsPath); err != nil {
-		return nil, fmt.Errorf("cannot create directories for small parts %q: %w", smallPartsPath, err)
+	if err := fs.MkdirAllFailIfExist(smallPartsPath); err != nil {
+		return nil, fmt.Errorf("cannot create directory for small parts %q: %w", smallPartsPath, err)
 	}
-	if err := createPartitionDirs(bigPartsPath); err != nil {
-		return nil, fmt.Errorf("cannot create directories for big parts %q: %w", bigPartsPath, err)
+	if err := fs.MkdirAllFailIfExist(bigPartsPath); err != nil {
+		return nil, fmt.Errorf("cannot create directory for big parts %q: %w", bigPartsPath, err)
 	}
 
 	pt := newPartition(name, smallPartsPath, bigPartsPath, s)
@@ -243,8 +248,6 @@ func (pt *partition) startBackgroundWorkers() {
 // The pt must be detached from table before calling pt.Drop.
 func (pt *partition) Drop() {
 	logger.Infof("dropping partition %q at smallPartsPath=%q, bigPartsPath=%q", pt.name, pt.smallPartsPath, pt.bigPartsPath)
-	// Wait until all the pending transaction deletions are finished before removing partition directories.
-	pendingTxnDeletionsWG.Wait()
 
 	fs.MustRemoveDirAtomic(pt.smallPartsPath)
 	fs.MustRemoveDirAtomic(pt.bigPartsPath)
@@ -266,11 +269,13 @@ func openPartition(smallPartsPath, bigPartsPath string, s *Storage) (*partition,
 		return nil, fmt.Errorf("patititon name in bigPartsPath %q doesn't match smallPartsPath %q; want %q", bigPartsPath, smallPartsPath, name)
 	}
 
-	smallParts, err := openParts(smallPartsPath, bigPartsPath, smallPartsPath)
+	partNamesSmall, partNamesBig := mustReadPartNames(smallPartsPath, bigPartsPath)
+
+	smallParts, err := openParts(smallPartsPath, partNamesSmall)
 	if err != nil {
 		return nil, fmt.Errorf("cannot open small parts from %q: %w", smallPartsPath, err)
 	}
-	bigParts, err := openParts(smallPartsPath, bigPartsPath, bigPartsPath)
+	bigParts, err := openParts(bigPartsPath, partNamesBig)
 	if err != nil {
 		mustCloseParts(smallParts)
 		return nil, fmt.Errorf("cannot open big parts from %q: %w", bigPartsPath, err)
@@ -375,21 +380,21 @@ func (pt *partition) UpdateMetrics(m *partitionMetrics) {
 		m.InmemoryRowsCount += p.ph.RowsCount
 		m.InmemoryBlocksCount += p.ph.BlocksCount
 		m.InmemorySizeBytes += p.size
-		m.InmemoryPartsRefCount += atomic.LoadUint64(&pw.refCount)
+		m.InmemoryPartsRefCount += uint64(atomic.LoadUint32(&pw.refCount))
 	}
 	for _, pw := range pt.smallParts {
 		p := pw.p
 		m.SmallRowsCount += p.ph.RowsCount
 		m.SmallBlocksCount += p.ph.BlocksCount
 		m.SmallSizeBytes += p.size
-		m.SmallPartsRefCount += atomic.LoadUint64(&pw.refCount)
+		m.SmallPartsRefCount += uint64(atomic.LoadUint32(&pw.refCount))
 	}
 	for _, pw := range pt.bigParts {
 		p := pw.p
 		m.BigRowsCount += p.ph.RowsCount
 		m.BigBlocksCount += p.ph.BlocksCount
 		m.BigSizeBytes += p.size
-		m.BigPartsRefCount += atomic.LoadUint64(&pw.refCount)
+		m.BigPartsRefCount += uint64(atomic.LoadUint32(&pw.refCount))
 	}
 
 	m.InmemoryPartsCount += uint64(len(pt.inmemoryParts))
@@ -751,19 +756,12 @@ func (pt *partition) HasTimestamp(timestamp int64) bool {
 func (pt *partition) GetParts(dst []*partWrapper, addInMemory bool) []*partWrapper {
 	pt.partsLock.Lock()
 	if addInMemory {
-		for _, pw := range pt.inmemoryParts {
-			pw.incRef()
-		}
+		incRefForParts(pt.inmemoryParts)
 		dst = append(dst, pt.inmemoryParts...)
 	}
-
-	for _, pw := range pt.smallParts {
-		pw.incRef()
-	}
+	incRefForParts(pt.smallParts)
 	dst = append(dst, pt.smallParts...)
-	for _, pw := range pt.bigParts {
-		pw.incRef()
-	}
+	incRefForParts(pt.bigParts)
 	dst = append(dst, pt.bigParts...)
 	pt.partsLock.Unlock()
 
@@ -777,15 +775,18 @@ func (pt *partition) PutParts(pws []*partWrapper) {
 	}
 }
 
+func incRefForParts(pws []*partWrapper) {
+	for _, pw := range pws {
+		pw.incRef()
+	}
+}
+
 // MustClose closes the pt, so the app may safely exit.
 //
 // The pt must be detached from table before calling pt.MustClose.
 func (pt *partition) MustClose() {
 	close(pt.stopCh)
 
-	// Wait until all the pending transaction deletions are finished.
-	pendingTxnDeletionsWG.Wait()
-
 	logger.Infof("waiting for service workers to stop on %q...", pt.smallPartsPath)
 	startTime := time.Now()
 	pt.wg.Wait()
@@ -871,35 +872,20 @@ func (pt *partition) flushInmemoryRows() {
 }
 
 func (pt *partition) flushInmemoryParts(isFinal bool) {
-	for {
-		currentTime := time.Now()
-		var pws []*partWrapper
+	currentTime := time.Now()
+	var pws []*partWrapper
 
-		pt.partsLock.Lock()
-		for _, pw := range pt.inmemoryParts {
-			if !pw.isInMerge && (isFinal || pw.flushToDiskDeadline.Before(currentTime)) {
-				pw.isInMerge = true
-				pws = append(pws, pw)
-			}
+	pt.partsLock.Lock()
+	for _, pw := range pt.inmemoryParts {
+		if !pw.isInMerge && (isFinal || pw.flushToDiskDeadline.Before(currentTime)) {
+			pw.isInMerge = true
+			pws = append(pws, pw)
 		}
-		pt.partsLock.Unlock()
+	}
+	pt.partsLock.Unlock()
 
-		if err := pt.mergePartsOptimal(pws, nil); err != nil {
-			logger.Panicf("FATAL: cannot merge in-memory parts: %s", err)
-		}
-		if !isFinal {
-			return
-		}
-		pt.partsLock.Lock()
-		n := len(pt.inmemoryParts)
-		pt.partsLock.Unlock()
-		if n == 0 {
-			// All the in-memory parts were flushed to disk.
-			return
-		}
-		// Some parts weren't flushed to disk because they were being merged.
-		// Sleep for a while and try flushing them again.
-		time.Sleep(10 * time.Millisecond)
+	if err := pt.mergePartsOptimal(pws, nil); err != nil {
+		logger.Panicf("FATAL: cannot merge in-memory parts: %s", err)
 	}
 }
 
@@ -1238,10 +1224,6 @@ func atomicSetBool(p *uint64, b bool) {
 
 func (pt *partition) runFinalDedup() error {
 	requiredDedupInterval, actualDedupInterval := pt.getRequiredDedupInterval()
-	if requiredDedupInterval <= actualDedupInterval {
-		// Deduplication isn't needed.
-		return nil
-	}
 	t := time.Now()
 	logger.Infof("starting final dedup for partition %s using requiredDedupInterval=%d ms, since the partition has smaller actualDedupInterval=%d ms",
 		pt.bigPartsPath, requiredDedupInterval, actualDedupInterval)
@@ -1252,6 +1234,11 @@ func (pt *partition) runFinalDedup() error {
 	return nil
 }
 
+func (pt *partition) isFinalDedupNeeded() bool {
+	requiredDedupInterval, actualDedupInterval := pt.getRequiredDedupInterval()
+	return requiredDedupInterval > actualDedupInterval
+}
+
 func (pt *partition) getRequiredDedupInterval() (int64, int64) {
 	pws := pt.GetParts(nil, false)
 	defer pt.PutParts(pws)
@@ -1292,7 +1279,8 @@ func (pt *partition) mergeParts(pws []*partWrapper, stopCh <-chan struct{}, isFi
 
 	// Initialize destination paths.
 	dstPartType := pt.getDstPartType(pws, isFinal)
-	ptPath, tmpPartPath, mergeIdx := pt.getDstPartPaths(dstPartType)
+	mergeIdx := pt.nextMergeIdx()
+	dstPartPath := pt.getDstPartPath(dstPartType, mergeIdx)
 
 	if dstPartType == partBig {
 		bigMergeWorkersLimitCh <- struct{}{}
@@ -1304,17 +1292,10 @@ func (pt *partition) mergeParts(pws []*partWrapper, stopCh <-chan struct{}, isFi
 	if !isDedupEnabled() && isFinal && len(pws) == 1 && pws[0].mp != nil {
 		// Fast path: flush a single in-memory part to disk.
 		mp := pws[0].mp
-		if tmpPartPath == "" {
-			logger.Panicf("BUG: tmpPartPath must be non-empty")
-		}
-
-		if err := mp.StoreToDisk(tmpPartPath); err != nil {
-			return fmt.Errorf("cannot store in-memory part to %q: %w", tmpPartPath, err)
-		}
-		pwNew, err := pt.openCreatedPart(&mp.ph, pws, nil, ptPath, tmpPartPath, mergeIdx)
-		if err != nil {
-			return fmt.Errorf("cannot atomically register the created part: %w", err)
+		if err := mp.StoreToDisk(dstPartPath); err != nil {
+			logger.Panicf("FATAL: cannot store in-memory part to %s: %s", dstPartPath, err)
 		}
+		pwNew := pt.openCreatedPart(&mp.ph, pws, nil, dstPartPath)
 		pt.swapSrcWithDstParts(pws, pwNew, dstPartType)
 		return nil
 	}
@@ -1322,7 +1303,7 @@ func (pt *partition) mergeParts(pws []*partWrapper, stopCh <-chan struct{}, isFi
 	// Prepare BlockStreamReaders for source parts.
 	bsrs, err := openBlockStreamReaders(pws)
 	if err != nil {
-		return err
+		logger.Panicf("FATAL: cannot open source parts for merging: %s", err)
 	}
 	closeBlockStreamReaders := func() {
 		for _, bsr := range bsrs {
@@ -1348,45 +1329,38 @@ func (pt *partition) mergeParts(pws []*partWrapper, stopCh <-chan struct{}, isFi
 		mpNew = getInmemoryPart()
 		bsw.InitFromInmemoryPart(mpNew, compressLevel)
 	} else {
-		if tmpPartPath == "" {
-			logger.Panicf("BUG: tmpPartPath must be non-empty")
+		if dstPartPath == "" {
+			logger.Panicf("BUG: dstPartPath must be non-empty")
 		}
 		nocache := dstPartType == partBig
-		if err := bsw.InitFromFilePart(tmpPartPath, nocache, compressLevel); err != nil {
-			closeBlockStreamReaders()
-			return fmt.Errorf("cannot create destination part at %q: %w", tmpPartPath, err)
+		if err := bsw.InitFromFilePart(dstPartPath, nocache, compressLevel); err != nil {
+			logger.Panicf("FATAL: cannot create destination part at %s: %s", dstPartPath, err)
 		}
 	}
 
 	// Merge source parts to destination part.
-	ph, err := pt.mergePartsInternal(tmpPartPath, bsw, bsrs, dstPartType, stopCh)
+	ph, err := pt.mergePartsInternal(dstPartPath, bsw, bsrs, dstPartType, stopCh)
 	putBlockStreamWriter(bsw)
 	closeBlockStreamReaders()
 	if err != nil {
-		return fmt.Errorf("cannot merge %d parts: %w", len(pws), err)
+		return err
 	}
 	if mpNew != nil {
 		// Update partHeader for destination inmemory part after the merge.
 		mpNew.ph = *ph
 	}
 
-	// Atomically move the created part from tmpPartPath to its destination
-	// and swap the source parts with the newly created part.
-	pwNew, err := pt.openCreatedPart(ph, pws, mpNew, ptPath, tmpPartPath, mergeIdx)
-	if err != nil {
-		return fmt.Errorf("cannot atomically register the created part: %w", err)
-	}
+	// Atomically swap the source parts with the newly created part.
+	pwNew := pt.openCreatedPart(ph, pws, mpNew, dstPartPath)
 
 	dstRowsCount := uint64(0)
 	dstBlocksCount := uint64(0)
 	dstSize := uint64(0)
-	dstPartPath := ""
 	if pwNew != nil {
 		pDst := pwNew.p
 		dstRowsCount = pDst.ph.RowsCount
 		dstBlocksCount = pDst.ph.BlocksCount
 		dstSize = pDst.size
-		dstPartPath = pDst.String()
 	}
 
 	pt.swapSrcWithDstParts(pws, pwNew, dstPartType)
@@ -1439,7 +1413,7 @@ func (pt *partition) getDstPartType(pws []*partWrapper, isFinal bool) partType {
 	return partInmemory
 }
 
-func (pt *partition) getDstPartPaths(dstPartType partType) (string, string, uint64) {
+func (pt *partition) getDstPartPath(dstPartType partType, mergeIdx uint64) string {
 	ptPath := ""
 	switch dstPartType {
 	case partSmall:
@@ -1451,13 +1425,11 @@ func (pt *partition) getDstPartPaths(dstPartType partType) (string, string, uint
 	default:
 		logger.Panicf("BUG: unknown partType=%d", dstPartType)
 	}
-	ptPath = filepath.Clean(ptPath)
-	mergeIdx := pt.nextMergeIdx()
-	tmpPartPath := ""
+	dstPartPath := ""
 	if dstPartType != partInmemory {
-		tmpPartPath = fmt.Sprintf("%s/tmp/%016X", ptPath, mergeIdx)
+		dstPartPath = fmt.Sprintf("%s/%016X", ptPath, mergeIdx)
 	}
-	return ptPath, tmpPartPath, mergeIdx
+	return dstPartPath
 }
 
 func openBlockStreamReaders(pws []*partWrapper) ([]*blockStreamReader, error) {
@@ -1479,7 +1451,7 @@ func openBlockStreamReaders(pws []*partWrapper) ([]*blockStreamReader, error) {
 	return bsrs, nil
 }
 
-func (pt *partition) mergePartsInternal(tmpPartPath string, bsw *blockStreamWriter, bsrs []*blockStreamReader, dstPartType partType, stopCh <-chan struct{}) (*partHeader, error) {
+func (pt *partition) mergePartsInternal(dstPartPath string, bsw *blockStreamWriter, bsrs []*blockStreamReader, dstPartType partType, stopCh <-chan struct{}) (*partHeader, error) {
 	var ph partHeader
 	var rowsMerged *uint64
 	var rowsDeleted *uint64
@@ -1510,64 +1482,42 @@ func (pt *partition) mergePartsInternal(tmpPartPath string, bsw *blockStreamWrit
 	atomic.AddUint64(activeMerges, ^uint64(0))
 	atomic.AddUint64(mergesCount, 1)
 	if err != nil {
-		return nil, fmt.Errorf("cannot merge parts to %q: %w", tmpPartPath, err)
+		return nil, fmt.Errorf("cannot merge %d parts to %s: %w", len(bsrs), dstPartPath, err)
 	}
-	if tmpPartPath != "" {
+	if dstPartPath != "" {
 		ph.MinDedupInterval = GetDedupInterval()
-		if err := ph.writeMinDedupInterval(tmpPartPath); err != nil {
-			return nil, fmt.Errorf("cannot store min dedup interval: %w", err)
+		if err := ph.WriteMetadata(dstPartPath); err != nil {
+			logger.Panicf("FATAL: cannot store metadata to %s: %s", dstPartPath, err)
 		}
 	}
 	return &ph, nil
 }
 
-func (pt *partition) openCreatedPart(ph *partHeader, pws []*partWrapper, mpNew *inmemoryPart, ptPath, tmpPartPath string, mergeIdx uint64) (*partWrapper, error) {
-	dstPartPath := ""
-	if mpNew == nil || !areAllInmemoryParts(pws) {
-		// Either source or destination parts are located on disk.
-		// Create a transaction for atomic deleting of old parts and moving new part to its destination on disk.
-		var bb bytesutil.ByteBuffer
-		for _, pw := range pws {
-			if pw.mp == nil {
-				fmt.Fprintf(&bb, "%s\n", pw.p.path)
-			}
-		}
-		if ph.RowsCount > 0 {
-			// The destination part may have no rows if they are deleted during the merge.
-			dstPartPath = ph.Path(ptPath, mergeIdx)
-		}
-		fmt.Fprintf(&bb, "%s -> %s\n", tmpPartPath, dstPartPath)
-		txnPath := fmt.Sprintf("%s/txn/%016X", ptPath, mergeIdx)
-		if err := fs.WriteFileAtomically(txnPath, bb.B, false); err != nil {
-			return nil, fmt.Errorf("cannot create transaction file %q: %w", txnPath, err)
-		}
-
-		// Run the created transaction.
-		if err := runTransaction(&pt.snapshotLock, pt.smallPartsPath, pt.bigPartsPath, txnPath); err != nil {
-			return nil, fmt.Errorf("cannot execute transaction %q: %w", txnPath, err)
-		}
-	}
+func (pt *partition) openCreatedPart(ph *partHeader, pws []*partWrapper, mpNew *inmemoryPart, dstPartPath string) *partWrapper {
 	// Open the created part.
 	if ph.RowsCount == 0 {
-		// The created part is empty.
-		return nil, nil
+		// The created part is empty. Remove it
+		if mpNew == nil {
+			fs.MustRemoveAll(dstPartPath)
+		}
+		return nil
 	}
 	if mpNew != nil {
 		// Open the created part from memory.
 		flushToDiskDeadline := getFlushToDiskDeadline(pws)
 		pwNew := newPartWrapperFromInmemoryPart(mpNew, flushToDiskDeadline)
-		return pwNew, nil
+		return pwNew
 	}
 	// Open the created part from disk.
 	pNew, err := openFilePart(dstPartPath)
 	if err != nil {
-		return nil, fmt.Errorf("cannot open merged part %q: %w", dstPartPath, err)
+		logger.Panicf("FATAL: cannot open merged part %s: %s", dstPartPath, err)
 	}
 	pwNew := &partWrapper{
 		p:        pNew,
 		refCount: 1,
 	}
-	return pwNew, nil
+	return pwNew
 }
 
 func areAllInmemoryParts(pws []*partWrapper) bool {
@@ -1593,6 +1543,7 @@ func (pt *partition) swapSrcWithDstParts(pws []*partWrapper, pwNew *partWrapper,
 	removedBigParts := 0
 
 	pt.partsLock.Lock()
+
 	pt.inmemoryParts, removedInmemoryParts = removeParts(pt.inmemoryParts, m)
 	pt.smallParts, removedSmallParts = removeParts(pt.smallParts, m)
 	pt.bigParts, removedBigParts = removeParts(pt.bigParts, m)
@@ -1609,6 +1560,14 @@ func (pt *partition) swapSrcWithDstParts(pws []*partWrapper, pwNew *partWrapper,
 		}
 		pt.notifyBackgroundMergers()
 	}
+
+	// Atomically store the updated list of file-based parts on disk.
+	// This must be performed under partsLock in order to prevent from races
+	// when multiple concurrently running goroutines update the list.
+	if removedSmallParts > 0 || removedBigParts > 0 || pwNew != nil && (dstPartType == partSmall || dstPartType == partBig) {
+		mustWritePartNames(pt.smallParts, pt.bigParts, pt.smallPartsPath)
+	}
+
 	pt.partsLock.Unlock()
 
 	removedParts := removedInmemoryParts + removedSmallParts + removedBigParts
@@ -1616,8 +1575,10 @@ func (pt *partition) swapSrcWithDstParts(pws []*partWrapper, pwNew *partWrapper,
 		logger.Panicf("BUG: unexpected number of parts removed; got %d, want %d", removedParts, len(m))
 	}
 
-	// Remove partition references from old parts.
+	// Mark old parts as must be deleted and decrement reference count,
+	// so they are eventually closed and deleted.
 	for _, pw := range pws {
+		atomic.StoreUint32(&pw.mustBeDeleted, 1)
 		pw.decRef()
 	}
 }
@@ -1687,62 +1648,35 @@ func (pt *partition) stalePartsRemover() {
 }
 
 func (pt *partition) removeStaleParts() {
-	m := make(map[*partWrapper]bool)
 	startTime := time.Now()
 	retentionDeadline := timestampFromTime(startTime) - pt.s.retentionMsecs
 
+	var pws []*partWrapper
 	pt.partsLock.Lock()
 	for _, pw := range pt.inmemoryParts {
 		if !pw.isInMerge && pw.p.ph.MaxTimestamp < retentionDeadline {
 			atomic.AddUint64(&pt.inmemoryRowsDeleted, pw.p.ph.RowsCount)
-			m[pw] = true
+			pw.isInMerge = true
+			pws = append(pws, pw)
 		}
 	}
 	for _, pw := range pt.smallParts {
 		if !pw.isInMerge && pw.p.ph.MaxTimestamp < retentionDeadline {
 			atomic.AddUint64(&pt.smallRowsDeleted, pw.p.ph.RowsCount)
-			m[pw] = true
+			pw.isInMerge = true
+			pws = append(pws, pw)
 		}
 	}
 	for _, pw := range pt.bigParts {
 		if !pw.isInMerge && pw.p.ph.MaxTimestamp < retentionDeadline {
 			atomic.AddUint64(&pt.bigRowsDeleted, pw.p.ph.RowsCount)
-			m[pw] = true
+			pw.isInMerge = true
+			pws = append(pws, pw)
 		}
 	}
-	removedInmemoryParts := 0
-	removedSmallParts := 0
-	removedBigParts := 0
-	if len(m) > 0 {
-		pt.inmemoryParts, removedInmemoryParts = removeParts(pt.inmemoryParts, m)
-		pt.smallParts, removedSmallParts = removeParts(pt.smallParts, m)
-		pt.bigParts, removedBigParts = removeParts(pt.bigParts, m)
-	}
 	pt.partsLock.Unlock()
 
-	removedParts := removedInmemoryParts + removedSmallParts + removedBigParts
-	if removedParts != len(m) {
-		logger.Panicf("BUG: unexpected number of stale parts removed; got %d, want %d", removedParts, len(m))
-	}
-
-	// Physically remove stale parts under snapshotLock in order to provide
-	// consistent snapshots with table.CreateSnapshot().
-	pt.snapshotLock.RLock()
-	for pw := range m {
-		if pw.mp == nil {
-			logger.Infof("removing part %q, since its data is out of the configured retention (%d secs)", pw.p.path, pt.s.retentionMsecs/1000)
-			fs.MustRemoveDirAtomic(pw.p.path)
-		}
-	}
-	// There is no need in calling fs.MustSyncPath() on pt.smallPartsPath and pt.bigPartsPath,
-	// since they should be automatically called inside fs.MustRemoveDirAtomic().
-	pt.snapshotLock.RUnlock()
-
-	// Remove partition references from removed parts.
-	for pw := range m {
-		pw.decRef()
-	}
-
+	pt.swapSrcWithDstParts(pws, nil, partSmall)
 }
 
 // getPartsToMerge returns optimal parts to merge from pws.
@@ -1883,68 +1817,50 @@ func getPartsSize(pws []*partWrapper) uint64 {
 	return n
 }
 
-func openParts(pathPrefix1, pathPrefix2, path string) ([]*partWrapper, error) {
+func openParts(path string, partNames []string) ([]*partWrapper, error) {
 	// The path can be missing after restoring from backup, so create it if needed.
 	if err := fs.MkdirAllIfNotExist(path); err != nil {
 		return nil, err
 	}
 	fs.MustRemoveTemporaryDirs(path)
-	d, err := os.Open(path)
-	if err != nil {
-		return nil, fmt.Errorf("cannot open partition directory: %w", err)
-	}
-	defer fs.MustClose(d)
 
-	// Run remaining transactions and cleanup /txn and /tmp directories.
-	// Snapshots cannot be created yet, so use fakeSnapshotLock.
-	var fakeSnapshotLock sync.RWMutex
-	if err := runTransactions(&fakeSnapshotLock, pathPrefix1, pathPrefix2, path); err != nil {
-		return nil, fmt.Errorf("cannot run transactions from %q: %w", path, err)
-	}
-
-	txnDir := path + "/txn"
-	fs.MustRemoveDirAtomic(txnDir)
-	tmpDir := path + "/tmp"
-	fs.MustRemoveDirAtomic(tmpDir)
-	if err := createPartitionDirs(path); err != nil {
-		return nil, fmt.Errorf("cannot create directories for partition %q: %w", path, err)
-	}
+	// Remove txn and tmp directories, which may be left after the upgrade
+	// to v1.90.0 and newer versions.
+	fs.MustRemoveAll(path + "/txn")
+	fs.MustRemoveAll(path + "/tmp")
 
-	// Open parts.
-	fis, err := d.Readdir(-1)
+	// Remove dirs missing in partNames. These dirs may be left after unclean shutdown
+	// or after the update from versions prior to v1.90.0.
+	des, err := os.ReadDir(path)
 	if err != nil {
-		return nil, fmt.Errorf("cannot read directory %q: %w", d.Name(), err)
+		return nil, fmt.Errorf("cannot read partition dir: %w", err)
 	}
-	var pws []*partWrapper
-	for _, fi := range fis {
-		if !fs.IsDirOrSymlink(fi) {
+	m := make(map[string]struct{}, len(partNames))
+	for _, partName := range partNames {
+		m[partName] = struct{}{}
+	}
+	for _, de := range des {
+		if !fs.IsDirOrSymlink(de) {
 			// Skip non-directories.
 			continue
 		}
-		fn := fi.Name()
-		if fn == "snapshots" {
-			// "snapshots" dir is skipped for backwards compatibility. Now it is unused.
-			continue
+		fn := de.Name()
+		if _, ok := m[fn]; !ok {
+			deletePath := path + "/" + fn
+			fs.MustRemoveAll(deletePath)
 		}
-		if fn == "tmp" || fn == "txn" {
-			// Skip special dirs.
-			continue
-		}
-		partPath := path + "/" + fn
-		if fs.IsEmptyDir(partPath) {
-			// Remove empty directory, which can be left after unclean shutdown on NFS.
-			// See https://github.com/VictoriaMetrics/VictoriaMetrics/issues/1142
-			fs.MustRemoveDirAtomic(partPath)
-			continue
-		}
-		startTime := time.Now()
+	}
+	fs.MustSyncPath(path)
+
+	// Open parts
+	var pws []*partWrapper
+	for _, partName := range partNames {
+		partPath := path + "/" + partName
 		p, err := openFilePart(partPath)
 		if err != nil {
 			mustCloseParts(pws)
 			return nil, fmt.Errorf("cannot open part %q: %w", partPath, err)
 		}
-		logger.Infof("opened part %q in %.3f seconds", partPath, time.Since(startTime).Seconds())
-
 		pw := &partWrapper{
 			p:        p,
 			refCount: 1,
@@ -1966,8 +1882,7 @@ func mustCloseParts(pws []*partWrapper) {
 
 // CreateSnapshotAt creates pt snapshot at the given smallPath and bigPath dirs.
 //
-// Snapshot is created using linux hard links, so it is usually created
-// very quickly.
+// Snapshot is created using linux hard links, so it is usually created very quickly.
 func (pt *partition) CreateSnapshotAt(smallPath, bigPath string) error {
 	logger.Infof("creating partition snapshot of %q and %q...", pt.smallPartsPath, pt.bigPartsPath)
 	startTime := time.Now()
@@ -1975,15 +1890,32 @@ func (pt *partition) CreateSnapshotAt(smallPath, bigPath string) error {
 	// Flush inmemory data to disk.
 	pt.flushInmemoryRows()
 
-	// The snapshot must be created under the lock in order to prevent from
-	// concurrent modifications via runTransaction.
-	pt.snapshotLock.Lock()
-	defer pt.snapshotLock.Unlock()
+	pt.partsLock.Lock()
+	incRefForParts(pt.smallParts)
+	pwsSmall := append([]*partWrapper{}, pt.smallParts...)
+	incRefForParts(pt.bigParts)
+	pwsBig := append([]*partWrapper{}, pt.bigParts...)
+	pt.partsLock.Unlock()
 
-	if err := pt.createSnapshot(pt.smallPartsPath, smallPath); err != nil {
+	defer func() {
+		pt.PutParts(pwsSmall)
+		pt.PutParts(pwsBig)
+	}()
+
+	if err := fs.MkdirAllFailIfExist(smallPath); err != nil {
+		return fmt.Errorf("cannot create snapshot dir %q: %w", smallPath, err)
+	}
+	if err := fs.MkdirAllFailIfExist(bigPath); err != nil {
+		return fmt.Errorf("cannot create snapshot dir %q: %w", bigPath, err)
+	}
+
+	// Create a file with part names at smallPath
+	mustWritePartNames(pwsSmall, pwsBig, smallPath)
+
+	if err := pt.createSnapshot(pt.smallPartsPath, smallPath, pwsSmall); err != nil {
 		return fmt.Errorf("cannot create snapshot for %q: %w", pt.smallPartsPath, err)
 	}
-	if err := pt.createSnapshot(pt.bigPartsPath, bigPath); err != nil {
+	if err := pt.createSnapshot(pt.bigPartsPath, bigPath, pwsBig); err != nil {
 		return fmt.Errorf("cannot create snapshot for %q: %w", pt.bigPartsPath, err)
 	}
 
@@ -1995,214 +1927,115 @@ func (pt *partition) CreateSnapshotAt(smallPath, bigPath string) error {
 // createSnapshot creates a snapshot from srcDir to dstDir.
 //
 // The caller is responsible for deleting dstDir if createSnapshot() returns error.
-func (pt *partition) createSnapshot(srcDir, dstDir string) error {
-	if err := fs.MkdirAllFailIfExist(dstDir); err != nil {
-		return fmt.Errorf("cannot create snapshot dir %q: %w", dstDir, err)
-	}
-
-	d, err := os.Open(srcDir)
-	if err != nil {
-		return fmt.Errorf("cannot open partition difrectory: %w", err)
-	}
-	defer fs.MustClose(d)
-
-	fis, err := d.Readdir(-1)
-	if err != nil {
-		return fmt.Errorf("cannot read partition directory: %w", err)
-	}
-	for _, fi := range fis {
-		fn := fi.Name()
-		if !fs.IsDirOrSymlink(fi) {
-			if fn == "appliedRetention.txt" {
-				// Copy the appliedRetention.txt file to dstDir.
-				// This file can be created by VictoriaMetrics enterprise.
-				// See https://docs.victoriametrics.com/#retention-filters .
-				// Do not make hard link to this file, since it can be modified over time.
-				srcPath := srcDir + "/" + fn
-				dstPath := dstDir + "/" + fn
-				if err := fs.CopyFile(srcPath, dstPath); err != nil {
-					return fmt.Errorf("cannot copy %q to %q: %w", srcPath, dstPath, err)
-				}
-			}
-			// Skip non-directories.
-			continue
-		}
-		if fn == "tmp" || fn == "txn" || fs.IsScheduledForRemoval(fn) {
-			// Skip special dirs.
-			continue
-		}
-		srcPartPath := srcDir + "/" + fn
-		dstPartPath := dstDir + "/" + fn
+func (pt *partition) createSnapshot(srcDir, dstDir string, pws []*partWrapper) error {
+	// Make hardlinks for pws at dstDir
+	for _, pw := range pws {
+		srcPartPath := pw.p.path
+		dstPartPath := dstDir + "/" + filepath.Base(srcPartPath)
 		if err := fs.HardLinkFiles(srcPartPath, dstPartPath); err != nil {
 			return fmt.Errorf("cannot create hard links from %q to %q: %w", srcPartPath, dstPartPath, err)
 		}
 	}
 
+	// Copy the appliedRetention.txt file to dstDir.
+	// This file can be created by VictoriaMetrics enterprise.
+	// See https://docs.victoriametrics.com/#retention-filters .
+	// Do not make hard link to this file, since it can be modified over time.
+	srcPath := srcDir + "/appliedRetention.txt"
+	if fs.IsPathExist(srcPath) {
+		dstPath := dstDir + "/" + filepath.Base(srcPath)
+		if err := fs.CopyFile(srcPath, dstPath); err != nil {
+			return fmt.Errorf("cannot copy %q to %q: %w", srcPath, dstPath, err)
+		}
+	}
+
 	fs.MustSyncPath(dstDir)
-	fs.MustSyncPath(filepath.Dir(dstDir))
+	parentDir := filepath.Dir(dstDir)
+	fs.MustSyncPath(parentDir)
 
 	return nil
 }
 
-func runTransactions(txnLock *sync.RWMutex, pathPrefix1, pathPrefix2, path string) error {
-	// Wait until all the previous pending transaction deletions are finished.
-	pendingTxnDeletionsWG.Wait()
-
-	// Make sure all the current transaction deletions are finished before exiting.
-	defer pendingTxnDeletionsWG.Wait()
+type partNamesJSON struct {
+	Small []string
+	Big   []string
+}
 
-	txnDir := path + "/txn"
-	d, err := os.Open(txnDir)
-	if err != nil {
-		if os.IsNotExist(err) {
-			return nil
-		}
-		return fmt.Errorf("cannot open transaction directory: %w", err)
+func mustWritePartNames(pwsSmall, pwsBig []*partWrapper, dstDir string) {
+	partNamesSmall := getPartNames(pwsSmall)
+	partNamesBig := getPartNames(pwsBig)
+	partNames := &partNamesJSON{
+		Small: partNamesSmall,
+		Big:   partNamesBig,
 	}
-	defer fs.MustClose(d)
-
-	fis, err := d.Readdir(-1)
+	data, err := json.Marshal(partNames)
 	if err != nil {
-		return fmt.Errorf("cannot read directory %q: %w", d.Name(), err)
+		logger.Panicf("BUG: cannot marshal partNames to JSON: %s", err)
 	}
+	partNamesPath := dstDir + "/parts.json"
+	if err := fs.WriteFileAtomically(partNamesPath, data, true); err != nil {
+		logger.Panicf("FATAL: cannot update %s: %s", partNamesPath, err)
+	}
+}
 
-	// Sort transaction files by id.
-	sort.Slice(fis, func(i, j int) bool {
-		return fis[i].Name() < fis[j].Name()
-	})
-
-	for _, fi := range fis {
-		fn := fi.Name()
-		if fs.IsTemporaryFileName(fn) {
-			// Skip temporary files, which could be left after unclean shutdown.
+func getPartNames(pws []*partWrapper) []string {
+	partNames := make([]string, 0, len(pws))
+	for _, pw := range pws {
+		if pw.mp != nil {
+			// Skip in-memory parts
 			continue
 		}
-		txnPath := txnDir + "/" + fn
-		if err := runTransaction(txnLock, pathPrefix1, pathPrefix2, txnPath); err != nil {
-			return fmt.Errorf("cannot run transaction from %q: %w", txnPath, err)
-		}
+		partName := filepath.Base(pw.p.path)
+		partNames = append(partNames, partName)
 	}
-	return nil
+	sort.Strings(partNames)
+	return partNames
 }
 
-func runTransaction(txnLock *sync.RWMutex, pathPrefix1, pathPrefix2, txnPath string) error {
-	// The transaction must run under read lock in order to provide
-	// consistent snapshots with partition.CreateSnapshot().
-	txnLock.RLock()
-	defer txnLock.RUnlock()
-
-	data, err := os.ReadFile(txnPath)
-	if err != nil {
-		return fmt.Errorf("cannot read transaction file: %w", err)
-	}
-	if len(data) > 0 && data[len(data)-1] == '\n' {
-		data = data[:len(data)-1]
-	}
-	paths := strings.Split(string(data), "\n")
-
-	if len(paths) == 0 {
-		return fmt.Errorf("empty transaction")
-	}
-	rmPaths := paths[:len(paths)-1]
-	mvPaths := strings.Split(paths[len(paths)-1], " -> ")
-	if len(mvPaths) != 2 {
-		return fmt.Errorf("invalid last line in the transaction file: got %q; must contain `srcPath -> dstPath`", paths[len(paths)-1])
-	}
-
-	// Remove old paths. It is OK if certain paths don't exist.
-	for _, path := range rmPaths {
-		path, err := validatePath(pathPrefix1, pathPrefix2, path)
-		if err != nil {
-			return fmt.Errorf("invalid path to remove: %w", err)
+func mustReadPartNames(smallPartsPath, bigPartsPath string) ([]string, []string) {
+	partNamesPath := smallPartsPath + "/parts.json"
+	data, err := os.ReadFile(partNamesPath)
+	if err == nil {
+		var partNames partNamesJSON
+		if err := json.Unmarshal(data, &partNames); err != nil {
+			logger.Panicf("FATAL: cannot parse %s: %s", partNamesPath, err)
 		}
-		fs.MustRemoveDirAtomic(path)
+		return partNames.Small, partNames.Big
 	}
-
-	// Move the new part to new directory.
-	srcPath := mvPaths[0]
-	dstPath := mvPaths[1]
-	if len(srcPath) > 0 {
-		srcPath, err = validatePath(pathPrefix1, pathPrefix2, srcPath)
-		if err != nil {
-			return fmt.Errorf("invalid source path to rename: %w", err)
-		}
-		if len(dstPath) > 0 {
-			// Move srcPath to dstPath.
-			dstPath, err = validatePath(pathPrefix1, pathPrefix2, dstPath)
-			if err != nil {
-				return fmt.Errorf("invalid destination path to rename: %w", err)
-			}
-			if fs.IsPathExist(srcPath) {
-				if err := os.Rename(srcPath, dstPath); err != nil {
-					return fmt.Errorf("cannot rename %q to %q: %w", srcPath, dstPath, err)
-				}
-			} else if !fs.IsPathExist(dstPath) {
-				// Emit info message for the expected condition after unclean shutdown on NFS disk.
-				// The dstPath part may be missing because it could be already merged into bigger part
-				// while old source parts for the current txn weren't still deleted due to NFS locks.
-				logger.Infof("cannot find both source and destination paths: %q -> %q; this may be the case after unclean shutdown "+
-					"(OOM, `kill -9`, hard reset) on NFS disk", srcPath, dstPath)
-			}
-		} else {
-			// Just remove srcPath.
-			fs.MustRemoveDirAtomic(srcPath)
-		}
+	if !os.IsNotExist(err) {
+		logger.Panicf("FATAL: cannot read parts.json file: %s", err)
 	}
-
-	// Flush pathPrefix* directory metadata to the underlying storage,
-	// so the moved files become visible there.
-	fs.MustSyncPath(pathPrefix1)
-	fs.MustSyncPath(pathPrefix2)
-
-	pendingTxnDeletionsWG.Add(1)
-	go func() {
-		defer pendingTxnDeletionsWG.Done()
-
-		// There is no need in calling fs.MustSyncPath for pathPrefix* after parts' removal,
-		// since it is already called by fs.MustRemoveDirAtomic.
-
-		if err := os.Remove(txnPath); err != nil {
-			logger.Errorf("cannot remove transaction file %q: %s", txnPath, err)
-		}
-	}()
-
-	return nil
+	// The parts.json is missing. This is the upgrade from versions previous to v1.90.0.
+	// Read part names from smallPartsPath and bigPartsPath directories
+	partNamesSmall := mustReadPartNamesFromDir(smallPartsPath)
+	partNamesBig := mustReadPartNamesFromDir(bigPartsPath)
+	return partNamesSmall, partNamesBig
 }
 
-var pendingTxnDeletionsWG syncwg.WaitGroup
-
-func validatePath(pathPrefix1, pathPrefix2, path string) (string, error) {
-	var err error
-
-	pathPrefix1, err = filepath.Abs(pathPrefix1)
+func mustReadPartNamesFromDir(srcDir string) []string {
+	des, err := os.ReadDir(srcDir)
 	if err != nil {
-		return path, fmt.Errorf("cannot determine absolute path for pathPrefix1=%q: %w", pathPrefix1, err)
-	}
-	pathPrefix2, err = filepath.Abs(pathPrefix2)
-	if err != nil {
-		return path, fmt.Errorf("cannot determine absolute path for pathPrefix2=%q: %w", pathPrefix2, err)
-	}
-
-	path, err = filepath.Abs(path)
-	if err != nil {
-		return path, fmt.Errorf("cannot determine absolute path for %q: %w", path, err)
+		if os.IsNotExist(err) {
+			return nil
+		}
+		logger.Panicf("FATAL: cannot read partition dir: %s", err)
 	}
-	if !strings.HasPrefix(path, pathPrefix1+"/") && !strings.HasPrefix(path, pathPrefix2+"/") {
-		return path, fmt.Errorf("invalid path %q; must start with either %q or %q", path, pathPrefix1+"/", pathPrefix2+"/")
+	var partNames []string
+	for _, de := range des {
+		if !fs.IsDirOrSymlink(de) {
+			// Skip non-directories.
+			continue
+		}
+		partName := de.Name()
+		if isSpecialDir(partName) {
+			// Skip special dirs.
+			continue
+		}
+		partNames = append(partNames, partName)
 	}
-	return path, nil
+	return partNames
 }
 
-func createPartitionDirs(path string) error {
-	path = filepath.Clean(path)
-	txnPath := path + "/txn"
-	if err := fs.MkdirAllFailIfExist(txnPath); err != nil {
-		return fmt.Errorf("cannot create txn directory %q: %w", txnPath, err)
-	}
-	tmpPath := path + "/tmp"
-	if err := fs.MkdirAllFailIfExist(tmpPath); err != nil {
-		return fmt.Errorf("cannot create tmp directory %q: %w", tmpPath, err)
-	}
-	fs.MustSyncPath(path)
-	return nil
+func isSpecialDir(name string) bool {
+	return name == "tmp" || name == "txn" || name == "snapshots" || fs.IsScheduledForRemoval(name)
 }
diff --git a/lib/storage/storage.go b/lib/storage/storage.go
index 38a06dec5..d020ae9f9 100644
--- a/lib/storage/storage.go
+++ b/lib/storage/storage.go
@@ -160,8 +160,8 @@ func OpenStorage(path string, retentionMsecs int64, maxHourlySeries, maxDailySer
 	// See https://github.com/VictoriaMetrics/VictoriaMetrics/issues/1447 for details.
 	if fs.IsPathExist(s.cachePath + "/reset_cache_on_startup") {
 		logger.Infof("removing cache directory at %q, since it contains `reset_cache_on_startup` file...", s.cachePath)
-		// Do not use fs.MustRemoveDirAtomic() here, since the cache directory may be mounted
-		// to a separate filesystem. In this case the fs.MustRemoveDirAtomic() will fail while
+		// Do not use fs.MustRemoveAll() here, since the cache directory may be mounted
+		// to a separate filesystem. In this case the fs.MustRemoveAll() will fail while
 		// trying to remove the mount root.
 		fs.RemoveDirContents(s.cachePath)
 		logger.Infof("cache directory at %q has been successfully removed", s.cachePath)
@@ -2339,25 +2339,19 @@ func (s *Storage) openIndexDBTables(path string) (curr, prev *indexDB, err error
 	}
 	fs.MustRemoveTemporaryDirs(path)
 
-	d, err := os.Open(path)
-	if err != nil {
-		return nil, nil, fmt.Errorf("cannot open directory: %w", err)
-	}
-	defer fs.MustClose(d)
-
 	// Search for the two most recent tables - the last one is active,
 	// the previous one contains backup data.
-	fis, err := d.Readdir(-1)
+	des, err := os.ReadDir(path)
 	if err != nil {
 		return nil, nil, fmt.Errorf("cannot read directory: %w", err)
 	}
 	var tableNames []string
-	for _, fi := range fis {
-		if !fs.IsDirOrSymlink(fi) {
+	for _, de := range des {
+		if !fs.IsDirOrSymlink(de) {
 			// Skip non-directories.
 			continue
 		}
-		tableName := fi.Name()
+		tableName := de.Name()
 		if !indexDBTableNameRegexp.MatchString(tableName) {
 			// Skip invalid directories.
 			continue
@@ -2383,7 +2377,7 @@ func (s *Storage) openIndexDBTables(path string) (curr, prev *indexDB, err error
 	for _, tn := range tableNames[:len(tableNames)-2] {
 		pathToRemove := path + "/" + tn
 		logger.Infof("removing obsolete indexdb dir %q...", pathToRemove)
-		fs.MustRemoveDirAtomic(pathToRemove)
+		fs.MustRemoveAll(pathToRemove)
 		logger.Infof("removed obsolete indexdb dir %q", pathToRemove)
 	}
 
diff --git a/lib/storage/table.go b/lib/storage/table.go
index 32bb03373..95fd3a657 100644
--- a/lib/storage/table.go
+++ b/lib/storage/table.go
@@ -466,7 +466,7 @@ func (tb *table) finalDedupWatcher() {
 		timestamp := timestampFromTime(time.Now())
 		currentPartitionName := timestampToPartitionName(timestamp)
 		for _, ptw := range ptws {
-			if ptw.pt.name == currentPartitionName {
+			if ptw.pt.name == currentPartitionName || !ptw.pt.isFinalDedupNeeded() {
 				// Do not run final dedup for the current month.
 				continue
 			}
@@ -535,22 +535,16 @@ func openPartitions(smallPartitionsPath, bigPartitionsPath string, s *Storage) (
 }
 
 func populatePartitionNames(partitionsPath string, ptNames map[string]bool) error {
-	d, err := os.Open(partitionsPath)
+	des, err := os.ReadDir(partitionsPath)
 	if err != nil {
-		return fmt.Errorf("cannot open directory with partitions: %w", err)
+		return fmt.Errorf("cannot read directory with partitions: %w", err)
 	}
-	defer fs.MustClose(d)
-
-	fis, err := d.Readdir(-1)
-	if err != nil {
-		return fmt.Errorf("cannot read directory with partitions %q: %w", partitionsPath, err)
-	}
-	for _, fi := range fis {
-		if !fs.IsDirOrSymlink(fi) {
+	for _, de := range des {
+		if !fs.IsDirOrSymlink(de) {
 			// Skip non-directories
 			continue
 		}
-		ptName := fi.Name()
+		ptName := de.Name()
 		if ptName == "snapshots" {
 			// Skip directory with snapshots
 			continue


Test patch:
diff --git a/app/vmalert/config/log/logger_test.go b/app/vmalert/config/log/logger_test.go
new file mode 100644
index 000000000..ea0f4fe6f
--- /dev/null
+++ b/app/vmalert/config/log/logger_test.go
@@ -0,0 +1,54 @@
+package log
+
+import (
+	"bytes"
+	"fmt"
+	"strings"
+	"testing"
+
+	"github.com/VictoriaMetrics/VictoriaMetrics/lib/logger"
+)
+
+func TestOutput(t *testing.T) {
+	testOutput := &bytes.Buffer{}
+	logger.SetOutputForTests(testOutput)
+	defer logger.ResetOutputForTest()
+
+	log := &Logger{}
+
+	mustMatch := func(exp string) {
+		t.Helper()
+		if exp == "" {
+			if testOutput.String() != "" {
+				t.Errorf("expected output to be empty; got %q", testOutput.String())
+				return
+			}
+		}
+		if !strings.Contains(testOutput.String(), exp) {
+			t.Errorf("output %q should contain %q", testOutput.String(), exp)
+		}
+		fmt.Println(testOutput.String())
+		testOutput.Reset()
+	}
+
+	log.Warnf("foo")
+	mustMatch("foo")
+
+	log.Infof("info %d", 2)
+	mustMatch("info 2")
+
+	log.Errorf("error %s %d", "baz", 5)
+	mustMatch("error baz 5")
+
+	log.Suppress(true)
+
+	log.Warnf("foo")
+	mustMatch("")
+
+	log.Infof("info %d", 2)
+	mustMatch("")
+
+	log.Errorf("error %q %d", "baz", 5)
+	mustMatch("")
+
+}
diff --git a/app/vmalert/notifier/alert_test.go b/app/vmalert/notifier/alert_test.go
index 9999f6f42..bff535996 100644
--- a/app/vmalert/notifier/alert_test.go
+++ b/app/vmalert/notifier/alert_test.go
@@ -200,6 +200,9 @@ func TestAlert_ExecTemplate(t *testing.T) {
 	}
 	for _, tc := range testCases {
 		t.Run(tc.name, func(t *testing.T) {
+			if err := ValidateTemplates(tc.annotations); err != nil {
+				t.Fatal(err)
+			}
 			tpl, err := tc.alert.ExecTemplate(qFn, tc.alert.Labels, tc.annotations)
 			if err != nil {
 				t.Fatal(err)
diff --git a/app/vmalert/notifier/init_test.go b/app/vmalert/notifier/init_test.go
new file mode 100644
index 000000000..c7ab2527f
--- /dev/null
+++ b/app/vmalert/notifier/init_test.go
@@ -0,0 +1,37 @@
+package notifier
+
+import (
+	"github.com/VictoriaMetrics/VictoriaMetrics/lib/flagutil"
+	"testing"
+)
+
+func TestInit(t *testing.T) {
+	oldAddrs := *addrs
+	defer func() { *addrs = oldAddrs }()
+
+	*addrs = flagutil.ArrayString{"127.0.0.1", "127.0.0.2"}
+
+	fn, err := Init(nil, nil, "")
+	if err != nil {
+		t.Fatalf("%s", err)
+	}
+
+	nfs := fn()
+	if len(nfs) != 2 {
+		t.Fatalf("expected to get 2 notifiers; got %d", len(nfs))
+	}
+
+	targets := GetTargets()
+	if targets == nil || targets[TargetStatic] == nil {
+		t.Fatalf("expected to get static targets in response")
+	}
+
+	nf1 := targets[TargetStatic][0]
+	if nf1.Addr() != "127.0.0.1/api/v2/alerts" {
+		t.Fatalf("expected to get \"127.0.0.1/api/v2/alerts\"; got %q instead", nf1.Addr())
+	}
+	nf2 := targets[TargetStatic][1]
+	if nf2.Addr() != "127.0.0.2/api/v2/alerts" {
+		t.Fatalf("expected to get \"127.0.0.2/api/v2/alerts\"; got %q instead", nf2.Addr())
+	}
+}
diff --git a/app/vmalert/remotewrite/init_test.go b/app/vmalert/remotewrite/init_test.go
new file mode 100644
index 000000000..330fef0f3
--- /dev/null
+++ b/app/vmalert/remotewrite/init_test.go
@@ -0,0 +1,20 @@
+package remotewrite
+
+import (
+	"context"
+	"testing"
+)
+
+func TestInit(t *testing.T) {
+	oldAddr := *addr
+	defer func() { *addr = oldAddr }()
+
+	*addr = "http://localhost:8428"
+	cl, err := Init(context.Background())
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := cl.Close(); err != nil {
+		t.Fatal(err)
+	}
+}
diff --git a/app/vmalert/templates/template_test.go b/app/vmalert/templates/template_test.go
index d5378a434..30bf1e975 100644
--- a/app/vmalert/templates/template_test.go
+++ b/app/vmalert/templates/template_test.go
@@ -76,6 +76,20 @@ func TestTemplateFuncs(t *testing.T) {
 	formatting("humanize1024", float64(146521335255970361638912), "124.1Zi")
 	formatting("humanize1024", float64(150037847302113650318245888), "124.1Yi")
 	formatting("humanize1024", float64(153638755637364377925883789312), "1.271e+05Yi")
+
+	formatting("humanize", float64(127087), "127.1k")
+	formatting("humanize", float64(136458627186688), "136.5T")
+
+	formatting("humanizeDuration", 1, "1s")
+	formatting("humanizeDuration", 0.2, "200ms")
+	formatting("humanizeDuration", 42000, "11h 40m 0s")
+	formatting("humanizeDuration", 16790555, "194d 8h 2m 35s")
+
+	formatting("humanizePercentage", 1, "100%")
+	formatting("humanizePercentage", 0.8, "80%")
+	formatting("humanizePercentage", 0.015, "1.5%")
+
+	formatting("humanizeTimestamp", 1679055557, "2023-03-17 12:19:17 +0000 UTC")
 }
 
 func mkTemplate(current, replacement interface{}) textTemplate {
diff --git a/app/vmalert/web_test.go b/app/vmalert/web_test.go
index 5648087c6..600010527 100644
--- a/app/vmalert/web_test.go
+++ b/app/vmalert/web_test.go
@@ -7,6 +7,7 @@ import (
 	"net/http/httptest"
 	"reflect"
 	"testing"
+	"time"
 
 	"github.com/VictoriaMetrics/VictoriaMetrics/app/vmalert/notifier"
 )
@@ -19,9 +20,18 @@ func TestHandler(t *testing.T) {
 		},
 		state: newRuleState(10),
 	}
+	ar.state.add(ruleStateEntry{
+		time:    time.Now(),
+		at:      time.Now(),
+		samples: 10,
+	})
+	rr := &RecordingRule{
+		Name:  "record",
+		state: newRuleState(10),
+	}
 	g := &Group{
 		Name:  "group",
-		Rules: []Rule{ar},
+		Rules: []Rule{ar, rr},
 	}
 	m := &manager{groups: make(map[uint64]*Group)}
 	m.groups[0] = g
@@ -62,6 +72,14 @@ func TestHandler(t *testing.T) {
 	t.Run("/vmalert/rule", func(t *testing.T) {
 		a := ar.ToAPI()
 		getResp(ts.URL+"/vmalert/"+a.WebLink(), nil, 200)
+		r := rr.ToAPI()
+		getResp(ts.URL+"/vmalert/"+r.WebLink(), nil, 200)
+	})
+	t.Run("/vmalert/alert", func(t *testing.T) {
+		alerts := ar.AlertsToAPI()
+		for _, a := range alerts {
+			getResp(ts.URL+"/vmalert/"+a.WebLink(), nil, 200)
+		}
 	})
 	t.Run("/vmalert/rule?badParam", func(t *testing.T) {
 		params := fmt.Sprintf("?%s=0&%s=1", paramGroupID, paramRuleID)
diff --git a/lib/storage/part_header_test.go b/lib/storage/part_header_test.go
deleted file mode 100644
index a4c20c0b1..000000000
--- a/lib/storage/part_header_test.go
+++ /dev/null
@@ -1,62 +0,0 @@
-package storage
-
-import (
-	"testing"
-)
-
-func TestPartHeaderParseFromPath(t *testing.T) {
-	testParseFromPathError := func(path string) {
-		t.Helper()
-
-		var ph partHeader
-		if err := ph.ParseFromPath(path); err == nil {
-			t.Fatalf("expecting non-nil error")
-		}
-	}
-
-	t.Run("Error", func(t *testing.T) {
-		testParseFromPathError("")
-		testParseFromPathError("foobar")
-		testParseFromPathError("/foo/bar")
-		testParseFromPathError("/rowscount_mintimestamp_maxtimestamp_garbage")
-		testParseFromPathError("/rowscount_mintimestamp_maxtimestamp_garbage")
-		testParseFromPathError("/12_3456_mintimestamp_maxtimestamp_garbage")
-		testParseFromPathError("/12_3456_20181011010203.456_maxtimestamp_garbage")
-		testParseFromPathError("/12_3456_20181011010203.456_20181011010202.456_garbage")
-		testParseFromPathError("12_3456_20181011010203.456_20181011010203.457_garbage")
-		testParseFromPathError("12_3456_20181011010203.456_20181011010203.457_garbage/")
-
-		// MinTimestamp > MaxTimetamp
-		testParseFromPathError("1233_456_20181011010203.456_20181011010202.457_garbage")
-
-		// Zero rowsCount
-		testParseFromPathError("0_123_20181011010203.456_20181011010203.457_garbage")
-
-		// Zero blocksCount
-		testParseFromPathError("123_0_20181011010203.456_20181011010203.457_garbage")
-
-		// blocksCount > rowsCount
-		testParseFromPathError("123_456_20181011010203.456_20181011010203.457_garbage")
-	})
-
-	testParseFromPathSuccess := func(path string, phStringExpected string) {
-		t.Helper()
-
-		var ph partHeader
-		if err := ph.ParseFromPath(path); err != nil {
-			t.Fatalf("unexpected error when parsing path %q: %s", path, err)
-		}
-		phString := ph.String()
-		if phString != phStringExpected {
-			t.Fatalf("unexpected partHeader string for path %q: got %q; want %q", path, phString, phStringExpected)
-		}
-	}
-
-	t.Run("Success", func(t *testing.T) {
-		testParseFromPathSuccess("/1233_456_20181011010203.456_20181011010203.457_garbage", "1233_456_20181011010203.456_20181011010203.457")
-		testParseFromPathSuccess("/1233_456_20181011010203.456_20181011010203.457_garbage/", "1233_456_20181011010203.456_20181011010203.457")
-		testParseFromPathSuccess("/1233_456_20181011010203.456_20181011010203.457_garbage///", "1233_456_20181011010203.456_20181011010203.457")
-		testParseFromPathSuccess("/var/lib/tsdb/1233_456_20181011010203.456_20181011010203.457_garbage///", "1233_456_20181011010203.456_20181011010203.457")
-		testParseFromPathSuccess("/var/lib/tsdb/456_456_20181011010203.456_20181011010203.457_232345///", "456_456_20181011010203.456_20181011010203.457")
-	})
-}