2PC released
This commit is contained in:
377
LICENSE
Normal file
377
LICENSE
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
COMMON DEVELOPMENT AND DISTRIBUTION LICENSE Version 1.0
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
1.1. "Contributor" means each individual or entity that creates
|
||||||
|
or contributes to the creation of Modifications.
|
||||||
|
|
||||||
|
1.2. "Contributor Version" means the combination of the Original
|
||||||
|
Software, prior Modifications used by a Contributor (if any),
|
||||||
|
and the Modifications made by that particular Contributor.
|
||||||
|
|
||||||
|
1.3. "Covered Software" means (a) the Original Software, or (b)
|
||||||
|
Modifications, or (c) the combination of files containing
|
||||||
|
Original Software with files containing Modifications, in
|
||||||
|
each case including portions thereof.
|
||||||
|
|
||||||
|
1.4. "Executable" means the Covered Software in any form other
|
||||||
|
than Source Code.
|
||||||
|
|
||||||
|
1.5. "Initial Developer" means the individual or entity that first
|
||||||
|
makes Original Software available under this License.
|
||||||
|
|
||||||
|
1.6. "Larger Work" means a work which combines Covered Software or
|
||||||
|
portions thereof with code not governed by the terms of this
|
||||||
|
License.
|
||||||
|
|
||||||
|
1.7. "License" means this document.
|
||||||
|
|
||||||
|
1.8. "Licensable" means having the right to grant, to the maximum
|
||||||
|
extent possible, whether at the time of the initial grant or
|
||||||
|
subsequently acquired, any and all of the rights conveyed
|
||||||
|
herein.
|
||||||
|
|
||||||
|
1.9. "Modifications" means the Source Code and Executable form of
|
||||||
|
any of the following:
|
||||||
|
|
||||||
|
A. Any file that results from an addition to, deletion from or
|
||||||
|
modification of the contents of a file containing Original
|
||||||
|
Software or previous Modifications;
|
||||||
|
|
||||||
|
B. Any new file that contains any part of the Original
|
||||||
|
Software or previous Modifications; or
|
||||||
|
|
||||||
|
C. Any new file that is contributed or otherwise made
|
||||||
|
available under the terms of this License.
|
||||||
|
|
||||||
|
1.10. "Original Software" means the Source Code and Executable
|
||||||
|
form of computer software code that is originally released
|
||||||
|
under this License.
|
||||||
|
|
||||||
|
1.11. "Patent Claims" means any patent claim(s), now owned or
|
||||||
|
hereafter acquired, including without limitation, method,
|
||||||
|
process, and apparatus claims, in any patent Licensable by
|
||||||
|
grantor.
|
||||||
|
|
||||||
|
1.12. "Source Code" means (a) the common form of computer software
|
||||||
|
code in which modifications are made and (b) associated
|
||||||
|
documentation included in or with such code.
|
||||||
|
|
||||||
|
1.13. "You" (or "Your") means an individual or a legal entity
|
||||||
|
exercising rights under, and complying with all of the terms
|
||||||
|
of, this License. For legal entities, "You" includes any
|
||||||
|
entity which controls, is controlled by, or is under common
|
||||||
|
control with You. For purposes of this definition,
|
||||||
|
"control" means (a) the power, direct or indirect, to cause
|
||||||
|
the direction or management of such entity, whether by
|
||||||
|
contract or otherwise, or (b) ownership of more than fifty
|
||||||
|
percent (50%) of the outstanding shares or beneficial
|
||||||
|
ownership of such entity.
|
||||||
|
|
||||||
|
2. License Grants.
|
||||||
|
|
||||||
|
2.1. The Initial Developer Grant.
|
||||||
|
|
||||||
|
Conditioned upon Your compliance with Section 3.1 below and
|
||||||
|
subject to third party intellectual property claims, the Initial
|
||||||
|
Developer hereby grants You a world-wide, royalty-free,
|
||||||
|
non-exclusive license:
|
||||||
|
|
||||||
|
(a) under intellectual property rights (other than patent or
|
||||||
|
trademark) Licensable by Initial Developer, to use,
|
||||||
|
reproduce, modify, display, perform, sublicense and
|
||||||
|
distribute the Original Software (or portions thereof),
|
||||||
|
with or without Modifications, and/or as part of a Larger
|
||||||
|
Work; and
|
||||||
|
|
||||||
|
(b) under Patent Claims infringed by the making, using or
|
||||||
|
selling of Original Software, to make, have made, use,
|
||||||
|
practice, sell, and offer for sale, and/or otherwise
|
||||||
|
dispose of the Original Software (or portions thereof).
|
||||||
|
|
||||||
|
(c) The licenses granted in Sections 2.1(a) and (b) are
|
||||||
|
effective on the date Initial Developer first distributes
|
||||||
|
or otherwise makes the Original Software available to a
|
||||||
|
third party under the terms of this License.
|
||||||
|
|
||||||
|
(d) Notwithstanding Section 2.1(b) above, no patent license is
|
||||||
|
granted: (1) for code that You delete from the Original
|
||||||
|
Software, or (2) for infringements caused by: (i) the
|
||||||
|
modification of the Original Software, or (ii) the
|
||||||
|
combination of the Original Software with other software
|
||||||
|
or devices.
|
||||||
|
|
||||||
|
2.2. Contributor Grant.
|
||||||
|
|
||||||
|
Conditioned upon Your compliance with Section 3.1 below and
|
||||||
|
subject to third party intellectual property claims, each
|
||||||
|
Contributor hereby grants You a world-wide, royalty-free,
|
||||||
|
non-exclusive license:
|
||||||
|
|
||||||
|
(a) under intellectual property rights (other than patent or
|
||||||
|
trademark) Licensable by Contributor to use, reproduce,
|
||||||
|
modify, display, perform, sublicense and distribute the
|
||||||
|
Modifications created by such Contributor (or portions
|
||||||
|
thereof), either on an unmodified basis, with other
|
||||||
|
Modifications, as Covered Software and/or as part of a
|
||||||
|
Larger Work; and
|
||||||
|
|
||||||
|
(b) under Patent Claims infringed by the making, using, or
|
||||||
|
selling of Modifications made by that Contributor either
|
||||||
|
alone and/or in combination with its Contributor Version
|
||||||
|
(or portions of such combination), to make, use, sell,
|
||||||
|
offer for sale, have made, and/or otherwise dispose of:
|
||||||
|
(1) Modifications made by that Contributor (or portions
|
||||||
|
thereof); and (2) the combination of Modifications made by
|
||||||
|
that Contributor with its Contributor Version (or portions
|
||||||
|
of such combination).
|
||||||
|
|
||||||
|
(c) The licenses granted in Sections 2.2(a) and 2.2(b) are
|
||||||
|
effective on the date Contributor first distributes or
|
||||||
|
otherwise makes the Modifications available to a third
|
||||||
|
party.
|
||||||
|
|
||||||
|
(d) Notwithstanding Section 2.2(b) above, no patent license is
|
||||||
|
granted: (1) for any code that Contributor has deleted
|
||||||
|
from the Contributor Version; (2) for infringements caused
|
||||||
|
by: (i) third party modifications of Contributor Version,
|
||||||
|
or (ii) the combination of Modifications made by that
|
||||||
|
Contributor with other software (except as part of the
|
||||||
|
Contributor Version) or other devices; or (3) under Patent
|
||||||
|
Claims infringed by Covered Software in the absence of
|
||||||
|
Modifications made by that Contributor.
|
||||||
|
|
||||||
|
3. Distribution Obligations.
|
||||||
|
|
||||||
|
3.1. Availability of Source Code.
|
||||||
|
|
||||||
|
Any Covered Software that You distribute or otherwise make
|
||||||
|
available in Executable form must also be made available in Source
|
||||||
|
Code form and that Source Code form must be distributed only under
|
||||||
|
the terms of this License. You must include a copy of this
|
||||||
|
License with every copy of the Source Code form of the Covered
|
||||||
|
Software You distribute or otherwise make available. You must
|
||||||
|
inform recipients of any such Covered Software in Executable form
|
||||||
|
as to how they can obtain such Covered Software in Source Code
|
||||||
|
form in a reasonable manner on or through a medium customarily
|
||||||
|
used for software exchange.
|
||||||
|
|
||||||
|
3.2. Modifications.
|
||||||
|
|
||||||
|
The Modifications that You create or to which You contribute are
|
||||||
|
governed by the terms of this License. You represent that You
|
||||||
|
believe Your Modifications are Your original creation(s) and/or
|
||||||
|
You have sufficient rights to grant the rights conveyed by this
|
||||||
|
License.
|
||||||
|
|
||||||
|
3.3. Required Notices.
|
||||||
|
|
||||||
|
You must include a notice in each of Your Modifications that
|
||||||
|
identifies You as the Contributor of the Modification. You may
|
||||||
|
not remove or alter any copyright, patent or trademark notices
|
||||||
|
contained within the Covered Software, or any notices of licensing
|
||||||
|
or any descriptive text giving attribution to any Contributor or
|
||||||
|
the Initial Developer.
|
||||||
|
|
||||||
|
3.4. Application of Additional Terms.
|
||||||
|
|
||||||
|
You may not offer or impose any terms on any Covered Software in
|
||||||
|
Source Code form that alters or restricts the applicable version
|
||||||
|
of this License or the recipients' rights hereunder. You may
|
||||||
|
choose to offer, and to charge a fee for, warranty, support,
|
||||||
|
indemnity or liability obligations to one or more recipients of
|
||||||
|
Covered Software. However, you may do so only on Your own behalf,
|
||||||
|
and not on behalf of the Initial Developer or any Contributor.
|
||||||
|
You must make it absolutely clear that any such warranty, support,
|
||||||
|
indemnity or liability obligation is offered by You alone, and You
|
||||||
|
hereby agree to indemnify the Initial Developer and every
|
||||||
|
Contributor for any liability incurred by the Initial Developer or
|
||||||
|
such Contributor as a result of warranty, support, indemnity or
|
||||||
|
liability terms You offer.
|
||||||
|
|
||||||
|
3.5. Distribution of Executable Versions.
|
||||||
|
|
||||||
|
You may distribute the Executable form of the Covered Software
|
||||||
|
under the terms of this License or under the terms of a license of
|
||||||
|
Your choice, which may contain terms different from this License,
|
||||||
|
provided that You are in compliance with the terms of this License
|
||||||
|
and that the license for the Executable form does not attempt to
|
||||||
|
limit or alter the recipient's rights in the Source Code form from
|
||||||
|
the rights set forth in this License. If You distribute the
|
||||||
|
Covered Software in Executable form under a different license, You
|
||||||
|
must make it absolutely clear that any terms which differ from
|
||||||
|
this License are offered by You alone, not by the Initial
|
||||||
|
Developer or Contributor. You hereby agree to indemnify the
|
||||||
|
Initial Developer and every Contributor for any liability incurred
|
||||||
|
by the Initial Developer or such Contributor as a result of any
|
||||||
|
such terms You offer.
|
||||||
|
|
||||||
|
3.6. Larger Works.
|
||||||
|
|
||||||
|
You may create a Larger Work by combining Covered Software with
|
||||||
|
other code not governed by the terms of this License and
|
||||||
|
distribute the Larger Work as a single product. In such a case,
|
||||||
|
You must make sure the requirements of this License are fulfilled
|
||||||
|
for the Covered Software.
|
||||||
|
|
||||||
|
4. Versions of the License.
|
||||||
|
|
||||||
|
4.1. New Versions.
|
||||||
|
|
||||||
|
Sun Microsystems, Inc. is the initial license steward and may
|
||||||
|
publish revised and/or new versions of this License from time to
|
||||||
|
time. Each version will be given a distinguishing version number.
|
||||||
|
Except as provided in Section 4.3, no one other than the license
|
||||||
|
steward has the right to modify this License.
|
||||||
|
|
||||||
|
4.2. Effect of New Versions.
|
||||||
|
|
||||||
|
You may always continue to use, distribute or otherwise make the
|
||||||
|
Covered Software available under the terms of the version of the
|
||||||
|
License under which You originally received the Covered Software.
|
||||||
|
If the Initial Developer includes a notice in the Original
|
||||||
|
Software prohibiting it from being distributed or otherwise made
|
||||||
|
available under any subsequent version of the License, You must
|
||||||
|
distribute and make the Covered Software available under the terms
|
||||||
|
of the version of the License under which You originally received
|
||||||
|
the Covered Software. Otherwise, You may also choose to use,
|
||||||
|
distribute or otherwise make the Covered Software available under
|
||||||
|
the terms of any subsequent version of the License published by
|
||||||
|
the license steward.
|
||||||
|
|
||||||
|
4.3. Modified Versions.
|
||||||
|
|
||||||
|
When You are an Initial Developer and You want to create a new
|
||||||
|
license for Your Original Software, You may create and use a
|
||||||
|
modified version of this License if You: (a) rename the license
|
||||||
|
and remove any references to the name of the license steward
|
||||||
|
(except to note that the license differs from this License); and
|
||||||
|
(b) otherwise make it clear that the license contains terms which
|
||||||
|
differ from this License.
|
||||||
|
|
||||||
|
5. DISCLAIMER OF WARRANTY.
|
||||||
|
|
||||||
|
COVERED SOFTWARE IS PROVIDED UNDER THIS LICENSE ON AN "AS IS"
|
||||||
|
BASIS, WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED,
|
||||||
|
INCLUDING, WITHOUT LIMITATION, WARRANTIES THAT THE COVERED
|
||||||
|
SOFTWARE IS FREE OF DEFECTS, MERCHANTABLE, FIT FOR A PARTICULAR
|
||||||
|
PURPOSE OR NON-INFRINGING. THE ENTIRE RISK AS TO THE QUALITY AND
|
||||||
|
PERFORMANCE OF THE COVERED SOFTWARE IS WITH YOU. SHOULD ANY
|
||||||
|
COVERED SOFTWARE PROVE DEFECTIVE IN ANY RESPECT, YOU (NOT THE
|
||||||
|
INITIAL DEVELOPER OR ANY OTHER CONTRIBUTOR) ASSUME THE COST OF ANY
|
||||||
|
NECESSARY SERVICING, REPAIR OR CORRECTION. THIS DISCLAIMER OF
|
||||||
|
WARRANTY CONSTITUTES AN ESSENTIAL PART OF THIS LICENSE. NO USE OF
|
||||||
|
ANY COVERED SOFTWARE IS AUTHORIZED HEREUNDER EXCEPT UNDER THIS
|
||||||
|
DISCLAIMER.
|
||||||
|
|
||||||
|
6. TERMINATION.
|
||||||
|
|
||||||
|
6.1. This License and the rights granted hereunder will terminate
|
||||||
|
automatically if You fail to comply with terms herein and fail to
|
||||||
|
cure such breach within 30 days of becoming aware of the breach.
|
||||||
|
Provisions which, by their nature, must remain in effect beyond
|
||||||
|
the termination of this License shall survive.
|
||||||
|
|
||||||
|
6.2. If You assert a patent infringement claim (excluding
|
||||||
|
declaratory judgment actions) against Initial Developer or a
|
||||||
|
Contributor (the Initial Developer or Contributor against whom You
|
||||||
|
assert such claim is referred to as "Participant") alleging that
|
||||||
|
the Participant Software (meaning the Contributor Version where
|
||||||
|
the Participant is a Contributor or the Original Software where
|
||||||
|
the Participant is the Initial Developer) directly or indirectly
|
||||||
|
infringes any patent, then any and all rights granted directly or
|
||||||
|
indirectly to You by such Participant, the Initial Developer (if
|
||||||
|
the Initial Developer is not the Participant) and all Contributors
|
||||||
|
under Sections 2.1 and/or 2.2 of this License shall, upon 60 days
|
||||||
|
notice from Participant terminate prospectively and automatically
|
||||||
|
at the expiration of such 60 day notice period, unless if within
|
||||||
|
such 60 day period You withdraw Your claim with respect to the
|
||||||
|
Participant Software against such Participant either unilaterally
|
||||||
|
or pursuant to a written agreement with Participant.
|
||||||
|
|
||||||
|
6.3. In the event of termination under Sections 6.1 or 6.2 above,
|
||||||
|
all end user licenses that have been validly granted by You or any
|
||||||
|
distributor hereunder prior to termination (excluding licenses
|
||||||
|
granted to You by any distributor) shall survive termination.
|
||||||
|
|
||||||
|
7. LIMITATION OF LIABILITY.
|
||||||
|
|
||||||
|
UNDER NO CIRCUMSTANCES AND UNDER NO LEGAL THEORY, WHETHER TORT
|
||||||
|
(INCLUDING NEGLIGENCE), CONTRACT, OR OTHERWISE, SHALL YOU, THE
|
||||||
|
INITIAL DEVELOPER, ANY OTHER CONTRIBUTOR, OR ANY DISTRIBUTOR OF
|
||||||
|
COVERED SOFTWARE, OR ANY SUPPLIER OF ANY OF SUCH PARTIES, BE
|
||||||
|
LIABLE TO ANY PERSON FOR ANY INDIRECT, SPECIAL, INCIDENTAL, OR
|
||||||
|
CONSEQUENTIAL DAMAGES OF ANY CHARACTER INCLUDING, WITHOUT
|
||||||
|
LIMITATION, DAMAGES FOR LOST PROFITS, LOSS OF GOODWILL, WORK
|
||||||
|
STOPPAGE, COMPUTER FAILURE OR MALFUNCTION, OR ANY AND ALL OTHER
|
||||||
|
COMMERCIAL DAMAGES OR LOSSES, EVEN IF SUCH PARTY SHALL HAVE BEEN
|
||||||
|
INFORMED OF THE POSSIBILITY OF SUCH DAMAGES. THIS LIMITATION OF
|
||||||
|
LIABILITY SHALL NOT APPLY TO LIABILITY FOR DEATH OR PERSONAL
|
||||||
|
INJURY RESULTING FROM SUCH PARTY'S NEGLIGENCE TO THE EXTENT
|
||||||
|
APPLICABLE LAW PROHIBITS SUCH LIMITATION. SOME JURISDICTIONS DO
|
||||||
|
NOT ALLOW THE EXCLUSION OR LIMITATION OF INCIDENTAL OR
|
||||||
|
CONSEQUENTIAL DAMAGES, SO THIS EXCLUSION AND LIMITATION MAY NOT
|
||||||
|
APPLY TO YOU.
|
||||||
|
|
||||||
|
8. U.S. GOVERNMENT END USERS.
|
||||||
|
|
||||||
|
The Covered Software is a "commercial item," as that term is
|
||||||
|
defined in 48 C.F.R. 2.101 (Oct. 1995), consisting of "commercial
|
||||||
|
computer software" (as that term is defined at 48
|
||||||
|
C.F.R. 252.227-7014(a)(1)) and "commercial computer software
|
||||||
|
documentation" as such terms are used in 48 C.F.R. 12.212
|
||||||
|
(Sept. 1995). Consistent with 48 C.F.R. 12.212 and 48
|
||||||
|
C.F.R. 227.7202-1 through 227.7202-4 (June 1995), all
|
||||||
|
U.S. Government End Users acquire Covered Software with only those
|
||||||
|
rights set forth herein. This U.S. Government Rights clause is in
|
||||||
|
lieu of, and supersedes, any other FAR, DFAR, or other clause or
|
||||||
|
provision that addresses Government rights in computer software
|
||||||
|
under this License.
|
||||||
|
|
||||||
|
9. MISCELLANEOUS.
|
||||||
|
|
||||||
|
This License represents the complete agreement concerning subject
|
||||||
|
matter hereof. If any provision of this License is held to be
|
||||||
|
unenforceable, such provision shall be reformed only to the extent
|
||||||
|
necessary to make it enforceable. This License shall be governed
|
||||||
|
by the law of the jurisdiction specified in a notice contained
|
||||||
|
within the Original Software (except to the extent applicable law,
|
||||||
|
if any, provides otherwise), excluding such jurisdiction's
|
||||||
|
conflict-of-law provisions. Any litigation relating to this
|
||||||
|
License shall be subject to the jurisdiction of the courts located
|
||||||
|
in the jurisdiction and venue specified in a notice contained
|
||||||
|
within the Original Software, with the losing party responsible
|
||||||
|
for costs, including, without limitation, court costs and
|
||||||
|
reasonable attorneys' fees and expenses. The application of the
|
||||||
|
United Nations Convention on Contracts for the International Sale
|
||||||
|
of Goods is expressly excluded. Any law or regulation which
|
||||||
|
provides that the language of a contract shall be construed
|
||||||
|
against the drafter shall not apply to this License. You agree
|
||||||
|
that You alone are responsible for compliance with the United
|
||||||
|
States export administration regulations (and the export control
|
||||||
|
laws and regulation of any other countries) when You use,
|
||||||
|
distribute or otherwise make available any Covered Software.
|
||||||
|
|
||||||
|
10. RESPONSIBILITY FOR CLAIMS.
|
||||||
|
|
||||||
|
As between Initial Developer and the Contributors, each party is
|
||||||
|
responsible for claims and damages arising, directly or
|
||||||
|
indirectly, out of its utilization of rights under this License
|
||||||
|
and You agree to work with Initial Developer and Contributors to
|
||||||
|
distribute such responsibility on an equitable basis. Nothing
|
||||||
|
herein is intended or shall be deemed to constitute any admission
|
||||||
|
of liability.
|
||||||
|
|
||||||
|
--------------------------------------------------------------------
|
||||||
|
|
||||||
|
NOTICE PURSUANT TO SECTION 9 OF THE COMMON DEVELOPMENT AND
|
||||||
|
DISTRIBUTION LICENSE (CDDL)
|
||||||
|
|
||||||
|
For Covered Software in this distribution, this License shall
|
||||||
|
be governed by the laws of the State of California (excluding
|
||||||
|
conflict-of-law provisions).
|
||||||
|
|
||||||
|
Any litigation relating to this License shall be subject to the
|
||||||
|
jurisdiction of the Federal Courts of the Northern District of
|
||||||
|
California and the state courts of the State of California, with
|
||||||
|
venue lying in Santa Clara County, California.
|
||||||
181
build.sh
Executable file
181
build.sh
Executable file
@@ -0,0 +1,181 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Copyright 2026 Safronov Grigorii
|
||||||
|
#
|
||||||
|
# Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
#
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
# https://opensource.org/licenses/CDDL-1.0
|
||||||
|
#
|
||||||
|
# Универсальный скрипт сборки futriix для Linux и Illumos
|
||||||
|
# НЕ ТРЕБУЕТ GCC - использует CGO_ENABLED=0 для всех платформ
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "[BUILD] Building futriix database..."
|
||||||
|
|
||||||
|
# Определение ОС
|
||||||
|
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||||
|
|
||||||
|
# Цвета для вывода
|
||||||
|
if [ -t 1 ]; then
|
||||||
|
RED='\033[0;31m'
|
||||||
|
BOLD_RED='\033[1;31m'
|
||||||
|
BOLD_GREEN='\033[1;32m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
LIGHT_CYAN='\033[1;36m'
|
||||||
|
NC='\033[0m'
|
||||||
|
else
|
||||||
|
RED=''; BOLD_RED=''; BOLD_GREEN=''; GREEN=''; YELLOW=''; LIGHT_CYAN=''; NC=''
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Функции
|
||||||
|
error_msg() { echo -e "${BOLD_RED}[ERROR] $1${NC}"; }
|
||||||
|
success_msg() { echo -e "${BOLD_GREEN}[OK] $1${NC}"; }
|
||||||
|
info_msg() { echo -e "${LIGHT_CYAN}[INFO] $1${NC}"; }
|
||||||
|
warning_msg() { echo -e "${YELLOW}[WARN] $1${NC}"; }
|
||||||
|
print_separator() { echo "================================================"; }
|
||||||
|
|
||||||
|
# Проверка Go
|
||||||
|
check_go() {
|
||||||
|
info_msg "Checking Go installation..."
|
||||||
|
if ! command -v go >/dev/null 2>&1; then
|
||||||
|
error_msg "Go is not installed or not in PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
GO_VERSION=$(go version | awk '{print $3}')
|
||||||
|
success_msg "Go found: $GO_VERSION"
|
||||||
|
|
||||||
|
go env -w GOPROXY=https://proxy.golang.org,direct 2>/dev/null || true
|
||||||
|
info_msg "Go proxy: $(go env GOPROXY)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Подготовка vendor с видимым прогрессом
|
||||||
|
setup_vendor() {
|
||||||
|
info_msg "Checking vendor directory..."
|
||||||
|
|
||||||
|
NEED_VENDOR=false
|
||||||
|
|
||||||
|
if [ ! -d "vendor" ] || [ ! -f "vendor/modules.txt" ]; then
|
||||||
|
warning_msg "Vendor directory not found or incomplete"
|
||||||
|
NEED_VENDOR=true
|
||||||
|
elif [ "go.mod" -nt "vendor/modules.txt" ]; then
|
||||||
|
warning_msg "go.mod is newer than vendor directory"
|
||||||
|
NEED_VENDOR=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$NEED_VENDOR" = true ]; then
|
||||||
|
info_msg "Creating/updating vendor directory..."
|
||||||
|
echo ""
|
||||||
|
info_msg "Step 1/2: Downloading modules..."
|
||||||
|
go mod download -x 2>&1 | grep -E "^(GET|Downloading)" | head -20 || true
|
||||||
|
echo ""
|
||||||
|
info_msg "Step 2/2: Creating vendor directory..."
|
||||||
|
go mod vendor
|
||||||
|
success_msg "Vendor dependencies ready"
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
info_msg "Vendor directory is up to date"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Сборка статического бинарника
|
||||||
|
build_static() {
|
||||||
|
local target_os=$1
|
||||||
|
local target_arch="amd64"
|
||||||
|
local output_name="futriix-${target_os}"
|
||||||
|
local output_path="bin/${output_name}"
|
||||||
|
|
||||||
|
info_msg "Building for ${target_os} (static, no CGO)..."
|
||||||
|
|
||||||
|
export GOOS="${target_os}"
|
||||||
|
export GOARCH="${target_arch}"
|
||||||
|
export CGO_ENABLED=0
|
||||||
|
|
||||||
|
local build_tags=""
|
||||||
|
if [ "${target_os}" = "illumos" ] || [ "${target_os}" = "solaris" ]; then
|
||||||
|
build_tags="-tags=illumos"
|
||||||
|
output_name="futriix-illumos"
|
||||||
|
output_path="bin/futriix-illumos"
|
||||||
|
elif [ "${target_os}" = "linux" ]; then
|
||||||
|
output_name="futriix-linux"
|
||||||
|
output_path="bin/futriix-linux"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p bin
|
||||||
|
|
||||||
|
if go build -mod=vendor ${build_tags} -ldflags="-s -w" -o "${output_path}" ./cmd/futriis; then
|
||||||
|
success_msg "Build successful for ${target_os}: ${output_path}"
|
||||||
|
cp "${output_path}" "./${output_name}" 2>/dev/null || true
|
||||||
|
info_msg "Binary copied to: ./${output_name}"
|
||||||
|
|
||||||
|
SIZE=$(ls -lh "${output_path}" | awk '{print $5}')
|
||||||
|
info_msg "Binary size: ${SIZE}"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
error_msg "Build failed for ${target_os}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Основная функция
|
||||||
|
main() {
|
||||||
|
print_separator
|
||||||
|
echo -e "${GREEN}[BUILD] Building futriix database (static, no GCC required)${NC}"
|
||||||
|
print_separator
|
||||||
|
|
||||||
|
info_msg "Detected OS: ${OS}"
|
||||||
|
|
||||||
|
check_go
|
||||||
|
setup_vendor
|
||||||
|
|
||||||
|
print_separator
|
||||||
|
|
||||||
|
BUILD_SUCCESS=true
|
||||||
|
|
||||||
|
case "${OS}" in
|
||||||
|
linux)
|
||||||
|
if ! build_static "linux"; then
|
||||||
|
BUILD_SUCCESS=false
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
sunos|illumos)
|
||||||
|
if ! build_static "illumos"; then
|
||||||
|
BUILD_SUCCESS=false
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
darwin)
|
||||||
|
info_msg "Detected macOS, cross-compiling for Linux..."
|
||||||
|
if ! build_static "linux"; then
|
||||||
|
BUILD_SUCCESS=false
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
warning_msg "Unknown OS: ${OS}, building for Linux..."
|
||||||
|
if ! build_static "linux"; then
|
||||||
|
BUILD_SUCCESS=false
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
print_separator
|
||||||
|
|
||||||
|
if [ "$BUILD_SUCCESS" = true ]; then
|
||||||
|
success_msg "Build complete!"
|
||||||
|
echo ""
|
||||||
|
info_msg "To run the database:"
|
||||||
|
echo " ./futriix-linux # On Linux"
|
||||||
|
echo " ./futriix-illumos # On Illumos/OpenIndiana"
|
||||||
|
echo ""
|
||||||
|
info_msg "Binary location: bin/"
|
||||||
|
ls -lh bin/ 2>/dev/null || true
|
||||||
|
else
|
||||||
|
error_msg "Build failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
362
cmd/futriis/main.go
Normal file
362
cmd/futriis/main.go
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"futriis/internal/acl"
|
||||||
|
"futriis/internal/api"
|
||||||
|
"futriis/internal/cluster"
|
||||||
|
"futriis/internal/config"
|
||||||
|
"futriis/internal/log"
|
||||||
|
"futriis/internal/plugin"
|
||||||
|
"futriis/internal/repl"
|
||||||
|
"futriis/internal/storage"
|
||||||
|
"futriis/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
utils.SetColorEnabled(true)
|
||||||
|
|
||||||
|
logLevel := os.Getenv("LOG_LEVEL")
|
||||||
|
if logLevel == "" {
|
||||||
|
logLevel = "info"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := log.InitDefaultLogger("futriis/futriis.log", logLevel); err != nil {
|
||||||
|
fmt.Printf("Failed to initialize logger: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("Futriis DB starting...")
|
||||||
|
log.Infof("Log level: %s", logLevel)
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig("config.toml")
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to load config: " + err.Error())
|
||||||
|
utils.PrintError("Failed to load config: " + err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger, err := log.NewLogger(cfg.Log.LogFile, cfg.Log.LogLevel)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to initialize logger: " + err.Error())
|
||||||
|
utils.PrintError("Failed to initialize logger: " + err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer logger.Close()
|
||||||
|
logger.Info("futriix database starting...")
|
||||||
|
|
||||||
|
store := storage.NewStorage(cfg.Storage.PageSizeMB, logger)
|
||||||
|
|
||||||
|
storage.SetGlobalStorage(store)
|
||||||
|
|
||||||
|
if err := storage.InitTransactionManager("futriix.wal"); err != nil {
|
||||||
|
logger.Warn("Failed to initialize transaction manager: " + err.Error())
|
||||||
|
} else {
|
||||||
|
storage.SetTransactionLogger(logger)
|
||||||
|
logger.Info("Transaction manager initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация менеджера триггеров (функция не возвращает ошибку)
|
||||||
|
storage.InitTriggerManager(logger)
|
||||||
|
logger.Info("Trigger manager initialized")
|
||||||
|
|
||||||
|
aclManager := acl.NewACLManager()
|
||||||
|
logger.Info("ACL manager initialized")
|
||||||
|
|
||||||
|
// Исправлено: передаём store как второй аргумент
|
||||||
|
raftCoordinator, err := cluster.NewRaftCoordinator(cfg, store, logger)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to start Raft coordinator: " + err.Error())
|
||||||
|
utils.PrintError("Failed to start Raft coordinator: " + err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Cluster.Bootstrap || len(cfg.Cluster.Nodes) <= 1 {
|
||||||
|
maxRetriesLocal := 10
|
||||||
|
for i := 0; i < maxRetriesLocal; i++ {
|
||||||
|
if raftCoordinator.IsLeader() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
node := cluster.NewNode(cfg.Cluster.NodeIP, cfg.Cluster.NodePort, store, logger)
|
||||||
|
|
||||||
|
// Исправлено: объявляем переменную maxRetries здесь, чтобы она была доступна
|
||||||
|
maxRetries := 5
|
||||||
|
var registerErr error
|
||||||
|
for i := 0; i < maxRetries; i++ {
|
||||||
|
registerErr = raftCoordinator.RegisterNode(node)
|
||||||
|
if registerErr == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if i < maxRetries-1 {
|
||||||
|
logger.Warn(fmt.Sprintf("Failed to register node (attempt %d/%d): %v, retrying...", i+1, maxRetries, registerErr))
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if registerErr != nil {
|
||||||
|
logger.Error("Failed to register node: " + registerErr.Error())
|
||||||
|
utils.PrintError("Failed to register node: " + registerErr.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Исправлено: передаём отдельные параметры из конфигурации плагинов
|
||||||
|
pluginManager := plugin.NewPluginManager(
|
||||||
|
cfg.Plugins.ScriptDir,
|
||||||
|
logger,
|
||||||
|
store,
|
||||||
|
cfg.Plugins.Enabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
if cfg.Plugins.Enabled {
|
||||||
|
logger.Info(fmt.Sprintf("Plugin manager initialized, directory: %s", cfg.Plugins.ScriptDir))
|
||||||
|
|
||||||
|
plugins := pluginManager.ListPlugins()
|
||||||
|
for _, p := range plugins {
|
||||||
|
if err := pluginManager.StartPlugin(p.Name); err != nil {
|
||||||
|
logger.Warn(fmt.Sprintf("Failed to start plugin %s: %v", p.Name, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
httpPort := cfg.API.Port
|
||||||
|
httpServer := api.NewHTTPServer(httpPort, store, raftCoordinator, aclManager, logger)
|
||||||
|
go func() {
|
||||||
|
if err := httpServer.Start(); err != nil {
|
||||||
|
logger.Error("HTTP server error: " + err.Error())
|
||||||
|
utils.PrintError("HTTP server error: " + err.Error())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
logger.Info(fmt.Sprintf("HTTP API server started on port %d", httpPort))
|
||||||
|
|
||||||
|
webUIPort := cfg.WebUI.Port
|
||||||
|
if webUIPort == 0 {
|
||||||
|
webUIPort = 8081
|
||||||
|
}
|
||||||
|
webUI := api.NewWebUIServer(webUIPort, cfg.WebUI.Enabled, store, raftCoordinator, aclManager, logger)
|
||||||
|
go func() {
|
||||||
|
if err := webUI.Start(); err != nil && cfg.WebUI.Enabled {
|
||||||
|
logger.Error("Web UI error: " + err.Error())
|
||||||
|
utils.PrintError("Web UI error: " + err.Error())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if cfg.WebUI.Enabled {
|
||||||
|
logger.Info(fmt.Sprintf("Web UI started on port %d", webUIPort))
|
||||||
|
}
|
||||||
|
|
||||||
|
if raftCoordinator != nil {
|
||||||
|
logger.Info("Cluster features enabled: Pipeline Replication, Batch Commit, Dynamic Resharding, Joint Consensus")
|
||||||
|
logger.Info("Observability metrics and health checks available at /api/webui/metrics and /api/webui/health")
|
||||||
|
}
|
||||||
|
|
||||||
|
displayBanner(cfg.Cluster.Name, cfg.WebUI.Enabled, webUIPort, httpPort)
|
||||||
|
|
||||||
|
replInstance := repl.NewRepl(store, raftCoordinator, logger, cfg, aclManager, pluginManager)
|
||||||
|
|
||||||
|
// ========== GRACEFUL SHUTDOWN ==========
|
||||||
|
// Канал для сигналов завершения
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
||||||
|
|
||||||
|
// Запускаем graceful shutdown в отдельной горутине
|
||||||
|
go func() {
|
||||||
|
<-sigChan
|
||||||
|
utils.Println("\nReceived shutdown signal, starting graceful shutdown...")
|
||||||
|
logger.Info("Received shutdown signal, starting graceful shutdown...")
|
||||||
|
|
||||||
|
// 1. Останавливаем REPL
|
||||||
|
logger.Info("Stopping REPL...")
|
||||||
|
replInstance.Close()
|
||||||
|
logger.Info("REPL stopped")
|
||||||
|
|
||||||
|
// 2. Останавливаем HTTP сервер с таймаутом
|
||||||
|
logger.Info("Stopping HTTP server...")
|
||||||
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer shutdownCancel()
|
||||||
|
|
||||||
|
httpShutdownDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
if err := httpServer.Stop(); err != nil {
|
||||||
|
logger.Error(fmt.Sprintf("HTTP server stop error: %v", err))
|
||||||
|
}
|
||||||
|
close(httpShutdownDone)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-httpShutdownDone:
|
||||||
|
logger.Info("HTTP server stopped")
|
||||||
|
case <-shutdownCtx.Done():
|
||||||
|
logger.Warn("HTTP server shutdown timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Останавливаем WebUI
|
||||||
|
logger.Info("Stopping WebUI...")
|
||||||
|
webUIStopDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
if err := webUI.Stop(); err != nil {
|
||||||
|
logger.Error(fmt.Sprintf("WebUI stop error: %v", err))
|
||||||
|
}
|
||||||
|
close(webUIStopDone)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-webUIStopDone:
|
||||||
|
logger.Info("WebUI stopped")
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
logger.Warn("WebUI shutdown timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Останавливаем плагины
|
||||||
|
if cfg.Plugins.Enabled && pluginManager != nil {
|
||||||
|
logger.Info("Stopping plugins...")
|
||||||
|
plugins := pluginManager.ListPlugins()
|
||||||
|
pluginStopDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
for _, p := range plugins {
|
||||||
|
if err := pluginManager.StopPlugin(p.Name); err != nil {
|
||||||
|
logger.Warn(fmt.Sprintf("Failed to stop plugin %s: %v", p.Name, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close(pluginStopDone)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-pluginStopDone:
|
||||||
|
logger.Info("Plugins stopped")
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
logger.Warn("Plugins shutdown timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Сохраняем данные на диск
|
||||||
|
logger.Info("Persisting data to disk...")
|
||||||
|
persistDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
// Используем публичный метод GetPersistenceManager()
|
||||||
|
persistenceMgr := raftCoordinator.GetPersistenceManager()
|
||||||
|
if raftCoordinator != nil && persistenceMgr != nil {
|
||||||
|
if err := persistenceMgr.SaveAll(); err != nil {
|
||||||
|
logger.Error(fmt.Sprintf("Failed to save data: %v", err))
|
||||||
|
} else {
|
||||||
|
logger.Info("Data persisted to disk")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close(persistDone)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-persistDone:
|
||||||
|
logger.Info("Persistence completed")
|
||||||
|
case <-time.After(15 * time.Second):
|
||||||
|
logger.Warn("Persistence timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Останавливаем координатор
|
||||||
|
logger.Info("Stopping Raft coordinator...")
|
||||||
|
raftStopDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
raftCoordinator.Stop()
|
||||||
|
close(raftStopDone)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-raftStopDone:
|
||||||
|
logger.Info("Raft coordinator stopped")
|
||||||
|
case <-time.After(15 * time.Second):
|
||||||
|
logger.Warn("Raft coordinator shutdown timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Останавливаем узел
|
||||||
|
logger.Info("Stopping cluster node...")
|
||||||
|
nodeStopDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
node.Stop()
|
||||||
|
close(nodeStopDone)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-nodeStopDone:
|
||||||
|
logger.Info("Cluster node stopped")
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
logger.Warn("Node shutdown timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Синхронизируем и закрываем логгер
|
||||||
|
logger.Info("Finalizing logger...")
|
||||||
|
if err := logger.Sync(); err != nil {
|
||||||
|
fmt.Printf("Failed to sync logger: %v\n", err)
|
||||||
|
}
|
||||||
|
logger.Close()
|
||||||
|
|
||||||
|
utils.DisableColorMode()
|
||||||
|
fmt.Println("Futriis DB shutdown complete")
|
||||||
|
os.Exit(0)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Запускаем REPL (блокирующий вызов)
|
||||||
|
if err := replInstance.Run(); err != nil {
|
||||||
|
logger.Error("REPL error: " + err.Error())
|
||||||
|
utils.PrintError("REPL error: " + err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayBanner(clusterName string, webUIEnabled bool, webUIPort int, httpPort int) {
|
||||||
|
utils.Println("")
|
||||||
|
bannerLines := []string{
|
||||||
|
" futriix 3i²(by 02.04.2026) ",
|
||||||
|
" Distributed Document-Store in-memory database with support lua plugins ",
|
||||||
|
" Cluster status: enable (Raft consensus)",
|
||||||
|
" Cluster features: Pipeline Replication, Batch Commit, Dynamic Resharding",
|
||||||
|
" Cluster name: " + clusterName,
|
||||||
|
" HTTP API (for curl or wget utils only): http://localhost:" + fmt.Sprintf("%d", httpPort) + "/api/",
|
||||||
|
}
|
||||||
|
|
||||||
|
if webUIEnabled {
|
||||||
|
bannerLines = append(bannerLines, fmt.Sprintf(" Web UI: http://localhost:%d/", webUIPort))
|
||||||
|
bannerLines = append(bannerLines, " Observability endpoints: /api/webui/metrics, /api/webui/health")
|
||||||
|
}
|
||||||
|
|
||||||
|
bannerLines = append(bannerLines, []string{
|
||||||
|
" Type 'quit' or 'exit' to quit",
|
||||||
|
" Type 'status' to see cluster status",
|
||||||
|
" Type 'acl login <user> <pass>' to authenticate",
|
||||||
|
" Type 'plugin list' to see loaded plugins",
|
||||||
|
" Type 'cluster pipeline' to see pipeline stats",
|
||||||
|
" Type 'cluster reshard' to trigger manual resharding",
|
||||||
|
}...)
|
||||||
|
|
||||||
|
for _, line := range bannerLines {
|
||||||
|
utils.PrintInfo(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printJSON(data interface{}) {
|
||||||
|
jsonData, err := json.MarshalIndent(data, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
utils.PrintError("Failed to marshal JSON: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println(string(jsonData))
|
||||||
|
}
|
||||||
159
config.toml
Normal file
159
config.toml
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
#
|
||||||
|
# Copyright 2026 Grigorii Safronov
|
||||||
|
#
|
||||||
|
# Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
#
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
# https://opensource.org/licenses/CDDL-1.0
|
||||||
|
#
|
||||||
|
|
||||||
|
# Конфигурационный файл СУБД futriiX
|
||||||
|
|
||||||
|
[cluster]
|
||||||
|
name = "test_cluster"
|
||||||
|
node_ip = "192.168.0.103" # Укажите ваш реальный IP
|
||||||
|
node_port = 9876
|
||||||
|
raft_port = 9878
|
||||||
|
raft_data_dir = "raft_data"
|
||||||
|
bootstrap = true # Флаг для первого узла в кластере
|
||||||
|
nodes = [ # Список узлов кластера
|
||||||
|
"192.168.0.103:9878", # Текущий узел
|
||||||
|
# "192.168.0.104:9878", # Другие узлы кластера
|
||||||
|
# "192.168.0.105:9878",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Таймауты Raft
|
||||||
|
heartbeat_timeout_ms = 1000 # Интервал heartbeat от лидера
|
||||||
|
election_timeout_ms = 1000 # Таймаут до начала выборов
|
||||||
|
commit_timeout_ms = 500 # Таймаут коммита
|
||||||
|
|
||||||
|
# Snapshot настройки
|
||||||
|
snapshot_interval_min = 30 # Интервал создания снапшотов
|
||||||
|
snapshot_threshold = 1000 # Кол-во логов до снапшота
|
||||||
|
|
||||||
|
# Split-brain защита
|
||||||
|
split_brain_prevention = true # Включить защиту от split-brain
|
||||||
|
recovery_timeout_sec = 30 # Таймаут восстановления
|
||||||
|
|
||||||
|
# Региональные настройки
|
||||||
|
region = "default" # Регион узла
|
||||||
|
priority_zone = 0 # Приоритетная зона (0-9)
|
||||||
|
|
||||||
|
[storage]
|
||||||
|
page_size_mb = 64
|
||||||
|
max_collections = 100
|
||||||
|
max_documents_per_collection = 1000000
|
||||||
|
|
||||||
|
[repl]
|
||||||
|
prompt_color = "#00bfff"
|
||||||
|
history_size = 1000
|
||||||
|
|
||||||
|
[log]
|
||||||
|
log_file = "futriix.log"
|
||||||
|
log_level = "debug"
|
||||||
|
|
||||||
|
[api]
|
||||||
|
port = 8080 # Порт для HTTP API сервера
|
||||||
|
|
||||||
|
[replication]
|
||||||
|
enabled = false
|
||||||
|
master_master = false
|
||||||
|
sync_replication = false
|
||||||
|
replication_timeout_ms = 5000
|
||||||
|
|
||||||
|
[compression]
|
||||||
|
enabled = true # Включить сжатие данных
|
||||||
|
algorithm = "snappy" # Алгоритм: snappy, lz4, zstd
|
||||||
|
level = 3 # Уровень сжатия (1-9, зависит от алгоритма)
|
||||||
|
min_size = 1024 # Минимальный размер документа для сжатия (байт)
|
||||||
|
|
||||||
|
[webui]
|
||||||
|
enabled = true # Включить веб-интерфейс
|
||||||
|
port = 9080 # Порт для веб-интерфейса
|
||||||
|
theme = "dark" # Тема оформления (dark, light)
|
||||||
|
|
||||||
|
# ========== ПАРАМЕТРЫ ДЛЯ PERFORMANCE ==========
|
||||||
|
[performance]
|
||||||
|
enable_pipeline = true # Pipeline репликация
|
||||||
|
batch_size = 100 # Размер батча операций
|
||||||
|
read_from_follower = true # Чтение из реплик
|
||||||
|
max_connections = 1000 # Максимум соединений
|
||||||
|
|
||||||
|
# ========== ПАРАМЕТРЫ БЕЗОПАСНОСТИ ==========
|
||||||
|
[security]
|
||||||
|
enable_tls = false # Включить TLS
|
||||||
|
cert_file = "" # Путь к сертификату
|
||||||
|
key_file = "" # Путь к ключу
|
||||||
|
ca_file = "" # Путь к CA
|
||||||
|
min_version = "1.2" # Минимальная версия TLS
|
||||||
|
|
||||||
|
# ========== ПАРАМЕТРЫ МОНИТОРИНГА ==========
|
||||||
|
[monitoring]
|
||||||
|
enable_metrics = true # Включить Prometheus метрики
|
||||||
|
metrics_port = 9090 # Порт для метрик
|
||||||
|
enable_tracing = false # Включить распределённый трейсинг
|
||||||
|
trace_sample_rate = 0.01 # Частота сэмплирования (0-1)
|
||||||
|
|
||||||
|
# ========== ПАРАМЕТРЫ ВОССТАНОВЛЕНИЯ ==========
|
||||||
|
[recovery]
|
||||||
|
auto_rejoin = true # Автоматическое переподключение
|
||||||
|
max_retry_sec = 300 # Максимальное время повторов
|
||||||
|
data_replication_timeout_sec = 60 # Таймаут репликации данных
|
||||||
|
stale_read_timeout_sec = 30 # Таймаут для устаревших чтений
|
||||||
|
|
||||||
|
# ========== НАСТРОЙКИ WAL ==========
|
||||||
|
[wal]
|
||||||
|
segment_size_mb = 64 # Размер сегмента WAL (MB)
|
||||||
|
sync_interval_sec = 5 # Интервал синхронизации с диском
|
||||||
|
batch_size = 100 # Размер пакета записи
|
||||||
|
recovery_workers = 4 # Количество воркеров для parallel recovery
|
||||||
|
enabled = true # Включить WAL
|
||||||
|
async_recovery = true # Асинхронное восстановление
|
||||||
|
async_recovery_workers = 4 # Количество воркеров
|
||||||
|
async_recovery_buffer = 10000 # Размер буфера
|
||||||
|
|
||||||
|
|
||||||
|
# ========== НАСТРОЙКИ MVCC ==========
|
||||||
|
[mvcc]
|
||||||
|
max_versions_per_doc = 10 # Максимум версий документа (0 = отключено)
|
||||||
|
visibility_map_size = 1048576 # Размер карты видимости (1M)
|
||||||
|
prune_interval_min = 5 # Интервал очистки версий (минут)
|
||||||
|
retention_days = 7 # Дни хранения старых версий
|
||||||
|
read_cache_size = 10000 # Размер кэша чтения
|
||||||
|
read_cache_ttl_sec = 300 # TTL кэша (5 минут)
|
||||||
|
|
||||||
|
# ========== НАСТРОЙКИ ТРАНЗАКЦИЙ ==========
|
||||||
|
[transactions]
|
||||||
|
default_timeout_sec = 30 # Таймаут транзакции по умолчанию
|
||||||
|
deadlock_check_interval_sec = 1 # Интервал проверки дедлоков
|
||||||
|
max_savepoints_per_tx = 100 # Максимум точек сохранения
|
||||||
|
two_phase_commit_timeout_sec = 10 # Таймаут 2-phase commit
|
||||||
|
enabled = true # Включить поддержку транзакций
|
||||||
|
checkpoint_interval_sec = 300 # Интервал чекпоинтов (сек)
|
||||||
|
|
||||||
|
# ========== НАСТРОЙКИ ACL ==========
|
||||||
|
[acl]
|
||||||
|
max_denied_log_size = 10000 # Максимум записей в логе отказов
|
||||||
|
temporary_grant_ttl_hours = 24 # TTL временных разрешений
|
||||||
|
enable_role_hierarchy = true # Включить иерархию ролей
|
||||||
|
cache_ttl_sec = 60 # TTL кэша ACL
|
||||||
|
|
||||||
|
# ========== НАСТРОЙКИ ПЛАГИНОВ (расширенные) ==========
|
||||||
|
[plugins]
|
||||||
|
enabled = false # Включить плагины
|
||||||
|
script_dir = "plugins" # Директория плагинов
|
||||||
|
allow_list = ["print", "math", "string"]
|
||||||
|
max_lua_states = 100 # Максимум Lua-состояний
|
||||||
|
lua_state_ttl_sec = 600 # TTL неиспользуемого состояния (сек)
|
||||||
|
max_cpu_time_ms = 100 # Лимит CPU на плагин (ms)
|
||||||
|
max_memory_mb = 50 # Лимит памяти на плагин (MB)
|
||||||
|
max_execution_time_sec = 5 # Лимит времени выполнения
|
||||||
|
max_instructions = 1000000 # Лимит инструкций Lua
|
||||||
|
hot_reload_interval_sec = 30 # Интервал проверки изменений
|
||||||
|
max_event_log_size = 1000 # Максимум записей в логе плагина
|
||||||
|
load_timeout_sec = 10 # Таймаут загрузки плагина
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
12
futriis/.credentials
Normal file
12
futriis/.credentials
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"users": {
|
||||||
|
"admin": {
|
||||||
|
"password": "jGl25bVBBBW96Qi9Te4V37Fnqchz/Eu4qB9vKrRIqRg=",
|
||||||
|
"is_admin": true,
|
||||||
|
"created_at": 1780251271
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"version": "1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
28
go.mod
Normal file
28
go.mod
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
module futriis
|
||||||
|
|
||||||
|
go 1.26
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/BurntSushi/toml v1.6.0
|
||||||
|
github.com/andybalholm/brotli v1.1.0
|
||||||
|
github.com/fatih/color v1.19.0
|
||||||
|
github.com/golang/snappy v0.0.4
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/hashicorp/raft v1.7.3
|
||||||
|
github.com/klauspost/compress v1.18.0
|
||||||
|
github.com/vmihailenco/msgpack/v5 v5.4.1
|
||||||
|
github.com/yuin/gopher-lua v1.1.2
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/armon/go-metrics v0.4.1 // indirect
|
||||||
|
github.com/hashicorp/go-hclog v1.6.3 // indirect
|
||||||
|
github.com/hashicorp/go-immutable-radix v1.0.0 // indirect
|
||||||
|
github.com/hashicorp/go-metrics v0.5.4 // indirect
|
||||||
|
github.com/hashicorp/go-msgpack/v2 v2.1.5 // indirect
|
||||||
|
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||||
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
|
)
|
||||||
198
go.sum
Normal file
198
go.sum
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
|
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
|
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
|
||||||
|
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||||
|
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||||
|
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||||
|
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||||
|
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
|
||||||
|
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||||
|
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||||
|
github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA=
|
||||||
|
github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4=
|
||||||
|
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||||
|
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
|
||||||
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
|
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
|
||||||
|
github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
|
||||||
|
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||||
|
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
||||||
|
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||||
|
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||||
|
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
||||||
|
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||||
|
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||||
|
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||||
|
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||||
|
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||||
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||||
|
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||||
|
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
|
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
|
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||||
|
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
|
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
|
||||||
|
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||||
|
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||||
|
github.com/hashicorp/go-immutable-radix v1.0.0 h1:AKDB1HM5PWEA7i4nhcpwOrO2byshxBjXVn/J/3+z5/0=
|
||||||
|
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
||||||
|
github.com/hashicorp/go-metrics v0.5.4 h1:8mmPiIJkTPPEbAiV97IxdAGNdRdaWwVap1BU6elejKY=
|
||||||
|
github.com/hashicorp/go-metrics v0.5.4/go.mod h1:CG5yz4NZ/AI/aQt9Ucm/vdBnbh7fvmv4lxZ350i+QQI=
|
||||||
|
github.com/hashicorp/go-msgpack/v2 v2.1.5 h1:Ue879bPnutj/hXfmUk6s/jtIK90XxgiUIcXRl656T44=
|
||||||
|
github.com/hashicorp/go-msgpack/v2 v2.1.5/go.mod h1:bjCsRXpZ7NsJdk45PoCQnzRGDaK8TKm5ZnDI/9y3J4M=
|
||||||
|
github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
|
||||||
|
github.com/hashicorp/go-uuid v1.0.0 h1:RS8zrF7PhGwyNPOtxSClXXj9HA8feRnJzgnI1RJCSnM=
|
||||||
|
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
|
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
|
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
|
||||||
|
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||||
|
github.com/hashicorp/raft v1.7.3 h1:DxpEqZJysHN0wK+fviai5mFcSYsCkNpFUl1xpAW8Rbo=
|
||||||
|
github.com/hashicorp/raft v1.7.3/go.mod h1:DfvCGFxpAUPE0L4Uc8JLlTPtc3GzSbdH0MTJCLgnmJQ=
|
||||||
|
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
|
||||||
|
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||||
|
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||||
|
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||||
|
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||||
|
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||||
|
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
||||||
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
|
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
|
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
|
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||||
|
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||||
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||||
|
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||||
|
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||||
|
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||||
|
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||||
|
github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY=
|
||||||
|
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
||||||
|
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||||
|
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
|
||||||
|
github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
|
||||||
|
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
|
||||||
|
github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
|
||||||
|
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
||||||
|
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
|
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
|
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
|
||||||
|
github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
|
||||||
|
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
|
||||||
|
github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
|
||||||
|
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
||||||
|
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
|
||||||
|
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
|
||||||
|
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
|
||||||
|
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
|
||||||
|
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||||
|
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||||
|
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
|
||||||
|
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
|
||||||
|
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
||||||
|
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||||
|
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||||
|
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||||
|
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
|
||||||
|
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
|
||||||
|
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
|
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||||
|
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||||
|
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
|
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
498
internal/acl/manger.go
Normal file
498
internal/acl/manger.go
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/acl/manager.go
|
||||||
|
// Назначение: Глобальный менеджер ACL для всей СУБД.
|
||||||
|
// Управляет пользователями, ролями и разрешениями на уровне БД и коллекций.
|
||||||
|
// Реализован с использованием sync.Map для wait-free доступа.
|
||||||
|
|
||||||
|
package acl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PermissionType определяет тип разрешения
|
||||||
|
type PermissionType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PermRead PermissionType = "read"
|
||||||
|
PermWrite PermissionType = "write"
|
||||||
|
PermDelete PermissionType = "delete"
|
||||||
|
PermAdmin PermissionType = "admin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User представляет пользователя системы
|
||||||
|
type User struct {
|
||||||
|
ID string `msgpack:"id"`
|
||||||
|
Username string `msgpack:"username"`
|
||||||
|
Password string `msgpack:"password"` // В реальной системе - хеш
|
||||||
|
Roles []string `msgpack:"roles"`
|
||||||
|
CreatedAt int64 `msgpack:"created_at"`
|
||||||
|
LastLogin int64 `msgpack:"last_login"`
|
||||||
|
Active bool `msgpack:"active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session представляет активную сессию пользователя
|
||||||
|
type Session struct {
|
||||||
|
ID string `msgpack:"id"`
|
||||||
|
Username string `msgpack:"username"`
|
||||||
|
Roles []string `msgpack:"roles"`
|
||||||
|
CreatedAt int64 `msgpack:"created_at"`
|
||||||
|
ExpiresAt int64 `msgpack:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role представляет роль с набором разрешений
|
||||||
|
type Role struct {
|
||||||
|
Name string `msgpack:"name"`
|
||||||
|
Permissions []string `msgpack:"permissions"` // "database.collection:read" формат
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACLManager управляет доступом к БД
|
||||||
|
type ACLManager struct {
|
||||||
|
users sync.Map // map[string]*User
|
||||||
|
roles sync.Map // map[string]*Role
|
||||||
|
sessions sync.Map // map[string]*Session - sessionID -> Session
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewACLManager создаёт новый менеджер ACL
|
||||||
|
func NewACLManager() *ACLManager {
|
||||||
|
m := &ACLManager{}
|
||||||
|
|
||||||
|
// Создаём роль администратора по умолчанию
|
||||||
|
adminRole := &Role{
|
||||||
|
Name: "admin",
|
||||||
|
Permissions: []string{"*:*"},
|
||||||
|
}
|
||||||
|
m.roles.Store("admin", adminRole)
|
||||||
|
|
||||||
|
// Создаём пользователя admin по умолчанию
|
||||||
|
adminUser := &User{
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
Username: "admin",
|
||||||
|
Password: "admin", // В продакшене использовать хеш!
|
||||||
|
Roles: []string{"admin"},
|
||||||
|
CreatedAt: time.Now().UnixMilli(),
|
||||||
|
Active: true,
|
||||||
|
}
|
||||||
|
m.users.Store("admin", adminUser)
|
||||||
|
|
||||||
|
// Создаём роль guest с ограниченными правами
|
||||||
|
guestRole := &Role{
|
||||||
|
Name: "guest",
|
||||||
|
Permissions: []string{},
|
||||||
|
}
|
||||||
|
m.roles.Store("guest", guestRole)
|
||||||
|
|
||||||
|
// Создаём пользователя guest по умолчанию
|
||||||
|
guestUser := &User{
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
Username: "guest",
|
||||||
|
Password: "guest",
|
||||||
|
Roles: []string{"guest"},
|
||||||
|
CreatedAt: time.Now().UnixMilli(),
|
||||||
|
Active: true,
|
||||||
|
}
|
||||||
|
m.users.Store("guest", guestUser)
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUser создаёт нового пользователя
|
||||||
|
func (m *ACLManager) CreateUser(username, password string, roles []string) error {
|
||||||
|
if _, exists := m.users.Load(username); exists {
|
||||||
|
return fmt.Errorf("user %s already exists", username)
|
||||||
|
}
|
||||||
|
|
||||||
|
user := &User{
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
Username: username,
|
||||||
|
Password: password,
|
||||||
|
Roles: roles,
|
||||||
|
CreatedAt: time.Now().UnixMilli(),
|
||||||
|
Active: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
m.users.Store(username, user)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate аутентифицирует пользователя и создаёт сессию
|
||||||
|
func (m *ACLManager) Authenticate(username, password string) (string, error) {
|
||||||
|
val, ok := m.users.Load(username)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := val.(*User)
|
||||||
|
if !user.Active {
|
||||||
|
return "", fmt.Errorf("user is disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Password != password {
|
||||||
|
return "", fmt.Errorf("invalid password")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем время последнего входа
|
||||||
|
user.LastLogin = time.Now().UnixMilli()
|
||||||
|
m.users.Store(username, user)
|
||||||
|
|
||||||
|
// Создаём сессию (24 часа)
|
||||||
|
sessionID := uuid.New().String()
|
||||||
|
now := time.Now().Unix()
|
||||||
|
session := &Session{
|
||||||
|
ID: sessionID,
|
||||||
|
Username: username,
|
||||||
|
Roles: user.Roles,
|
||||||
|
CreatedAt: now,
|
||||||
|
ExpiresAt: now + 86400, // 24 часа
|
||||||
|
}
|
||||||
|
m.sessions.Store(sessionID, session)
|
||||||
|
|
||||||
|
return sessionID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout завершает сессию
|
||||||
|
func (m *ACLManager) Logout(sessionID string) {
|
||||||
|
m.sessions.Delete(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckSession проверяет, активна ли сессия
|
||||||
|
func (m *ACLManager) CheckSession(sessionID string) bool {
|
||||||
|
val, ok := m.sessions.Load(sessionID)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
session := val.(*Session)
|
||||||
|
// Проверка на expiry
|
||||||
|
if time.Now().Unix() > session.ExpiresAt {
|
||||||
|
m.sessions.Delete(sessionID)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUsername возвращает имя пользователя по ID сессии
|
||||||
|
func (m *ACLManager) GetUsername(sessionID string) string {
|
||||||
|
val, ok := m.sessions.Load(sessionID)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
session := val.(*Session)
|
||||||
|
return session.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserRoles возвращает роли пользователя по ID сессии
|
||||||
|
func (m *ACLManager) GetUserRoles(sessionID string) []string {
|
||||||
|
val, ok := m.sessions.Load(sessionID)
|
||||||
|
if !ok {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
session := val.(*Session)
|
||||||
|
return session.Roles
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckPermission проверяет разрешение для сессии
|
||||||
|
func (m *ACLManager) CheckPermission(sessionID, database, collection, operation string) bool {
|
||||||
|
val, ok := m.sessions.Load(sessionID)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
session := val.(*Session)
|
||||||
|
|
||||||
|
for _, roleName := range session.Roles {
|
||||||
|
roleVal, ok := m.roles.Load(roleName)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
role := roleVal.(*Role)
|
||||||
|
for _, perm := range role.Permissions {
|
||||||
|
if m.matchPermission(perm, database, collection, operation) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchPermission проверяет соответствие разрешения
|
||||||
|
func (m *ACLManager) matchPermission(perm, database, collection, operation string) bool {
|
||||||
|
// Формат: "database.collection:operation" или "*:*" для всех
|
||||||
|
// или "database.*:read" для всех коллекций в БД
|
||||||
|
|
||||||
|
parts := splitPermission(perm)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
resource := parts[0] // "database.collection" или "database.*"
|
||||||
|
op := parts[1] // "read", "write", "delete", "admin"
|
||||||
|
|
||||||
|
// Проверка операции
|
||||||
|
if op != "*" && op != operation && operation != "admin" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Администратор имеет доступ ко всему
|
||||||
|
if op == "admin" || op == "*" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка ресурса
|
||||||
|
if resource == "*:*" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
resourceParts := splitResource(resource)
|
||||||
|
if len(resourceParts) != 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
dbPattern := resourceParts[0]
|
||||||
|
collPattern := resourceParts[1]
|
||||||
|
|
||||||
|
if dbPattern != "*" && dbPattern != database {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if collPattern != "*" && collPattern != collection {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GrantPermission выдаёт разрешение роли
|
||||||
|
func (m *ACLManager) GrantPermission(roleName, permission string) error {
|
||||||
|
val, ok := m.roles.Load(roleName)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("role not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
role := val.(*Role)
|
||||||
|
role.Permissions = append(role.Permissions, permission)
|
||||||
|
m.roles.Store(roleName, role)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokePermission отзывает разрешение у роли
|
||||||
|
func (m *ACLManager) RevokePermission(roleName, permission string) error {
|
||||||
|
val, ok := m.roles.Load(roleName)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("role not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
role := val.(*Role)
|
||||||
|
newPermissions := make([]string, 0, len(role.Permissions))
|
||||||
|
for _, p := range role.Permissions {
|
||||||
|
if p != permission {
|
||||||
|
newPermissions = append(newPermissions, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
role.Permissions = newPermissions
|
||||||
|
m.roles.Store(roleName, role)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRole создаёт новую роль
|
||||||
|
func (m *ACLManager) CreateRole(name string) error {
|
||||||
|
if _, exists := m.roles.Load(name); exists {
|
||||||
|
return fmt.Errorf("role %s already exists", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
role := &Role{
|
||||||
|
Name: name,
|
||||||
|
Permissions: []string{},
|
||||||
|
}
|
||||||
|
|
||||||
|
m.roles.Store(name, role)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRole удаляет роль
|
||||||
|
func (m *ACLManager) DeleteRole(name string) error {
|
||||||
|
if _, exists := m.roles.LoadAndDelete(name); !exists {
|
||||||
|
return fmt.Errorf("role not found")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddUserRole добавляет роль пользователю
|
||||||
|
func (m *ACLManager) AddUserRole(username, roleName string) error {
|
||||||
|
val, ok := m.users.Load(username)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := val.(*User)
|
||||||
|
// Проверяем, есть ли уже такая роль
|
||||||
|
for _, r := range user.Roles {
|
||||||
|
if r == roleName {
|
||||||
|
return fmt.Errorf("user already has role %s", roleName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user.Roles = append(user.Roles, roleName)
|
||||||
|
m.users.Store(username, user)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveUserRole удаляет роль у пользователя
|
||||||
|
func (m *ACLManager) RemoveUserRole(username, roleName string) error {
|
||||||
|
val, ok := m.users.Load(username)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := val.(*User)
|
||||||
|
newRoles := make([]string, 0, len(user.Roles))
|
||||||
|
for _, r := range user.Roles {
|
||||||
|
if r != roleName {
|
||||||
|
newRoles = append(newRoles, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user.Roles = newRoles
|
||||||
|
m.users.Store(username, user)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisableUser отключает пользователя
|
||||||
|
func (m *ACLManager) DisableUser(username string) error {
|
||||||
|
val, ok := m.users.Load(username)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := val.(*User)
|
||||||
|
user.Active = false
|
||||||
|
m.users.Store(username, user)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnableUser включает пользователя
|
||||||
|
func (m *ACLManager) EnableUser(username string) error {
|
||||||
|
val, ok := m.users.Load(username)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := val.(*User)
|
||||||
|
user.Active = true
|
||||||
|
m.users.Store(username, user)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser удаляет пользователя
|
||||||
|
func (m *ACLManager) DeleteUser(username string) error {
|
||||||
|
if _, exists := m.users.LoadAndDelete(username); !exists {
|
||||||
|
return fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword изменяет пароль пользователя
|
||||||
|
func (m *ACLManager) ChangePassword(username, newPassword string) error {
|
||||||
|
val, ok := m.users.Load(username)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := val.(*User)
|
||||||
|
user.Password = newPassword
|
||||||
|
m.users.Store(username, user)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers возвращает список всех пользователей
|
||||||
|
func (m *ACLManager) ListUsers() []string {
|
||||||
|
users := make([]string, 0)
|
||||||
|
m.users.Range(func(key, value interface{}) bool {
|
||||||
|
users = append(users, key.(string))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return users
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRoles возвращает список всех ролей
|
||||||
|
func (m *ACLManager) ListRoles() []string {
|
||||||
|
roles := make([]string, 0)
|
||||||
|
m.roles.Range(func(key, value interface{}) bool {
|
||||||
|
roles = append(roles, key.(string))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return roles
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserInfo возвращает информацию о пользователе
|
||||||
|
func (m *ACLManager) GetUserInfo(username string) (*User, error) {
|
||||||
|
val, ok := m.users.Load(username)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := val.(*User)
|
||||||
|
// Возвращаем копию, чтобы избежать модификации извне
|
||||||
|
return &User{
|
||||||
|
ID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Roles: user.Roles,
|
||||||
|
CreatedAt: user.CreatedAt,
|
||||||
|
LastLogin: user.LastLogin,
|
||||||
|
Active: user.Active,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRolePermissions возвращает разрешения роли
|
||||||
|
func (m *ACLManager) GetRolePermissions(roleName string) ([]string, error) {
|
||||||
|
val, ok := m.roles.Load(roleName)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("role not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
role := val.(*Role)
|
||||||
|
return role.Permissions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
func splitPermission(perm string) []string {
|
||||||
|
for i := 0; i < len(perm); i++ {
|
||||||
|
if perm[i] == ':' {
|
||||||
|
return []string{perm[:i], perm[i+1:]}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []string{perm, ""}
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitResource(resource string) []string {
|
||||||
|
for i := 0; i < len(resource); i++ {
|
||||||
|
if resource[i] == '.' {
|
||||||
|
return []string{resource[:i], resource[i+1:]}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []string{resource, "*"}
|
||||||
|
}
|
||||||
1730
internal/api/http.go
Normal file
1730
internal/api/http.go
Normal file
File diff suppressed because it is too large
Load Diff
204
internal/api/rate_limiter.go
Normal file
204
internal/api/rate_limiter.go
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/api/rate_limiter.go (НОВЫЙ ФАЙЛ)
|
||||||
|
// Назначение: Rate limiting для API
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RateLimiterConfig содержит конфигурацию rate limiter'а
|
||||||
|
type RateLimiterConfig struct {
|
||||||
|
RequestsPerSecond int
|
||||||
|
BurstSize int
|
||||||
|
CleanupInterval time.Duration
|
||||||
|
MaxEntries int
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultRateLimiterConfig возвращает конфигурацию по умолчанию
|
||||||
|
func DefaultRateLimiterConfig() *RateLimiterConfig {
|
||||||
|
return &RateLimiterConfig{
|
||||||
|
RequestsPerSecond: 100,
|
||||||
|
BurstSize: 20,
|
||||||
|
CleanupInterval: 5 * time.Minute,
|
||||||
|
MaxEntries: 10000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenBucket реализует алгоритм token bucket
|
||||||
|
type TokenBucket struct {
|
||||||
|
tokens float64
|
||||||
|
maxTokens float64
|
||||||
|
refillRate float64
|
||||||
|
lastRefill time.Time
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTokenBucket создаёт новый token bucket
|
||||||
|
func NewTokenBucket(ratePerSecond float64, burst int) *TokenBucket {
|
||||||
|
return &TokenBucket{
|
||||||
|
tokens: float64(burst),
|
||||||
|
maxTokens: float64(burst),
|
||||||
|
refillRate: ratePerSecond,
|
||||||
|
lastRefill: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow проверяет, разрешён ли запрос
|
||||||
|
func (tb *TokenBucket) Allow() bool {
|
||||||
|
tb.mu.Lock()
|
||||||
|
defer tb.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
elapsed := now.Sub(tb.lastRefill).Seconds()
|
||||||
|
tb.tokens += elapsed * tb.refillRate
|
||||||
|
if tb.tokens > tb.maxTokens {
|
||||||
|
tb.tokens = tb.maxTokens
|
||||||
|
}
|
||||||
|
tb.lastRefill = now
|
||||||
|
|
||||||
|
if tb.tokens >= 1.0 {
|
||||||
|
tb.tokens -= 1.0
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// RateLimiter управляет rate limiting для API
|
||||||
|
type RateLimiter struct {
|
||||||
|
config *RateLimiterConfig
|
||||||
|
limiters sync.Map
|
||||||
|
rejected atomic.Uint64
|
||||||
|
allowed atomic.Uint64
|
||||||
|
stopChan chan struct{}
|
||||||
|
wg sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRateLimiter создаёт новый rate limiter
|
||||||
|
func NewRateLimiter(config *RateLimiterConfig) *RateLimiter {
|
||||||
|
if config == nil {
|
||||||
|
config = DefaultRateLimiterConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
rl := &RateLimiter{
|
||||||
|
config: config,
|
||||||
|
stopChan: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
rl.wg.Add(1)
|
||||||
|
go rl.cleanupLoop()
|
||||||
|
|
||||||
|
return rl
|
||||||
|
}
|
||||||
|
|
||||||
|
// getLimiter возвращает или создаёт лимитер для ключа
|
||||||
|
func (rl *RateLimiter) getLimiter(key string) *TokenBucket {
|
||||||
|
if val, ok := rl.limiters.Load(key); ok {
|
||||||
|
return val.(*TokenBucket)
|
||||||
|
}
|
||||||
|
|
||||||
|
limiter := NewTokenBucket(float64(rl.config.RequestsPerSecond), rl.config.BurstSize)
|
||||||
|
actual, _ := rl.limiters.LoadOrStore(key, limiter)
|
||||||
|
return actual.(*TokenBucket)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow проверяет, разрешён ли запрос для ключа
|
||||||
|
func (rl *RateLimiter) Allow(key string) bool {
|
||||||
|
limiter := rl.getLimiter(key)
|
||||||
|
allowed := limiter.Allow()
|
||||||
|
|
||||||
|
if allowed {
|
||||||
|
rl.allowed.Add(1)
|
||||||
|
} else {
|
||||||
|
rl.rejected.Add(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return allowed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware возвращает middleware для HTTP
|
||||||
|
func (rl *RateLimiter) Middleware(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Используем IP + User-Agent как ключ
|
||||||
|
key := r.RemoteAddr
|
||||||
|
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
||||||
|
key = forwarded
|
||||||
|
}
|
||||||
|
|
||||||
|
if !rl.Allow(key) {
|
||||||
|
w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%d", rl.config.RequestsPerSecond))
|
||||||
|
w.Header().Set("X-RateLimit-Remaining", "0")
|
||||||
|
w.Header().Set("Retry-After", "1")
|
||||||
|
http.Error(w, "Rate limit exceeded. Please try again later.", http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupLoop периодически очищает старые лимитеры
|
||||||
|
func (rl *RateLimiter) cleanupLoop() {
|
||||||
|
defer rl.wg.Done()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(rl.config.CleanupInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
rl.cleanup()
|
||||||
|
case <-rl.stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanup удаляет старые лимитеры
|
||||||
|
func (rl *RateLimiter) cleanup() {
|
||||||
|
count := 0
|
||||||
|
rl.limiters.Range(func(key, value interface{}) bool {
|
||||||
|
count++
|
||||||
|
if count > rl.config.MaxEntries {
|
||||||
|
rl.limiters.Delete(key)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStats возвращает статистику rate limiter'а
|
||||||
|
func (rl *RateLimiter) GetStats() map[string]interface{} {
|
||||||
|
count := 0
|
||||||
|
rl.limiters.Range(func(key, value interface{}) bool {
|
||||||
|
count++
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"active_limiters": count,
|
||||||
|
"allowed_requests": rl.allowed.Load(),
|
||||||
|
"rejected_requests": rl.rejected.Load(),
|
||||||
|
"requests_per_second": rl.config.RequestsPerSecond,
|
||||||
|
"burst_size": rl.config.BurstSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop останавливает rate limiter
|
||||||
|
func (rl *RateLimiter) Stop() {
|
||||||
|
close(rl.stopChan)
|
||||||
|
rl.wg.Wait()
|
||||||
|
}
|
||||||
1413
internal/api/static/app.js
Normal file
1413
internal/api/static/app.js
Normal file
File diff suppressed because it is too large
Load Diff
1572
internal/api/static/style.css
Normal file
1572
internal/api/static/style.css
Normal file
File diff suppressed because it is too large
Load Diff
3113
internal/api/webui.go
Normal file
3113
internal/api/webui.go
Normal file
File diff suppressed because it is too large
Load Diff
425
internal/api/webui_credentials.go
Normal file
425
internal/api/webui_credentials.go
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/api/webui_credentials.go
|
||||||
|
// Назначение: Управление учётными данными для веб-интерфейса
|
||||||
|
// Хранит логин/пароль в скрытом файле .credentials в директории futriis
|
||||||
|
// Поддерживает множественных пользователей-администраторов
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserCredential представляет учётные данные одного пользователя
|
||||||
|
type UserCredential struct {
|
||||||
|
Password string `json:"password"` // хранится в виде хеша
|
||||||
|
Avatar string `json:"avatar,omitempty"` // base64 encoded avatar
|
||||||
|
IsAdmin bool `json:"is_admin"` // является ли администратором
|
||||||
|
CreatedAt int64 `json:"created_at"` // время создания
|
||||||
|
LastLogin int64 `json:"last_login,omitempty"` // время последнего входа
|
||||||
|
}
|
||||||
|
|
||||||
|
// Credentials структура для хранения всех учётных данных
|
||||||
|
type Credentials struct {
|
||||||
|
Users map[string]*UserCredential `json:"users"`
|
||||||
|
Settings map[string]interface{} `json:"settings,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CredentialManager управляет учётными данными веб-интерфейса
|
||||||
|
type CredentialManager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
credentials *Credentials
|
||||||
|
credFile string
|
||||||
|
currentUser string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCredentialManager создаёт новый менеджер учётных данных
|
||||||
|
func NewCredentialManager() *CredentialManager {
|
||||||
|
// Определяем путь к директории futriis
|
||||||
|
execPath, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
execPath = "."
|
||||||
|
}
|
||||||
|
futriisDir := filepath.Dir(execPath)
|
||||||
|
|
||||||
|
// Ищем директорию futriis (поднимаемся вверх, если нужно)
|
||||||
|
for {
|
||||||
|
if _, err := os.Stat(filepath.Join(futriisDir, "futriis")); err == nil {
|
||||||
|
futriisDir = filepath.Join(futriisDir, "futriis")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(futriisDir)
|
||||||
|
if parent == futriisDir {
|
||||||
|
// Не нашли директорию futriis, создаём в текущей
|
||||||
|
futriisDir = filepath.Join(execPath, "futriis")
|
||||||
|
os.MkdirAll(futriisDir, 0700)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
futriisDir = parent
|
||||||
|
}
|
||||||
|
|
||||||
|
credFile := filepath.Join(futriisDir, ".credentials")
|
||||||
|
|
||||||
|
return &CredentialManager{
|
||||||
|
credentials: &Credentials{
|
||||||
|
Users: make(map[string]*UserCredential),
|
||||||
|
Settings: make(map[string]interface{}),
|
||||||
|
},
|
||||||
|
credFile: credFile,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hashPassword создаёт хеш пароля
|
||||||
|
func (cm *CredentialManager) hashPassword(password string) string {
|
||||||
|
hash := sha256.Sum256([]byte(password))
|
||||||
|
return base64.StdEncoding.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load загружает учётные данные из файла
|
||||||
|
func (cm *CredentialManager) Load() error {
|
||||||
|
cm.mu.Lock()
|
||||||
|
defer cm.mu.Unlock()
|
||||||
|
|
||||||
|
data, err := os.ReadFile(cm.credFile)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return cm.createDefaultFile()
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var creds Credentials
|
||||||
|
if err := json.Unmarshal(data, &creds); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cm.credentials = &creds
|
||||||
|
if cm.credentials.Users == nil {
|
||||||
|
cm.credentials.Users = make(map[string]*UserCredential)
|
||||||
|
}
|
||||||
|
if cm.credentials.Settings == nil {
|
||||||
|
cm.credentials.Settings = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createDefaultFile создаёт файл с учётными данными по умолчанию
|
||||||
|
func (cm *CredentialManager) createDefaultFile() error {
|
||||||
|
// Создаём пользователя admin по умолчанию
|
||||||
|
cm.credentials.Users["admin"] = &UserCredential{
|
||||||
|
Password: cm.hashPassword("admin"),
|
||||||
|
IsAdmin: true,
|
||||||
|
CreatedAt: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
cm.credentials.Settings["version"] = "1.0"
|
||||||
|
|
||||||
|
return cm.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDefault создаёт учётные данные по умолчанию (если файл не существует)
|
||||||
|
func (cm *CredentialManager) CreateDefault() error {
|
||||||
|
cm.mu.Lock()
|
||||||
|
defer cm.mu.Unlock()
|
||||||
|
|
||||||
|
// Проверяем, существует ли файл
|
||||||
|
if _, err := os.Stat(cm.credFile); err == nil {
|
||||||
|
return nil // файл уже существует
|
||||||
|
}
|
||||||
|
|
||||||
|
cm.credentials.Users["admin"] = &UserCredential{
|
||||||
|
Password: cm.hashPassword("admin"),
|
||||||
|
IsAdmin: true,
|
||||||
|
CreatedAt: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
cm.credentials.Settings["version"] = "1.0"
|
||||||
|
|
||||||
|
return cm.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// save сохраняет учётные данные в файл
|
||||||
|
func (cm *CredentialManager) save() error {
|
||||||
|
data, err := json.MarshalIndent(cm.credentials, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Устанавливаем права доступа 0600 (только владелец может читать/писать)
|
||||||
|
return os.WriteFile(cm.credFile, data, 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate проверяет учётные данные
|
||||||
|
func (cm *CredentialManager) Validate(username, password string) bool {
|
||||||
|
cm.mu.RLock()
|
||||||
|
defer cm.mu.RUnlock()
|
||||||
|
|
||||||
|
if cm.credentials == nil || cm.credentials.Users == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
user, exists := cm.credentials.Users[username]
|
||||||
|
if !exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.Password == cm.hashPassword(password)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAdmin проверяет, является ли пользователь администратором
|
||||||
|
func (cm *CredentialManager) IsAdmin(username string) bool {
|
||||||
|
cm.mu.RLock()
|
||||||
|
defer cm.mu.RUnlock()
|
||||||
|
|
||||||
|
if cm.credentials == nil || cm.credentials.Users == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
user, exists := cm.credentials.Users[username]
|
||||||
|
if !exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.IsAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUser создаёт нового пользователя (только для администраторов)
|
||||||
|
func (cm *CredentialManager) CreateUser(username, password string, isAdmin bool) error {
|
||||||
|
if username == "" {
|
||||||
|
return fmt.Errorf("username cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(password) < 4 {
|
||||||
|
return fmt.Errorf("password must be at least 4 characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
cm.mu.Lock()
|
||||||
|
defer cm.mu.Unlock()
|
||||||
|
|
||||||
|
if cm.credentials == nil {
|
||||||
|
cm.credentials = &Credentials{
|
||||||
|
Users: make(map[string]*UserCredential),
|
||||||
|
Settings: make(map[string]interface{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := cm.credentials.Users[username]; exists {
|
||||||
|
return fmt.Errorf("user %s already exists", username)
|
||||||
|
}
|
||||||
|
|
||||||
|
cm.credentials.Users[username] = &UserCredential{
|
||||||
|
Password: cm.hashPassword(password),
|
||||||
|
IsAdmin: isAdmin,
|
||||||
|
CreatedAt: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return cm.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser удаляет пользователя (только для администраторов)
|
||||||
|
func (cm *CredentialManager) DeleteUser(username string) error {
|
||||||
|
if username == "admin" {
|
||||||
|
return fmt.Errorf("cannot delete default admin user")
|
||||||
|
}
|
||||||
|
|
||||||
|
cm.mu.Lock()
|
||||||
|
defer cm.mu.Unlock()
|
||||||
|
|
||||||
|
if cm.credentials == nil || cm.credentials.Users == nil {
|
||||||
|
return fmt.Errorf("no users found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := cm.credentials.Users[username]; !exists {
|
||||||
|
return fmt.Errorf("user %s not found", username)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(cm.credentials.Users, username)
|
||||||
|
return cm.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers возвращает список всех пользователей (только для администраторов)
|
||||||
|
func (cm *CredentialManager) ListUsers() []map[string]interface{} {
|
||||||
|
cm.mu.RLock()
|
||||||
|
defer cm.mu.RUnlock()
|
||||||
|
|
||||||
|
users := make([]map[string]interface{}, 0)
|
||||||
|
|
||||||
|
for username, user := range cm.credentials.Users {
|
||||||
|
users = append(users, map[string]interface{}{
|
||||||
|
"username": username,
|
||||||
|
"is_admin": user.IsAdmin,
|
||||||
|
"created_at": user.CreatedAt,
|
||||||
|
"last_login": user.LastLogin,
|
||||||
|
"has_avatar": user.Avatar != "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return users
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword изменяет пароль пользователя
|
||||||
|
func (cm *CredentialManager) ChangePassword(username, currentPassword, newPassword string) error {
|
||||||
|
cm.mu.Lock()
|
||||||
|
defer cm.mu.Unlock()
|
||||||
|
|
||||||
|
if cm.credentials == nil || cm.credentials.Users == nil {
|
||||||
|
return fmt.Errorf("credentials not loaded")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, exists := cm.credentials.Users[username]
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Password != cm.hashPassword(currentPassword) {
|
||||||
|
return fmt.Errorf("current password is incorrect")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(newPassword) < 4 {
|
||||||
|
return fmt.Errorf("new password must be at least 4 characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Password = cm.hashPassword(newPassword)
|
||||||
|
|
||||||
|
return cm.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminChangePassword изменяет пароль пользователя (без проверки старого, только для админов)
|
||||||
|
func (cm *CredentialManager) AdminChangePassword(username, newPassword string) error {
|
||||||
|
cm.mu.Lock()
|
||||||
|
defer cm.mu.Unlock()
|
||||||
|
|
||||||
|
if cm.credentials == nil || cm.credentials.Users == nil {
|
||||||
|
return fmt.Errorf("credentials not loaded")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, exists := cm.credentials.Users[username]
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(newPassword) < 4 {
|
||||||
|
return fmt.Errorf("password must be at least 4 characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Password = cm.hashPassword(newPassword)
|
||||||
|
|
||||||
|
return cm.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAvatar устанавливает аватар для пользователя
|
||||||
|
func (cm *CredentialManager) SetAvatar(username, avatarBase64 string) error {
|
||||||
|
cm.mu.Lock()
|
||||||
|
defer cm.mu.Unlock()
|
||||||
|
|
||||||
|
if cm.credentials == nil || cm.credentials.Users == nil {
|
||||||
|
return fmt.Errorf("credentials not loaded")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, exists := cm.credentials.Users[username]
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Avatar = avatarBase64
|
||||||
|
|
||||||
|
return cm.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAvatar возвращает аватар пользователя
|
||||||
|
func (cm *CredentialManager) GetAvatar(username string) (string, error) {
|
||||||
|
cm.mu.RLock()
|
||||||
|
defer cm.mu.RUnlock()
|
||||||
|
|
||||||
|
if cm.credentials == nil || cm.credentials.Users == nil {
|
||||||
|
return "", fmt.Errorf("credentials not loaded")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, exists := cm.credentials.Users[username]
|
||||||
|
if !exists {
|
||||||
|
return "", fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.Avatar, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAvatar удаляет аватар пользователя
|
||||||
|
func (cm *CredentialManager) DeleteAvatar(username string) error {
|
||||||
|
cm.mu.Lock()
|
||||||
|
defer cm.mu.Unlock()
|
||||||
|
|
||||||
|
if cm.credentials == nil || cm.credentials.Users == nil {
|
||||||
|
return fmt.Errorf("credentials not loaded")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, exists := cm.credentials.Users[username]
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Avatar = ""
|
||||||
|
|
||||||
|
return cm.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateLastLogin обновляет время последнего входа пользователя
|
||||||
|
func (cm *CredentialManager) UpdateLastLogin(username string) {
|
||||||
|
cm.mu.Lock()
|
||||||
|
defer cm.mu.Unlock()
|
||||||
|
|
||||||
|
if cm.credentials == nil || cm.credentials.Users == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, exists := cm.credentials.Users[username]
|
||||||
|
if !exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user.LastLogin = time.Now().Unix()
|
||||||
|
cm.save() // игнорируем ошибку, т.к. это не критично
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCurrentUsername возвращает имя текущего пользователя
|
||||||
|
func (cm *CredentialManager) GetCurrentUsername() string {
|
||||||
|
cm.mu.RLock()
|
||||||
|
defer cm.mu.RUnlock()
|
||||||
|
|
||||||
|
if cm.credentials == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Возвращаем последнего вошедшего пользователя или admin по умолчанию
|
||||||
|
if cm.currentUser != "" {
|
||||||
|
return cm.currentUser
|
||||||
|
}
|
||||||
|
|
||||||
|
return "admin"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCurrentUsername устанавливает имя текущего пользователя
|
||||||
|
func (cm *CredentialManager) SetCurrentUsername(username string) {
|
||||||
|
cm.mu.Lock()
|
||||||
|
defer cm.mu.Unlock()
|
||||||
|
cm.currentUser = username
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCredentialsFile возвращает путь к файлу с учётными данными
|
||||||
|
func (cm *CredentialManager) GetCredentialsFile() string {
|
||||||
|
return cm.credFile
|
||||||
|
}
|
||||||
349
internal/cluster/auth.go
Normal file
349
internal/cluster/auth.go
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/cluster/auth.go
|
||||||
|
// Назначение: Аутентификация и авторизация между узлами кластера
|
||||||
|
|
||||||
|
package cluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NodeAuthConfig конфигурация аутентификации узлов
|
||||||
|
type NodeAuthConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
TokenTTL time.Duration `json:"token_ttl"`
|
||||||
|
PrivateKeyPath string `json:"private_key_path"`
|
||||||
|
PublicKeyPath string `json:"public_key_path"`
|
||||||
|
AllowedNodes []string `json:"allowed_nodes"`
|
||||||
|
RequireMTLS bool `json:"require_mtls"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeAuthToken представляет токен аутентификации узла
|
||||||
|
type NodeAuthToken struct {
|
||||||
|
NodeID string `json:"node_id"`
|
||||||
|
IssuedAt int64 `json:"issued_at"`
|
||||||
|
ExpiresAt int64 `json:"expires_at"`
|
||||||
|
Signature string `json:"signature"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeAuthenticator управляет аутентификацией между узлами
|
||||||
|
type NodeAuthenticator struct {
|
||||||
|
config *NodeAuthConfig
|
||||||
|
privateKey *rsa.PrivateKey
|
||||||
|
publicKey *rsa.PublicKey
|
||||||
|
tokens sync.Map
|
||||||
|
mu sync.RWMutex
|
||||||
|
logger LoggerInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNodeAuthenticator создаёт новый аутентификатор узлов
|
||||||
|
func NewNodeAuthenticator(config *NodeAuthConfig, logger LoggerInterface) (*NodeAuthenticator, error) {
|
||||||
|
if config == nil {
|
||||||
|
config = &NodeAuthConfig{
|
||||||
|
Enabled: true,
|
||||||
|
TokenTTL: 24 * time.Hour,
|
||||||
|
AllowedNodes: make([]string, 0),
|
||||||
|
RequireMTLS: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
na := &NodeAuthenticator{
|
||||||
|
config: config,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Enabled {
|
||||||
|
if err := na.loadKeys(); err != nil {
|
||||||
|
if err := na.generateKeys(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return na, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateKeys генерирует RSA ключи для аутентификации
|
||||||
|
func (na *NodeAuthenticator) generateKeys() error {
|
||||||
|
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
na.privateKey = privateKey
|
||||||
|
na.publicKey = &privateKey.PublicKey
|
||||||
|
|
||||||
|
// Сохраняем ключи
|
||||||
|
if err := na.saveKeys(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if na.logger != nil {
|
||||||
|
na.logger.Info("Generated new RSA keys for node authentication")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadKeys загружает RSA ключи с диска
|
||||||
|
func (na *NodeAuthenticator) loadKeys() error {
|
||||||
|
// Загружаем приватный ключ
|
||||||
|
privData, err := os.ReadFile(na.config.PrivateKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
block, _ := pem.Decode(privData)
|
||||||
|
if block == nil {
|
||||||
|
return fmt.Errorf("failed to decode private key")
|
||||||
|
}
|
||||||
|
|
||||||
|
privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var ok bool
|
||||||
|
na.privateKey, ok = privateKey.(*rsa.PrivateKey)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("invalid private key type")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем публичный ключ
|
||||||
|
pubData, err := os.ReadFile(na.config.PublicKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
pubBlock, _ := pem.Decode(pubData)
|
||||||
|
if pubBlock == nil {
|
||||||
|
return fmt.Errorf("failed to decode public key")
|
||||||
|
}
|
||||||
|
|
||||||
|
publicKey, err := x509.ParsePKIXPublicKey(pubBlock.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
na.publicKey, ok = publicKey.(*rsa.PublicKey)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("invalid public key type")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveKeys сохраняет RSA ключи на диск
|
||||||
|
func (na *NodeAuthenticator) saveKeys() error {
|
||||||
|
if na.privateKey == nil {
|
||||||
|
return fmt.Errorf("no private key to save")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем приватный ключ
|
||||||
|
privBytes, err := x509.MarshalPKCS8PrivateKey(na.privateKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
privPEM := pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "PRIVATE KEY",
|
||||||
|
Bytes: privBytes,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(na.config.PrivateKeyPath), 0700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(na.config.PrivateKeyPath, privPEM, 0600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем публичный ключ
|
||||||
|
pubBytes, err := x509.MarshalPKIXPublicKey(na.publicKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
pubPEM := pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "PUBLIC KEY",
|
||||||
|
Bytes: pubBytes,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := os.WriteFile(na.config.PublicKeyPath, pubPEM, 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateToken генерирует токен для узла
|
||||||
|
func (na *NodeAuthenticator) GenerateToken(nodeID string) (string, error) {
|
||||||
|
if !na.config.Enabled {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
token := &NodeAuthToken{
|
||||||
|
NodeID: nodeID,
|
||||||
|
IssuedAt: now,
|
||||||
|
ExpiresAt: now + na.config.TokenTTL.Milliseconds(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подписываем токен
|
||||||
|
data, err := json.Marshal(token)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := sha256.Sum256(data)
|
||||||
|
signature, err := rsa.SignPKCS1v15(rand.Reader, na.privateKey, crypto.SHA256, hash[:])
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
token.Signature = base64.StdEncoding.EncodeToString(signature)
|
||||||
|
|
||||||
|
// Сохраняем токен
|
||||||
|
tokenData, err := json.Marshal(token)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenString := base64.StdEncoding.EncodeToString(tokenData)
|
||||||
|
na.tokens.Store(nodeID, token)
|
||||||
|
|
||||||
|
return tokenString, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyToken проверяет токен узла
|
||||||
|
func (na *NodeAuthenticator) VerifyToken(tokenString string) (string, error) {
|
||||||
|
if !na.config.Enabled {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := base64.StdEncoding.DecodeString(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid token encoding: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var token NodeAuthToken
|
||||||
|
if err := json.Unmarshal(data, &token); err != nil {
|
||||||
|
return "", fmt.Errorf("invalid token format: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем срок действия
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
if token.ExpiresAt < now {
|
||||||
|
return "", fmt.Errorf("token expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем подпись
|
||||||
|
sigData, err := base64.StdEncoding.DecodeString(token.Signature)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid signature: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenCopy := token
|
||||||
|
tokenCopy.Signature = ""
|
||||||
|
dataWithoutSig, _ := json.Marshal(tokenCopy)
|
||||||
|
hash := sha256.Sum256(dataWithoutSig)
|
||||||
|
|
||||||
|
if err := rsa.VerifyPKCS1v15(na.publicKey, crypto.SHA256, hash[:], sigData); err != nil {
|
||||||
|
return "", fmt.Errorf("invalid signature: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, разрешён ли узел
|
||||||
|
if len(na.config.AllowedNodes) > 0 {
|
||||||
|
allowed := false
|
||||||
|
for _, node := range na.config.AllowedNodes {
|
||||||
|
if node == token.NodeID {
|
||||||
|
allowed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
return "", fmt.Errorf("node %s not allowed", token.NodeID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return token.NodeID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthenticateRequest аутентифицирует запрос
|
||||||
|
func (na *NodeAuthenticator) AuthenticateRequest(data []byte, signature string) bool {
|
||||||
|
if !na.config.Enabled {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
sigData, err := base64.StdEncoding.DecodeString(signature)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := sha256.Sum256(data)
|
||||||
|
err = rsa.VerifyPKCS1v15(na.publicKey, crypto.SHA256, hash[:], sigData)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignRequest подписывает запрос
|
||||||
|
func (na *NodeAuthenticator) SignRequest(data []byte) (string, error) {
|
||||||
|
if !na.config.Enabled {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := sha256.Sum256(data)
|
||||||
|
signature, err := rsa.SignPKCS1v15(rand.Reader, na.privateKey, crypto.SHA256, hash[:])
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return base64.StdEncoding.EncodeToString(signature), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddAllowedNode добавляет разрешённый узел
|
||||||
|
func (na *NodeAuthenticator) AddAllowedNode(nodeID string) {
|
||||||
|
na.mu.Lock()
|
||||||
|
defer na.mu.Unlock()
|
||||||
|
|
||||||
|
for _, n := range na.config.AllowedNodes {
|
||||||
|
if n == nodeID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
na.config.AllowedNodes = append(na.config.AllowedNodes, nodeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveAllowedNode удаляет разрешённый узел
|
||||||
|
func (na *NodeAuthenticator) RemoveAllowedNode(nodeID string) {
|
||||||
|
na.mu.Lock()
|
||||||
|
defer na.mu.Unlock()
|
||||||
|
|
||||||
|
newList := make([]string, 0)
|
||||||
|
for _, n := range na.config.AllowedNodes {
|
||||||
|
if n != nodeID {
|
||||||
|
newList = append(newList, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
na.config.AllowedNodes = newList
|
||||||
|
}
|
||||||
429
internal/cluster/network_replication.go
Normal file
429
internal/cluster/network_replication.go
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/cluster/network_replication.go
|
||||||
|
// Назначение: Реальная сетевая репликация с повторными попытками и бэкоффом
|
||||||
|
|
||||||
|
package cluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReplicationRetryConfig содержит конфигурацию повторных попыток
|
||||||
|
type ReplicationRetryConfig struct {
|
||||||
|
MaxRetries int
|
||||||
|
InitialBackoff time.Duration
|
||||||
|
MaxBackoff time.Duration
|
||||||
|
BackoffFactor float64
|
||||||
|
JitterEnabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NetworkReplicationStats содержит статистику репликации
|
||||||
|
type NetworkReplicationStats struct {
|
||||||
|
TotalRequests atomic.Uint64
|
||||||
|
SuccessfulReqs atomic.Uint64
|
||||||
|
FailedReqs atomic.Uint64
|
||||||
|
RetriedReqs atomic.Uint64
|
||||||
|
AvgLatencyMs atomic.Uint64
|
||||||
|
BytesSent atomic.Uint64
|
||||||
|
BytesReceived atomic.Uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultReplicationRetryConfig возвращает конфигурацию по умолчанию
|
||||||
|
func DefaultReplicationRetryConfig() *ReplicationRetryConfig {
|
||||||
|
return &ReplicationRetryConfig{
|
||||||
|
MaxRetries: 5,
|
||||||
|
InitialBackoff: 100 * time.Millisecond,
|
||||||
|
MaxBackoff: 10 * time.Second,
|
||||||
|
BackoffFactor: 2.0,
|
||||||
|
JitterEnabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateConfig проверяет конфигурацию
|
||||||
|
func validateConfig(c *ReplicationRetryConfig) error {
|
||||||
|
if c.MaxRetries < 0 {
|
||||||
|
return fmt.Errorf("max_retries cannot be negative")
|
||||||
|
}
|
||||||
|
if c.InitialBackoff < 0 {
|
||||||
|
return fmt.Errorf("initial_backoff cannot be negative")
|
||||||
|
}
|
||||||
|
if c.MaxBackoff < c.InitialBackoff {
|
||||||
|
return fmt.Errorf("max_backoff must be >= initial_backoff")
|
||||||
|
}
|
||||||
|
if c.BackoffFactor < 1.0 {
|
||||||
|
return fmt.Errorf("backoff_factor must be >= 1.0")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplicatedConnection представляет соединение с узлом
|
||||||
|
type ReplicatedConnection struct {
|
||||||
|
NodeID string
|
||||||
|
Address string
|
||||||
|
Conn net.Conn
|
||||||
|
LastUsed atomic.Int64
|
||||||
|
Failures atomic.Uint32
|
||||||
|
IsHealthy atomic.Bool
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NetworkReplicator управляет сетевой репликацией
|
||||||
|
type NetworkReplicator struct {
|
||||||
|
config *ReplicationRetryConfig
|
||||||
|
workerPool *WorkerPool
|
||||||
|
dialTimeout time.Duration
|
||||||
|
mu sync.RWMutex
|
||||||
|
connections map[string]*ReplicatedConnection
|
||||||
|
logger LoggerInterface
|
||||||
|
stats NetworkReplicationStats
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNetworkReplicator создаёт новый сетевой репликатор
|
||||||
|
func NewNetworkReplicator(config *ReplicationRetryConfig, workerPool *WorkerPool, logger LoggerInterface) *NetworkReplicator {
|
||||||
|
if config == nil {
|
||||||
|
config = DefaultReplicationRetryConfig()
|
||||||
|
}
|
||||||
|
// Игнорируем ошибку валидации в production
|
||||||
|
validateConfig(config)
|
||||||
|
|
||||||
|
return &NetworkReplicator{
|
||||||
|
config: config,
|
||||||
|
workerPool: workerPool,
|
||||||
|
dialTimeout: 10 * time.Second,
|
||||||
|
connections: make(map[string]*ReplicatedConnection),
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateBackoff вычисляет время задержки с бэкоффом
|
||||||
|
func (nr *NetworkReplicator) calculateBackoff(attempt int) time.Duration {
|
||||||
|
backoff := float64(nr.config.InitialBackoff) * math.Pow(nr.config.BackoffFactor, float64(attempt))
|
||||||
|
if backoff > float64(nr.config.MaxBackoff) {
|
||||||
|
backoff = float64(nr.config.MaxBackoff)
|
||||||
|
}
|
||||||
|
|
||||||
|
duration := time.Duration(backoff)
|
||||||
|
|
||||||
|
if nr.config.JitterEnabled {
|
||||||
|
// Добавляем случайный джиттер ±25%
|
||||||
|
jitter := time.Duration(float64(duration) * 0.25)
|
||||||
|
jitterDelta := int64(jitter) - int64(jitter/2)
|
||||||
|
if jitterDelta < 0 {
|
||||||
|
jitterDelta = 0
|
||||||
|
}
|
||||||
|
duration = duration + time.Duration(jitterDelta)
|
||||||
|
}
|
||||||
|
|
||||||
|
return duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// randUint64 возвращает случайное 64-битное число
|
||||||
|
func randUint64() uint64 {
|
||||||
|
var buf [8]byte
|
||||||
|
rand.Read(buf[:])
|
||||||
|
return binary.LittleEndian.Uint64(buf[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// randInt64 возвращает случайное число между min и max
|
||||||
|
func randInt64(min, max int64) int64 {
|
||||||
|
if min >= max {
|
||||||
|
return min
|
||||||
|
}
|
||||||
|
delta := max - min
|
||||||
|
if delta == 0 {
|
||||||
|
return min
|
||||||
|
}
|
||||||
|
n := int64(randUint64() % uint64(delta))
|
||||||
|
return min + n
|
||||||
|
}
|
||||||
|
|
||||||
|
// randInt возвращает случайное целое
|
||||||
|
func randInt(min, max int) int {
|
||||||
|
if min >= max {
|
||||||
|
return min
|
||||||
|
}
|
||||||
|
delta := max - min
|
||||||
|
if delta == 0 {
|
||||||
|
return min
|
||||||
|
}
|
||||||
|
n := int(randUint64() % uint64(delta))
|
||||||
|
return min + n
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect устанавливает соединение с узлом
|
||||||
|
func (nr *NetworkReplicator) Connect(nodeID, address string) error {
|
||||||
|
nr.mu.Lock()
|
||||||
|
defer nr.mu.Unlock()
|
||||||
|
|
||||||
|
if conn, exists := nr.connections[nodeID]; exists {
|
||||||
|
if conn.IsHealthy.Load() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
conn.Conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := net.DialTimeout("tcp", address, nr.dialTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to %s: %v", address, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
replicatedConn := &ReplicatedConnection{
|
||||||
|
NodeID: nodeID,
|
||||||
|
Address: address,
|
||||||
|
Conn: conn,
|
||||||
|
IsHealthy: atomic.Bool{},
|
||||||
|
}
|
||||||
|
replicatedConn.IsHealthy.Store(true)
|
||||||
|
replicatedConn.LastUsed.Store(time.Now().UnixMilli())
|
||||||
|
|
||||||
|
nr.connections[nodeID] = replicatedConn
|
||||||
|
|
||||||
|
if nr.logger != nil {
|
||||||
|
nr.logger.Debug(fmt.Sprintf("Connected to node %s at %s", nodeID, address))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendReplicationRequest отправляет запрос на репликацию
|
||||||
|
func (nr *NetworkReplicator) sendReplicationRequest(conn net.Conn, data []byte) error {
|
||||||
|
// Формируем сообщение: длина + данные
|
||||||
|
lenBuf := make([]byte, 4)
|
||||||
|
binary.BigEndian.PutUint32(lenBuf, uint32(len(data)))
|
||||||
|
|
||||||
|
if _, err := conn.Write(lenBuf); err != nil {
|
||||||
|
return fmt.Errorf("failed to write length: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := conn.Write(data); err != nil {
|
||||||
|
return fmt.Errorf("failed to write data: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readReplicationAck читает подтверждение репликации
|
||||||
|
func (nr *NetworkReplicator) readReplicationAck(conn net.Conn) error {
|
||||||
|
lenBuf := make([]byte, 4)
|
||||||
|
if _, err := io.ReadFull(conn, lenBuf); err != nil {
|
||||||
|
return fmt.Errorf("failed to read ack length: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ackLen := binary.BigEndian.Uint32(lenBuf)
|
||||||
|
if ackLen > 1024 {
|
||||||
|
return fmt.Errorf("ack too large: %d", ackLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
ackData := make([]byte, ackLen)
|
||||||
|
if _, err := io.ReadFull(conn, ackData); err != nil {
|
||||||
|
return fmt.Errorf("failed to read ack data: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ack struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(ackData, &ack); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse ack: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ack.Status != "ok" {
|
||||||
|
return fmt.Errorf("negative ack: %s", ack.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// doSendReplication выполняет отправку репликации
|
||||||
|
func (nr *NetworkReplicator) doSendReplication(nodeID, address string, data []byte) error {
|
||||||
|
conn := nr.getConnection(nodeID)
|
||||||
|
if conn == nil {
|
||||||
|
// Пытаемся подключиться
|
||||||
|
if err := nr.Connect(nodeID, address); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
conn = nr.getConnection(nodeID)
|
||||||
|
if conn == nil {
|
||||||
|
return fmt.Errorf("failed to get connection for node %s", nodeID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.mu.Lock()
|
||||||
|
defer conn.mu.Unlock()
|
||||||
|
|
||||||
|
if conn.Conn == nil {
|
||||||
|
return fmt.Errorf("connection is nil for node %s", nodeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Устанавливаем таймаут
|
||||||
|
if err := conn.Conn.SetWriteDeadline(time.Now().Add(30 * time.Second)); err != nil {
|
||||||
|
return fmt.Errorf("failed to set write deadline: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправляем данные
|
||||||
|
if err := nr.sendReplicationRequest(conn.Conn, data); err != nil {
|
||||||
|
conn.IsHealthy.Store(false)
|
||||||
|
conn.Conn.Close()
|
||||||
|
conn.Conn = nil
|
||||||
|
return fmt.Errorf("write failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Читаем подтверждение
|
||||||
|
if err := conn.Conn.SetReadDeadline(time.Now().Add(10 * time.Second)); err != nil {
|
||||||
|
return fmt.Errorf("failed to set read deadline: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := nr.readReplicationAck(conn.Conn); err != nil {
|
||||||
|
conn.IsHealthy.Store(false)
|
||||||
|
return fmt.Errorf("read ack failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.IsHealthy.Store(true)
|
||||||
|
conn.LastUsed.Store(time.Now().UnixMilli())
|
||||||
|
conn.Failures.Store(0)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replicate отправляет запрос на репликацию с повторными попытками
|
||||||
|
func (nr *NetworkReplicator) Replicate(targetNodeID, targetAddress string, data []byte) error {
|
||||||
|
startTime := time.Now()
|
||||||
|
nr.stats.TotalRequests.Add(1)
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
|
||||||
|
for attempt := 0; attempt <= nr.config.MaxRetries; attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
nr.stats.RetriedReqs.Add(1)
|
||||||
|
backoff := nr.calculateBackoff(attempt - 1)
|
||||||
|
if nr.logger != nil {
|
||||||
|
nr.logger.Debug(fmt.Sprintf("Replication retry %d for node %s after %v", attempt, targetNodeID, backoff))
|
||||||
|
}
|
||||||
|
time.Sleep(backoff)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := nr.doSendReplication(targetNodeID, targetAddress, data)
|
||||||
|
if err == nil {
|
||||||
|
latency := time.Since(startTime).Milliseconds()
|
||||||
|
nr.stats.SuccessfulReqs.Add(1)
|
||||||
|
nr.updateAvgLatency(latency)
|
||||||
|
nr.stats.BytesSent.Add(uint64(len(data)))
|
||||||
|
|
||||||
|
if nr.logger != nil {
|
||||||
|
nr.logger.Debug(fmt.Sprintf("Replication to %s succeeded after %d attempts, latency: %dms",
|
||||||
|
targetNodeID, attempt+1, latency))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lastErr = err
|
||||||
|
if nr.logger != nil {
|
||||||
|
nr.logger.Warn(fmt.Sprintf("Replication attempt %d to %s failed: %v", attempt+1, targetNodeID, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Помечаем соединение как нездоровое
|
||||||
|
nr.markUnhealthy(targetNodeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
nr.stats.FailedReqs.Add(1)
|
||||||
|
return fmt.Errorf("replication failed after %d attempts: %v", nr.config.MaxRetries+1, lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getConnection возвращает соединение с узлом
|
||||||
|
func (nr *NetworkReplicator) getConnection(nodeID string) *ReplicatedConnection {
|
||||||
|
nr.mu.RLock()
|
||||||
|
defer nr.mu.RUnlock()
|
||||||
|
return nr.connections[nodeID]
|
||||||
|
}
|
||||||
|
|
||||||
|
// markUnhealthy помечает соединение как нездоровое
|
||||||
|
func (nr *NetworkReplicator) markUnhealthy(nodeID string) {
|
||||||
|
nr.mu.RLock()
|
||||||
|
conn, exists := nr.connections[nodeID]
|
||||||
|
nr.mu.RUnlock()
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
conn.IsHealthy.Store(false)
|
||||||
|
failures := conn.Failures.Add(1)
|
||||||
|
if failures > 3 {
|
||||||
|
if nr.logger != nil {
|
||||||
|
nr.logger.Warn(fmt.Sprintf("Node %s marked as unhealthy after %d failures", nodeID, failures))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateAvgLatency обновляет среднюю задержку
|
||||||
|
func (nr *NetworkReplicator) updateAvgLatency(latencyMs int64) {
|
||||||
|
current := nr.stats.AvgLatencyMs.Load()
|
||||||
|
successful := nr.stats.SuccessfulReqs.Load()
|
||||||
|
if successful > 0 {
|
||||||
|
newAvg := (current*uint64(successful-1) + uint64(latencyMs)) / uint64(successful)
|
||||||
|
nr.stats.AvgLatencyMs.Store(newAvg)
|
||||||
|
} else {
|
||||||
|
nr.stats.AvgLatencyMs.Store(uint64(latencyMs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplicateDocumentAsync асинхронно реплицирует документ
|
||||||
|
func (nr *NetworkReplicator) ReplicateDocumentAsync(docID string, targetNodes []*NodeInfo, data []byte) {
|
||||||
|
for _, node := range targetNodes {
|
||||||
|
targetNodeID := node.ID
|
||||||
|
targetAddress := fmt.Sprintf("%s:%d", node.IP, node.Port)
|
||||||
|
|
||||||
|
err := nr.workerPool.SubmitFunc(fmt.Sprintf("replicate_%s_to_%s", docID, targetNodeID), func() error {
|
||||||
|
return nr.Replicate(targetNodeID, targetAddress, data)
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if nr.logger != nil {
|
||||||
|
nr.logger.Error(fmt.Sprintf("Failed to submit replication task for %s to %s: %v", docID, targetNodeID, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStats возвращает статистику репликации
|
||||||
|
func (nr *NetworkReplicator) GetStats() map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"total_requests": nr.stats.TotalRequests.Load(),
|
||||||
|
"successful": nr.stats.SuccessfulReqs.Load(),
|
||||||
|
"failed": nr.stats.FailedReqs.Load(),
|
||||||
|
"retried": nr.stats.RetriedReqs.Load(),
|
||||||
|
"avg_latency_ms": nr.stats.AvgLatencyMs.Load(),
|
||||||
|
"bytes_sent": nr.stats.BytesSent.Load(),
|
||||||
|
"bytes_received": nr.stats.BytesReceived.Load(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close закрывает все соединения
|
||||||
|
func (nr *NetworkReplicator) Close() error {
|
||||||
|
nr.mu.Lock()
|
||||||
|
defer nr.mu.Unlock()
|
||||||
|
|
||||||
|
for _, conn := range nr.connections {
|
||||||
|
if conn.Conn != nil {
|
||||||
|
conn.Conn.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nr.connections = make(map[string]*ReplicatedConnection)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
974
internal/cluster/node.go
Normal file
974
internal/cluster/node.go
Normal file
@@ -0,0 +1,974 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/cluster/node.go
|
||||||
|
// Назначение: Реализация узла кластера (node) для распределённой СУБД с поддержкой временных меток.
|
||||||
|
// Полностью lock-free с использованием атомарных операций.
|
||||||
|
|
||||||
|
package cluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"runtime/debug"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"futriis/internal/log"
|
||||||
|
"futriis/internal/storage"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NodeStatus представляет состояние узла кластера
|
||||||
|
type NodeStatus int32
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusOffline NodeStatus = iota
|
||||||
|
StatusActive
|
||||||
|
StatusSyncing
|
||||||
|
StatusFailed
|
||||||
|
)
|
||||||
|
|
||||||
|
// Node представляет отдельный узел в распределённой системе
|
||||||
|
type Node struct {
|
||||||
|
ID string // Уникальный идентификатор узла
|
||||||
|
IP string // IP-адрес узла
|
||||||
|
Port int // Порт для коммуникации
|
||||||
|
Status atomic.Int32 // Атомарный статус узла (NodeStatus)
|
||||||
|
Storage *storage.Storage
|
||||||
|
logger *log.Logger
|
||||||
|
coordinator *RaftCoordinator // Ссылка на координатора (теперь RaftCoordinator)
|
||||||
|
lastSeen atomic.Int64 // Время последнего heartbeat (Unix миллисекунды)
|
||||||
|
joinedAt atomic.Int64 // Время присоединения к кластеру
|
||||||
|
createdAt int64 // Время создания узла
|
||||||
|
startedAt int64 // Время последнего старта узла
|
||||||
|
stoppedAt int64 // Время остановки узла
|
||||||
|
incomingConn chan net.Conn // Канал для входящих соединений (wait-free)
|
||||||
|
stopChan chan struct{}
|
||||||
|
requestCount atomic.Uint64 // Счётчик обработанных запросов
|
||||||
|
bytesRx atomic.Uint64 // Получено байт
|
||||||
|
bytesTx atomic.Uint64 // Отправлено байт
|
||||||
|
mu sync.RWMutex // Для синхронизации изменений статуса
|
||||||
|
|
||||||
|
// Новые компоненты для production-ready реализации
|
||||||
|
workerPool *WorkerPool // Пул воркеров для ограничения горутин
|
||||||
|
replicator *NetworkReplicator // Сетевой репликатор с retry и backoff
|
||||||
|
connPool sync.Map // Пул соединений к другим узлам
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeConfig содержит конфигурацию для создания узла
|
||||||
|
type NodeConfig struct {
|
||||||
|
IP string
|
||||||
|
Port int
|
||||||
|
Storage *storage.Storage
|
||||||
|
Logger *log.Logger
|
||||||
|
Coordinator *RaftCoordinator
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNode создаёт новый экземпляр узла кластера
|
||||||
|
func NewNode(ip string, port int, store *storage.Storage, logger *log.Logger) *Node {
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
|
||||||
|
// Создаём пул воркеров с ограничением (максимум 500 одновременных горутин)
|
||||||
|
workerPool := NewWorkerPool(500, logger)
|
||||||
|
|
||||||
|
// Создаём сетевой репликатор с настройками retry и backoff
|
||||||
|
replicator := NewNetworkReplicator(DefaultReplicationRetryConfig(), workerPool, logger)
|
||||||
|
|
||||||
|
node := &Node{
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
IP: ip,
|
||||||
|
Port: port,
|
||||||
|
Storage: store,
|
||||||
|
logger: logger,
|
||||||
|
incomingConn: make(chan net.Conn, 10000), // Увеличенный буфер
|
||||||
|
stopChan: make(chan struct{}),
|
||||||
|
createdAt: now,
|
||||||
|
startedAt: now,
|
||||||
|
workerPool: workerPool,
|
||||||
|
replicator: replicator,
|
||||||
|
}
|
||||||
|
node.Status.Store(int32(StatusActive))
|
||||||
|
node.lastSeen.Store(now)
|
||||||
|
node.joinedAt.Store(0)
|
||||||
|
|
||||||
|
// Безопасный запуск сервера с обработкой паник
|
||||||
|
SafeGoWithLogger(node.startTCPServer, logger, "TCPServer")
|
||||||
|
SafeGoWithLogger(node.handleIncomingConnections, logger, "IncomingConnections")
|
||||||
|
SafeGoWithLogger(node.heartbeatLoop, logger, "HeartbeatLoop")
|
||||||
|
SafeGoWithLogger(node.connectionHealthMonitor, logger, "ConnectionHealthMonitor")
|
||||||
|
|
||||||
|
if logger != nil {
|
||||||
|
logger.Info(fmt.Sprintf("Node %s created at %s with worker pool (max: 500)", node.ID, node.GetCreatedAtStr()))
|
||||||
|
}
|
||||||
|
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
// startTCPServer запускает TCP-сервер для приёма запросов от других узлов
|
||||||
|
func (n *Node) startTCPServer() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Error(fmt.Sprintf("TCP server panicked: %v\n%s", r, debug.Stack()))
|
||||||
|
}
|
||||||
|
// Перезапускаем сервер
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
SafeGoWithLogger(n.startTCPServer, n.logger, "TCPServer")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:%d", n.IP, n.Port)
|
||||||
|
listener, err := net.Listen("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Error(fmt.Sprintf("Node %s failed to start TCP server: %v", n.ID, err))
|
||||||
|
}
|
||||||
|
n.Status.Store(int32(StatusFailed))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Info(fmt.Sprintf("Node %s listening on %s (started at %s)", n.ID, addr, n.GetStartedAtStr()))
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-n.stopChan:
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Info(fmt.Sprintf("Node %s TCP server stopped", n.ID))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
// Устанавливаем таймаут для Accept
|
||||||
|
if err := listener.(*net.TCPListener).SetDeadline(time.Now().Add(5 * time.Second)); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Error(fmt.Sprintf("Node %s accept error: %v", n.ID, err))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Устанавливаем таймауты на соединение
|
||||||
|
conn.SetReadDeadline(time.Now().Add(30 * time.Second))
|
||||||
|
conn.SetWriteDeadline(time.Now().Add(30 * time.Second))
|
||||||
|
|
||||||
|
select {
|
||||||
|
case n.incomingConn <- conn:
|
||||||
|
n.bytesRx.Add(1)
|
||||||
|
default:
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Warn(fmt.Sprintf("Node %s incoming connection queue full, dropping connection", n.ID))
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleIncomingConnections обрабатывает входящие соединения с использованием пула воркеров
|
||||||
|
func (n *Node) handleIncomingConnections() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Error(fmt.Sprintf("Incoming connections handler panicked: %v\n%s", r, debug.Stack()))
|
||||||
|
}
|
||||||
|
// Перезапускаем обработчик
|
||||||
|
SafeGoWithLogger(n.handleIncomingConnections, n.logger, "IncomingConnections")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-n.stopChan:
|
||||||
|
return
|
||||||
|
case conn := <-n.incomingConn:
|
||||||
|
n.requestCount.Add(1)
|
||||||
|
// Используем пул воркеров вместо создания неограниченного количества горутин
|
||||||
|
taskID := fmt.Sprintf("handle_conn_%d_%s", time.Now().UnixNano(), conn.RemoteAddr().String())
|
||||||
|
err := n.workerPool.SubmitFunc(taskID, func() error {
|
||||||
|
n.handleNodeRequest(conn)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Warn(fmt.Sprintf("Failed to submit connection task: %v", err))
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleNodeRequest обрабатывает конкретный запрос от другого узла
|
||||||
|
func (n *Node) handleNodeRequest(conn net.Conn) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Error(fmt.Sprintf("Request handler panicked: %v\n%s", r, debug.Stack()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Устанавливаем таймаут на чтение
|
||||||
|
conn.SetReadDeadline(time.Now().Add(30 * time.Second))
|
||||||
|
|
||||||
|
decoder := json.NewDecoder(conn)
|
||||||
|
var req NodeRequest
|
||||||
|
if err := decoder.Decode(&req); err != nil {
|
||||||
|
if err != io.EOF && n.logger != nil {
|
||||||
|
n.logger.Error(fmt.Sprintf("Node %s failed to decode request: %v", n.ID, err))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
n.lastSeen.Store(time.Now().UnixMilli())
|
||||||
|
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Debug(fmt.Sprintf("Node %s received request type %s from %s at %s",
|
||||||
|
n.ID, req.Type, req.FromNode, time.UnixMilli(req.Timestamp).Format("15:04:05.000")))
|
||||||
|
}
|
||||||
|
|
||||||
|
switch req.Type {
|
||||||
|
case "replicate":
|
||||||
|
n.handleReplicateRequest(req.Data)
|
||||||
|
case "query":
|
||||||
|
n.handleQueryRequest(req.Data, conn)
|
||||||
|
case "sync":
|
||||||
|
n.handleSyncRequest(req.Data, conn)
|
||||||
|
case "heartbeat":
|
||||||
|
n.handleHeartbeatRequest(req, conn)
|
||||||
|
case "status_sync":
|
||||||
|
n.handleStatusSyncRequest(req, conn)
|
||||||
|
default:
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Warn(fmt.Sprintf("Node %s unknown request type: %s", n.ID, req.Type))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleReplicateRequest обрабатывает запрос на репликацию документа
|
||||||
|
func (n *Node) handleReplicateRequest(data []byte) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Error(fmt.Sprintf("Replicate request handler panicked: %v\n%s", r, debug.Stack()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
startTime := time.Now().UnixMilli()
|
||||||
|
|
||||||
|
var repData struct {
|
||||||
|
Database string `json:"database"`
|
||||||
|
Collection string `json:"collection"`
|
||||||
|
Document map[string]interface{} `json:"document"`
|
||||||
|
SourceNode string `json:"source_node"`
|
||||||
|
ReplicaID string `json:"replica_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, &repData); err != nil {
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Error(fmt.Sprintf("Node %s failed to unmarshal replicate data: %v", n.ID, err))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := n.Storage.GetDatabase(repData.Database)
|
||||||
|
if err != nil {
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Error(fmt.Sprintf("Node %s database not found for replication: %s", n.ID, repData.Database))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
coll, err := db.GetCollection(repData.Collection)
|
||||||
|
if err != nil {
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Error(fmt.Sprintf("Node %s collection not found for replication: %s", n.ID, repData.Collection))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Безопасное получение ID документа
|
||||||
|
docID, ok := repData.Document["_id"].(string)
|
||||||
|
if !ok {
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Error(fmt.Sprintf("Node %s document missing _id field", n.ID))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
doc := &storage.Document{
|
||||||
|
ID: docID,
|
||||||
|
Fields: repData.Document,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := coll.Insert(doc); err != nil {
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Error(fmt.Sprintf("Node %s failed to replicate document: %v", n.ID, err))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
duration := time.Now().UnixMilli() - startTime
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Debug(fmt.Sprintf("Node %s replicated document %s from %s (took %d ms)",
|
||||||
|
n.ID, doc.ID, repData.SourceNode, duration))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleQueryRequest обрабатывает запрос на чтение данных с узла
|
||||||
|
func (n *Node) handleQueryRequest(data []byte, conn net.Conn) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Error(fmt.Sprintf("Query request handler panicked: %v\n%s", r, debug.Stack()))
|
||||||
|
}
|
||||||
|
n.sendErrorResponse(conn, "Internal server error")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
startTime := time.Now().UnixMilli()
|
||||||
|
|
||||||
|
var queryData struct {
|
||||||
|
Database string `json:"database"`
|
||||||
|
Collection string `json:"collection"`
|
||||||
|
DocumentID string `json:"document_id"`
|
||||||
|
RequestID string `json:"request_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, &queryData); err != nil {
|
||||||
|
n.sendErrorResponse(conn, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := n.Storage.GetDatabase(queryData.Database)
|
||||||
|
if err != nil {
|
||||||
|
n.sendErrorResponse(conn, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
coll, err := db.GetCollection(queryData.Collection)
|
||||||
|
if err != nil {
|
||||||
|
n.sendErrorResponse(conn, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
doc, err := coll.Find(queryData.DocumentID)
|
||||||
|
if err != nil {
|
||||||
|
n.sendErrorResponse(conn, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
duration := time.Now().UnixMilli() - startTime
|
||||||
|
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"status": "success",
|
||||||
|
"data": doc,
|
||||||
|
"node_id": n.ID,
|
||||||
|
"request_id": queryData.RequestID,
|
||||||
|
"duration_ms": duration,
|
||||||
|
"timestamp": time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
encoder := json.NewEncoder(conn)
|
||||||
|
if err := encoder.Encode(response); err == nil {
|
||||||
|
responseData, _ := json.Marshal(response)
|
||||||
|
n.bytesTx.Add(uint64(len(responseData)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Debug(fmt.Sprintf("Node %s handled query for %s.%s:%s (took %d ms)",
|
||||||
|
n.ID, queryData.Database, queryData.Collection, queryData.DocumentID, duration))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSyncRequest обрабатывает запрос на синхронизацию всей коллекции
|
||||||
|
func (n *Node) handleSyncRequest(data []byte, conn net.Conn) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Error(fmt.Sprintf("Sync request handler panicked: %v\n%s", r, debug.Stack()))
|
||||||
|
}
|
||||||
|
n.sendErrorResponse(conn, "Internal server error")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
startTime := time.Now().UnixMilli()
|
||||||
|
|
||||||
|
var syncData struct {
|
||||||
|
Database string `json:"database"`
|
||||||
|
Collection string `json:"collection"`
|
||||||
|
RequestID string `json:"request_id"`
|
||||||
|
Since int64 `json:"since"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, &syncData); err != nil {
|
||||||
|
n.sendErrorResponse(conn, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := n.Storage.GetDatabase(syncData.Database)
|
||||||
|
if err != nil {
|
||||||
|
n.sendErrorResponse(conn, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
coll, err := db.GetCollection(syncData.Collection)
|
||||||
|
if err != nil {
|
||||||
|
n.sendErrorResponse(conn, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
docs := coll.GetAllDocuments()
|
||||||
|
|
||||||
|
if syncData.Since > 0 {
|
||||||
|
filtered := make([]*storage.Document, 0)
|
||||||
|
for _, doc := range docs {
|
||||||
|
if doc.UpdatedAt > syncData.Since {
|
||||||
|
filtered = append(filtered, doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
docs = filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
duration := time.Now().UnixMilli() - startTime
|
||||||
|
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"status": "success",
|
||||||
|
"docs": docs,
|
||||||
|
"count": len(docs),
|
||||||
|
"node_id": n.ID,
|
||||||
|
"request_id": syncData.RequestID,
|
||||||
|
"duration_ms": duration,
|
||||||
|
"timestamp": time.Now().UnixMilli(),
|
||||||
|
"sync_duration_ms": duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.SetWriteDeadline(time.Now().Add(30 * time.Second))
|
||||||
|
encoder := json.NewEncoder(conn)
|
||||||
|
if err := encoder.Encode(response); err != nil && n.logger != nil {
|
||||||
|
n.logger.Error(fmt.Sprintf("Failed to send sync response: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Info(fmt.Sprintf("Node %s synced %d documents from %s.%s (took %d ms)",
|
||||||
|
n.ID, len(docs), syncData.Database, syncData.Collection, duration))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleHeartbeatRequest обрабатывает heartbeat запрос
|
||||||
|
func (n *Node) handleHeartbeatRequest(req NodeRequest, conn net.Conn) {
|
||||||
|
n.lastSeen.Store(time.Now().UnixMilli())
|
||||||
|
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"status": "alive",
|
||||||
|
"node_id": n.ID,
|
||||||
|
"timestamp": time.Now().UnixMilli(),
|
||||||
|
"uptime_ms": time.Now().UnixMilli() - n.startedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
encoder := json.NewEncoder(conn)
|
||||||
|
encoder.Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStatusSyncRequest обрабатывает запрос синхронизации статуса (для split-brain recovery)
|
||||||
|
func (n *Node) handleStatusSyncRequest(req NodeRequest, conn net.Conn) {
|
||||||
|
var syncStatus struct {
|
||||||
|
LeaderID string `json:"leader_id"`
|
||||||
|
Term uint64 `json:"term"`
|
||||||
|
ClusterSize int `json:"cluster_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(req.Data, &syncStatus); err != nil {
|
||||||
|
n.sendErrorResponse(conn, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if n.coordinator != nil {
|
||||||
|
n.coordinator.HandleStatusSync(syncStatus.LeaderID, syncStatus.Term, syncStatus.ClusterSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"status": "synced",
|
||||||
|
"node_id": n.ID,
|
||||||
|
"term": n.coordinator.GetCurrentTerm(),
|
||||||
|
"is_leader": n.coordinator.IsLeader(),
|
||||||
|
"timestamp": time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
encoder := json.NewEncoder(conn)
|
||||||
|
encoder.Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendErrorResponse отправляет ошибку в ответ на запрос
|
||||||
|
func (n *Node) sendErrorResponse(conn net.Conn, errMsg string) {
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"status": "error",
|
||||||
|
"error": errMsg,
|
||||||
|
"node_id": n.ID,
|
||||||
|
"timestamp": time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
encoder := json.NewEncoder(conn)
|
||||||
|
encoder.Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// heartbeatLoop отправляет периодические сигналы жизни координатору
|
||||||
|
func (n *Node) heartbeatLoop() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Error(fmt.Sprintf("Heartbeat loop panicked: %v\n%s", r, debug.Stack()))
|
||||||
|
}
|
||||||
|
// Перезапускаем heartbeat loop
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
SafeGoWithLogger(n.heartbeatLoop, n.logger, "HeartbeatLoop")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-n.stopChan:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if n.coordinator != nil {
|
||||||
|
n.coordinator.SendHeartbeat(n.ID)
|
||||||
|
n.lastSeen.Store(time.Now().UnixMilli())
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Debug(fmt.Sprintf("Node %s sent heartbeat at %s", n.ID, n.GetLastSeenStr()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// connectionHealthMonitor мониторит здоровье соединений
|
||||||
|
func (n *Node) connectionHealthMonitor() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Error(fmt.Sprintf("Connection health monitor panicked: %v\n%s", r, debug.Stack()))
|
||||||
|
}
|
||||||
|
SafeGoWithLogger(n.connectionHealthMonitor, n.logger, "ConnectionHealthMonitor")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-n.stopChan:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
n.cleanupStaleConnections()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupStaleConnections очищает старые соединения
|
||||||
|
func (n *Node) cleanupStaleConnections() {
|
||||||
|
n.connPool.Range(func(key, value interface{}) bool {
|
||||||
|
if conn, ok := value.(net.Conn); ok {
|
||||||
|
// Проверяем, активно ли соединение
|
||||||
|
conn.SetReadDeadline(time.Now().Add(1 * time.Second))
|
||||||
|
buf := make([]byte, 1)
|
||||||
|
_, err := conn.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
n.connPool.Delete(key)
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Debug(fmt.Sprintf("Cleaned up stale connection for %v", key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNodeStatus возвращает текущий статус узла (атомарно)
|
||||||
|
func (n *Node) GetNodeStatus() NodeStatus {
|
||||||
|
return NodeStatus(n.Status.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsActive проверяет, активен ли узел
|
||||||
|
func (n *Node) IsActive() bool {
|
||||||
|
return NodeStatus(n.Status.Load()) == StatusActive
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetStatus устанавливает статус узла с синхронизацией через Raft
|
||||||
|
func (n *Node) SetStatus(status NodeStatus) error {
|
||||||
|
n.mu.Lock()
|
||||||
|
defer n.mu.Unlock()
|
||||||
|
|
||||||
|
oldStatus := n.Status.Load()
|
||||||
|
if oldStatus == int32(status) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if n.coordinator != nil && n.coordinator.IsLeader() {
|
||||||
|
if err := n.coordinator.UpdateNodeStatus(n.ID, status); err != nil {
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Error(fmt.Sprintf("Failed to update node status via Raft: %v", err))
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
n.Status.Store(int32(status))
|
||||||
|
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Info(fmt.Sprintf("Node %s status changed from %d to %d at %s",
|
||||||
|
n.ID, oldStatus, status, time.Now().Format("2006-01-02 15:04:05.000")))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCoordinator устанавливает координатора для узла
|
||||||
|
func (n *Node) SetCoordinator(coord *RaftCoordinator) {
|
||||||
|
n.coordinator = coord
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
n.joinedAt.Store(now)
|
||||||
|
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Info(fmt.Sprintf("Node %s joined cluster at %s", n.ID, n.GetJoinedAtStr()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// JoinCluster присоединяет узел к кластеру
|
||||||
|
func (n *Node) JoinCluster(coord *RaftCoordinator) error {
|
||||||
|
if n.coordinator != nil {
|
||||||
|
return fmt.Errorf("node already joined to cluster")
|
||||||
|
}
|
||||||
|
|
||||||
|
n.SetCoordinator(coord)
|
||||||
|
|
||||||
|
if err := coord.RegisterNode(n); err != nil {
|
||||||
|
return fmt.Errorf("failed to register node: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := n.SetStatus(StatusActive); err != nil {
|
||||||
|
return fmt.Errorf("failed to set active status: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Info(fmt.Sprintf("Node %s successfully joined cluster at %s", n.ID, n.GetJoinedAtStr()))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LeaveCluster покидает кластер
|
||||||
|
func (n *Node) LeaveCluster() error {
|
||||||
|
if n.coordinator == nil {
|
||||||
|
return fmt.Errorf("node not in cluster")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := n.SetStatus(StatusOffline); err != nil {
|
||||||
|
n.logger.Warn(fmt.Sprintf("Failed to set offline status: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := n.coordinator.RemoveNode(n.ID); err != nil {
|
||||||
|
n.logger.Warn(fmt.Sprintf("Failed to remove node from coordinator: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
n.coordinator = nil
|
||||||
|
n.joinedAt.Store(0)
|
||||||
|
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Info(fmt.Sprintf("Node %s left cluster at %s", n.ID, time.Now().Format("2006-01-02 15:04:05.000")))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLastSeen возвращает время последнего контакта
|
||||||
|
func (n *Node) GetLastSeen() int64 {
|
||||||
|
return n.lastSeen.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLastSeenStr возвращает человекочитаемое время последнего контакта
|
||||||
|
func (n *Node) GetLastSeenStr() string {
|
||||||
|
lastSeen := n.lastSeen.Load()
|
||||||
|
if lastSeen == 0 {
|
||||||
|
return "never"
|
||||||
|
}
|
||||||
|
return time.UnixMilli(lastSeen).Format("2006-01-02 15:04:05.000")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetJoinedAt возвращает время присоединения к кластеру
|
||||||
|
func (n *Node) GetJoinedAt() int64 {
|
||||||
|
return n.joinedAt.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetJoinedAtStr возвращает человекочитаемое время присоединения
|
||||||
|
func (n *Node) GetJoinedAtStr() string {
|
||||||
|
joinedAt := n.joinedAt.Load()
|
||||||
|
if joinedAt == 0 {
|
||||||
|
return "not joined"
|
||||||
|
}
|
||||||
|
return time.UnixMilli(joinedAt).Format("2006-01-02 15:04:05.000")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStartedAt возвращает время старта узла
|
||||||
|
func (n *Node) GetStartedAt() int64 {
|
||||||
|
return n.startedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStartedAtStr возвращает человекочитаемое время старта
|
||||||
|
func (n *Node) GetStartedAtStr() string {
|
||||||
|
return time.UnixMilli(n.startedAt).Format("2006-01-02 15:04:05.000")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCreatedAt возвращает время создания узла
|
||||||
|
func (n *Node) GetCreatedAt() int64 {
|
||||||
|
return n.createdAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCreatedAtStr возвращает человекочитаемое время создания
|
||||||
|
func (n *Node) GetCreatedAtStr() string {
|
||||||
|
return time.UnixMilli(n.createdAt).Format("2006-01-02 15:04:05.000")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUptime возвращает время работы узла
|
||||||
|
func (n *Node) GetUptime() time.Duration {
|
||||||
|
if n.startedAt == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return time.Duration(time.Now().UnixMilli()-n.startedAt) * time.Millisecond
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStats возвращает статистику узла
|
||||||
|
func (n *Node) GetStats() map[string]interface{} {
|
||||||
|
stats := map[string]interface{}{
|
||||||
|
"id": n.ID,
|
||||||
|
"ip": n.IP,
|
||||||
|
"port": n.Port,
|
||||||
|
"status": n.GetNodeStatus(),
|
||||||
|
"created_at": n.GetCreatedAtStr(),
|
||||||
|
"started_at": n.GetStartedAtStr(),
|
||||||
|
"joined_at": n.GetJoinedAtStr(),
|
||||||
|
"last_seen": n.GetLastSeenStr(),
|
||||||
|
"uptime": n.GetUptime().String(),
|
||||||
|
"request_count": n.requestCount.Load(),
|
||||||
|
"bytes_rx": n.bytesRx.Load(),
|
||||||
|
"bytes_tx": n.bytesTx.Load(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if n.workerPool != nil {
|
||||||
|
stats["worker_pool"] = n.workerPool.GetStats()
|
||||||
|
}
|
||||||
|
|
||||||
|
if n.replicator != nil {
|
||||||
|
stats["replication"] = n.replicator.GetStats()
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop останавливает работу узла
|
||||||
|
func (n *Node) Stop() {
|
||||||
|
if n.coordinator != nil && n.coordinator.IsLeader() {
|
||||||
|
n.SetStatus(StatusOffline)
|
||||||
|
}
|
||||||
|
n.Status.Store(int32(StatusOffline))
|
||||||
|
n.stoppedAt = time.Now().UnixMilli()
|
||||||
|
|
||||||
|
close(n.stopChan)
|
||||||
|
|
||||||
|
if n.workerPool != nil {
|
||||||
|
n.workerPool.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
if n.replicator != nil {
|
||||||
|
n.replicator.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Закрываем все соединения в пуле
|
||||||
|
n.connPool.Range(func(key, value interface{}) bool {
|
||||||
|
if conn, ok := value.(net.Conn); ok {
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Info(fmt.Sprintf("Node %s stopped at %s", n.ID, time.UnixMilli(n.stoppedAt).Format("2006-01-02 15:04:05.000")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAddress возвращает адрес узла в формате "ip:port"
|
||||||
|
func (n *Node) GetAddress() string {
|
||||||
|
return fmt.Sprintf("%s:%d", n.IP, n.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateReplicationID генерирует уникальный ID для репликации
|
||||||
|
func generateReplicationID() string {
|
||||||
|
bytes := make([]byte, 16)
|
||||||
|
rand.Read(bytes)
|
||||||
|
return base64.URLEncoding.EncodeToString(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplicateDocument отправляет документ на репликацию всем активным узлам с retry и backoff
|
||||||
|
func (n *Node) ReplicateDocument(database, collection string, doc *storage.Document) error {
|
||||||
|
if n.coordinator == nil {
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Warn("No coordinator set, skipping replication")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("no coordinator set")
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes := n.coordinator.GetActiveNodes()
|
||||||
|
if len(nodes) <= 1 {
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Debug("No other nodes for replication")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подготавливаем данные для репликации
|
||||||
|
repData := struct {
|
||||||
|
Database string `json:"database"`
|
||||||
|
Collection string `json:"collection"`
|
||||||
|
Document map[string]interface{} `json:"document"`
|
||||||
|
SourceNode string `json:"source_node"`
|
||||||
|
ReplicaID string `json:"replica_id"`
|
||||||
|
}{
|
||||||
|
Database: database,
|
||||||
|
Collection: collection,
|
||||||
|
Document: doc.GetFields(),
|
||||||
|
SourceNode: n.ID,
|
||||||
|
ReplicaID: generateReplicationID(),
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(repData)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal replication data: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
startTime := time.Now().UnixMilli()
|
||||||
|
|
||||||
|
// Отправляем на все узлы, кроме себя
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var failedCount atomic.Int32
|
||||||
|
var successCount atomic.Int32
|
||||||
|
|
||||||
|
for _, nodeInfo := range nodes {
|
||||||
|
if nodeInfo.ID == n.ID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
targetNodeID := nodeInfo.ID
|
||||||
|
targetAddress := fmt.Sprintf("%s:%d", nodeInfo.IP, nodeInfo.Port)
|
||||||
|
|
||||||
|
// Асинхронная репликация с повторными попытками через пул воркеров
|
||||||
|
taskID := fmt.Sprintf("replicate_%s_to_%s_%s", doc.ID, targetNodeID, repData.ReplicaID)
|
||||||
|
err := n.workerPool.SubmitFunc(taskID, func() error {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
if n.replicator == nil {
|
||||||
|
failedCount.Add(1)
|
||||||
|
return fmt.Errorf("replicator not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := n.replicator.Replicate(targetNodeID, targetAddress, data)
|
||||||
|
if err != nil {
|
||||||
|
failedCount.Add(1)
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Error(fmt.Sprintf("Failed to replicate document %s to node %s after retries: %v",
|
||||||
|
doc.ID, targetNodeID, err))
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
successCount.Add(1)
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Debug(fmt.Sprintf("Successfully replicated document %s to node %s", doc.ID, targetNodeID))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
wg.Done()
|
||||||
|
failedCount.Add(1)
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Error(fmt.Sprintf("Failed to submit replication task for %s to %s: %v",
|
||||||
|
doc.ID, targetNodeID, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ждём завершения всех репликаций с таймаутом
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
duration := time.Now().UnixMilli() - startTime
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Info(fmt.Sprintf("Replicated document %s to %d/%d nodes (took %d ms)",
|
||||||
|
doc.ID, successCount.Load(), len(nodes)-1, duration))
|
||||||
|
}
|
||||||
|
case <-time.After(30 * time.Second):
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Warn(fmt.Sprintf("Replication timeout for document %s after %d ms", doc.ID, time.Now().UnixMilli()-startTime))
|
||||||
|
}
|
||||||
|
return fmt.Errorf("replication timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
if failedCount.Load() > 0 {
|
||||||
|
return fmt.Errorf("replication partially failed: %d of %d nodes failed", failedCount.Load(), len(nodes)-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWorkerPoolStats возвращает статистику пула воркеров
|
||||||
|
func (n *Node) GetWorkerPoolStats() map[string]interface{} {
|
||||||
|
if n.workerPool == nil {
|
||||||
|
return map[string]interface{}{"enabled": false}
|
||||||
|
}
|
||||||
|
return n.workerPool.GetStats()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReplicationStats возвращает статистику репликации
|
||||||
|
func (n *Node) GetReplicationStats() map[string]interface{} {
|
||||||
|
if n.replicator == nil {
|
||||||
|
return map[string]interface{}{"enabled": false}
|
||||||
|
}
|
||||||
|
return n.replicator.GetStats()
|
||||||
|
}
|
||||||
2947
internal/cluster/raft_coordinator.go
Normal file
2947
internal/cluster/raft_coordinator.go
Normal file
File diff suppressed because it is too large
Load Diff
289
internal/cluster/recovery.go
Normal file
289
internal/cluster/recovery.go
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/cluster/recovery.go
|
||||||
|
// Назначение: Автоматическое восстановление узлов после сбоя
|
||||||
|
|
||||||
|
package cluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NodeState представляет состояние узла для восстановления
|
||||||
|
type NodeState struct {
|
||||||
|
NodeID string `json:"node_id"`
|
||||||
|
LastSeen time.Time `json:"last_seen"`
|
||||||
|
LastLogIndex uint64 `json:"last_log_index"`
|
||||||
|
FailureCount int `json:"failure_count"`
|
||||||
|
IsRecovering bool `json:"is_recovering"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecoveryManager управляет восстановлением узлов
|
||||||
|
type RecoveryManager struct {
|
||||||
|
coordinator *RaftCoordinator
|
||||||
|
logger LoggerInterface
|
||||||
|
states sync.Map
|
||||||
|
maxFailures int
|
||||||
|
recoveryDelay time.Duration
|
||||||
|
stopChan chan struct{}
|
||||||
|
wg sync.WaitGroup
|
||||||
|
isActive atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRecoveryManager создаёт новый менеджер восстановления
|
||||||
|
func NewRecoveryManager(coordinator *RaftCoordinator, logger LoggerInterface) *RecoveryManager {
|
||||||
|
rm := &RecoveryManager{
|
||||||
|
coordinator: coordinator,
|
||||||
|
logger: logger,
|
||||||
|
maxFailures: 3,
|
||||||
|
recoveryDelay: 30 * time.Second,
|
||||||
|
stopChan: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
return rm
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start запускает мониторинг восстановления
|
||||||
|
func (rm *RecoveryManager) Start() {
|
||||||
|
rm.isActive.Store(true)
|
||||||
|
rm.wg.Add(2)
|
||||||
|
go rm.monitorLoop()
|
||||||
|
go rm.recoveryLoop()
|
||||||
|
|
||||||
|
if rm.logger != nil {
|
||||||
|
rm.logger.Info("Recovery manager started")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop останавливает менеджер восстановления
|
||||||
|
func (rm *RecoveryManager) Stop() {
|
||||||
|
rm.isActive.Store(false)
|
||||||
|
close(rm.stopChan)
|
||||||
|
rm.wg.Wait()
|
||||||
|
|
||||||
|
if rm.logger != nil {
|
||||||
|
rm.logger.Info("Recovery manager stopped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// monitorLoop отслеживает состояние узлов
|
||||||
|
func (rm *RecoveryManager) monitorLoop() {
|
||||||
|
defer rm.wg.Done()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(10 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-rm.stopChan:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
rm.checkNodesHealth()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkNodesHealth проверяет здоровье узлов
|
||||||
|
func (rm *RecoveryManager) checkNodesHealth() {
|
||||||
|
if rm.coordinator == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes := rm.coordinator.GetAllNodes()
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
for _, node := range nodes {
|
||||||
|
stateVal, ok := rm.states.Load(node.ID)
|
||||||
|
var state *NodeState
|
||||||
|
if ok {
|
||||||
|
state = stateVal.(*NodeState)
|
||||||
|
} else {
|
||||||
|
state = &NodeState{
|
||||||
|
NodeID: node.ID,
|
||||||
|
LastSeen: now,
|
||||||
|
LastLogIndex: 0,
|
||||||
|
FailureCount: 0,
|
||||||
|
IsRecovering: false,
|
||||||
|
}
|
||||||
|
rm.states.Store(node.ID, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, был ли недавно heartbeat
|
||||||
|
lastSeen := time.UnixMilli(node.LastSeen)
|
||||||
|
if now.Sub(lastSeen) > 30*time.Second {
|
||||||
|
state.FailureCount++
|
||||||
|
if rm.logger != nil {
|
||||||
|
rm.logger.Warn(fmt.Sprintf("Node %s appears unhealthy, failure count: %d", node.ID, state.FailureCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.FailureCount >= rm.maxFailures && !state.IsRecovering {
|
||||||
|
rm.triggerRecovery(node.ID)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if state.FailureCount > 0 {
|
||||||
|
state.FailureCount = 0
|
||||||
|
if rm.logger != nil {
|
||||||
|
rm.logger.Info(fmt.Sprintf("Node %s recovered", node.ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.LastSeen = now
|
||||||
|
rm.states.Store(node.ID, state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// triggerRecovery запускает восстановление узла
|
||||||
|
func (rm *RecoveryManager) triggerRecovery(nodeID string) {
|
||||||
|
stateVal, ok := rm.states.Load(nodeID)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state := stateVal.(*NodeState)
|
||||||
|
if state.IsRecovering {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state.IsRecovering = true
|
||||||
|
rm.states.Store(nodeID, state)
|
||||||
|
|
||||||
|
if rm.logger != nil {
|
||||||
|
rm.logger.Info(fmt.Sprintf("Triggering recovery for node %s", nodeID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запускаем асинхронное восстановление
|
||||||
|
go rm.recoverNode(nodeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recoverNode восстанавливает узел
|
||||||
|
func (rm *RecoveryManager) recoverNode(nodeID string) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
if rm.logger != nil {
|
||||||
|
rm.logger.Error(fmt.Sprintf("Recovery for node %s panicked: %v", nodeID, r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сбрасываем флаг восстановления
|
||||||
|
if stateVal, ok := rm.states.Load(nodeID); ok {
|
||||||
|
state := stateVal.(*NodeState)
|
||||||
|
state.IsRecovering = false
|
||||||
|
rm.states.Store(nodeID, state)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Ждём перед попыткой восстановления
|
||||||
|
time.Sleep(rm.recoveryDelay)
|
||||||
|
|
||||||
|
if rm.coordinator == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, не восстановился ли узел самостоятельно
|
||||||
|
node := rm.coordinator.GetNodeByID(nodeID)
|
||||||
|
if node != nil && time.Now().UnixMilli()-node.LastSeen < 30000 {
|
||||||
|
if rm.logger != nil {
|
||||||
|
rm.logger.Info(fmt.Sprintf("Node %s recovered on its own", nodeID))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пытаемся переподключить узел
|
||||||
|
if rm.logger != nil {
|
||||||
|
rm.logger.Info(fmt.Sprintf("Attempting to reconnect node %s", nodeID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем статус узла
|
||||||
|
if err := rm.coordinator.UpdateNodeStatus(nodeID, StatusActive); err != nil {
|
||||||
|
if rm.logger != nil {
|
||||||
|
rm.logger.Error(fmt.Sprintf("Failed to update node %s status: %v", nodeID, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запускаем синхронизацию данных
|
||||||
|
if err := rm.syncNodeData(nodeID); err != nil {
|
||||||
|
if rm.logger != nil {
|
||||||
|
rm.logger.Error(fmt.Sprintf("Failed to sync node %s data: %v", nodeID, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rm.logger != nil {
|
||||||
|
rm.logger.Info(fmt.Sprintf("Recovery completed for node %s", nodeID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncNodeData синхронизирует данные с узлом
|
||||||
|
func (rm *RecoveryManager) syncNodeData(nodeID string) error {
|
||||||
|
// Получаем информацию об узле
|
||||||
|
node := rm.coordinator.GetNodeByID(nodeID)
|
||||||
|
if node == nil {
|
||||||
|
return fmt.Errorf("node not found: %s", nodeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rm.logger != nil {
|
||||||
|
rm.logger.Debug(fmt.Sprintf("Syncing data with node %s at %s:%d", nodeID, node.IP, node.Port))
|
||||||
|
}
|
||||||
|
|
||||||
|
// В реальной реализации здесь была бы синхронизация данных
|
||||||
|
// Например, отправка всех изменений с момента последнего успешного heartbeat
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// recoveryLoop периодически проверяет и восстанавливает узлы
|
||||||
|
func (rm *RecoveryManager) recoveryLoop() {
|
||||||
|
defer rm.wg.Done()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(1 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-rm.stopChan:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
rm.attemptRecoveryAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// attemptRecoveryAll пытается восстановить все проблемные узлы
|
||||||
|
func (rm *RecoveryManager) attemptRecoveryAll() {
|
||||||
|
rm.states.Range(func(key, value interface{}) bool {
|
||||||
|
state := value.(*NodeState)
|
||||||
|
if state.FailureCount >= rm.maxFailures && !state.IsRecovering {
|
||||||
|
go rm.recoverNode(state.NodeID)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNodeState возвращает состояние узла
|
||||||
|
func (rm *RecoveryManager) GetNodeState(nodeID string) *NodeState {
|
||||||
|
if val, ok := rm.states.Load(nodeID); ok {
|
||||||
|
return val.(*NodeState)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllStates возвращает все состояния узлов
|
||||||
|
func (rm *RecoveryManager) GetAllStates() map[string]*NodeState {
|
||||||
|
states := make(map[string]*NodeState)
|
||||||
|
rm.states.Range(func(key, value interface{}) bool {
|
||||||
|
states[key.(string)] = value.(*NodeState)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return states
|
||||||
|
}
|
||||||
301
internal/cluster/types.go
Normal file
301
internal/cluster/types.go
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/cluster/types.go
|
||||||
|
// Назначение: Общие типы данных для кластерных операций с поддержкой временных меток
|
||||||
|
|
||||||
|
package cluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NodeInfo представляет информацию об узле для координатора
|
||||||
|
type NodeInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
LastSeen int64 `json:"last_seen"`
|
||||||
|
JoinedAt int64 `json:"joined_at"`
|
||||||
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
Version uint64 `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClusterStatus представляет статус кластера
|
||||||
|
type ClusterStatus struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
TotalNodes int `json:"total_nodes"`
|
||||||
|
ActiveNodes int `json:"active_nodes"`
|
||||||
|
SyncingNodes int `json:"syncing_nodes"`
|
||||||
|
FailedNodes int `json:"failed_nodes"`
|
||||||
|
ReplicationFactor int `json:"replication_factor"`
|
||||||
|
LeaderID string `json:"leader_id"`
|
||||||
|
Health string `json:"health"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
PipelineEnabled bool `json:"pipeline_enabled"`
|
||||||
|
BatchCommitEnabled bool `json:"batch_commit_enabled"`
|
||||||
|
ReshardingEnabled bool `json:"resharding_enabled"`
|
||||||
|
JointConsensusActive bool `json:"joint_consensus_active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClusterHealth представляет информацию о здоровье кластера
|
||||||
|
type ClusterHealth struct {
|
||||||
|
Nodes map[string]*NodeHealth `json:"nodes"`
|
||||||
|
OverallScore float64 `json:"overall_score"`
|
||||||
|
Recommendations string `json:"recommendations"`
|
||||||
|
CheckedAt int64 `json:"checked_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeHealth представляет здоровье отдельного узла
|
||||||
|
type NodeHealth struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
LatencyMs int64 `json:"latency_ms"`
|
||||||
|
LastCheck int64 `json:"last_check"`
|
||||||
|
LastSuccess int64 `json:"last_success"`
|
||||||
|
LastFailure int64 `json:"last_failure"`
|
||||||
|
FailureCount int `json:"failure_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeRequest представляет запрос от одного узла к другому
|
||||||
|
type NodeRequest struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Data []byte `json:"data"`
|
||||||
|
FromNode string `json:"from_node"`
|
||||||
|
RequestID string `json:"request_id"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShardInfo представляет информацию о шарде
|
||||||
|
type ShardInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Nodes []string `json:"nodes"`
|
||||||
|
LeaderNode string `json:"leader_node"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
LastRebalanced int64 `json:"last_rebalanced"`
|
||||||
|
DocumentCount int64 `json:"document_count"`
|
||||||
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplicationLogEntry представляет запись в журнале репликации
|
||||||
|
type ReplicationLogEntry struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
SourceNode string `json:"source_node"`
|
||||||
|
TargetNode string `json:"target_node"`
|
||||||
|
Operation string `json:"operation"`
|
||||||
|
Database string `json:"database"`
|
||||||
|
Collection string `json:"collection"`
|
||||||
|
DocumentID string `json:"document_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
DurationMs int64 `json:"duration_ms"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Details map[string]interface{} `json:"details,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== СТРУКТУРЫ ДЛЯ PRODUCTION-READY РЕАЛИЗАЦИИ ==========
|
||||||
|
|
||||||
|
// ConnectionInfo содержит информацию о соединении с узлом
|
||||||
|
type ConnectionInfo struct {
|
||||||
|
NodeID string `json:"node_id"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
IsHealthy bool `json:"is_healthy"`
|
||||||
|
LastUsed int64 `json:"last_used"`
|
||||||
|
Failures uint32 `json:"failures"`
|
||||||
|
ConnectedAt int64 `json:"connected_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClusterOperation представляет операцию в кластере
|
||||||
|
type ClusterOperation struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"` // "add_node", "remove_node", "rebalance", "reshard"
|
||||||
|
Status string `json:"status"` // "pending", "in_progress", "completed", "failed"
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
StartedAt int64 `json:"started_at"`
|
||||||
|
CompletedAt int64 `json:"completed_at"`
|
||||||
|
Details map[string]interface{} `json:"details"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkerPoolStats содержит статистику пула воркеров
|
||||||
|
type WorkerPoolStats struct {
|
||||||
|
MaxWorkers int `json:"max_workers"`
|
||||||
|
ActiveTasks int32 `json:"active_tasks"`
|
||||||
|
TotalTasks uint64 `json:"total_tasks"`
|
||||||
|
FailedTasks uint64 `json:"failed_tasks"`
|
||||||
|
QueueSize int `json:"queue_size"`
|
||||||
|
QueueCapacity int `json:"queue_capacity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== МЕТОДЫ ДЛЯ NodeInfo ==========
|
||||||
|
|
||||||
|
// NodeJoinedAt возвращает человекочитаемое время присоединения узла
|
||||||
|
func (n *NodeInfo) NodeJoinedAt() string {
|
||||||
|
if n.JoinedAt == 0 {
|
||||||
|
return "not joined"
|
||||||
|
}
|
||||||
|
return time.UnixMilli(n.JoinedAt).Format("2006-01-02 15:04:05.000")
|
||||||
|
}
|
||||||
|
|
||||||
|
// LastSeenAt возвращает человекочитаемое время последнего контакта
|
||||||
|
func (n *NodeInfo) LastSeenAt() string {
|
||||||
|
if n.LastSeen == 0 {
|
||||||
|
return "never"
|
||||||
|
}
|
||||||
|
return time.UnixMilli(n.LastSeen).Format("2006-01-02 15:04:05.000")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUptime возвращает время жизни узла в кластере
|
||||||
|
func (n *NodeInfo) GetUptime() time.Duration {
|
||||||
|
if n.JoinedAt == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return time.Duration(time.Now().UnixMilli()-n.JoinedAt) * time.Millisecond
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsHealthy проверяет, здоров ли узел
|
||||||
|
func (n *NodeInfo) IsHealthy() bool {
|
||||||
|
return n.Status == "active" && time.Now().UnixMilli()-n.LastSeen < 30000
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAddress возвращает адрес узла
|
||||||
|
func (n *NodeInfo) GetAddress() string {
|
||||||
|
return fmt.Sprintf("%s:%d", n.IP, n.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== МЕТОДЫ ДЛЯ ShardInfo ==========
|
||||||
|
|
||||||
|
// GetUptime возвращает время жизни шарда
|
||||||
|
func (s *ShardInfo) GetUptime() time.Duration {
|
||||||
|
if s.CreatedAt == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return time.Duration(time.Now().UnixMilli()-s.CreatedAt) * time.Millisecond
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsHealthy проверяет, здоров ли шард
|
||||||
|
func (s *ShardInfo) IsHealthy() bool {
|
||||||
|
return s.Status == "active"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLastRebalancedStr возвращает человекочитаемое время последней ребалансировки
|
||||||
|
func (s *ShardInfo) GetLastRebalancedStr() string {
|
||||||
|
if s.LastRebalanced == 0 {
|
||||||
|
return "never"
|
||||||
|
}
|
||||||
|
return time.UnixMilli(s.LastRebalanced).Format("2006-01-02 15:04:05.000")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCreatedAtStr возвращает человекочитаемое время создания
|
||||||
|
func (s *ShardInfo) GetCreatedAtStr() string {
|
||||||
|
if s.CreatedAt == 0 {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
return time.UnixMilli(s.CreatedAt).Format("2006-01-02 15:04:05.000")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUpdatedAtStr возвращает человекочитаемое время обновления
|
||||||
|
func (s *ShardInfo) GetUpdatedAtStr() string {
|
||||||
|
if s.UpdatedAt == 0 {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
return time.UnixMilli(s.UpdatedAt).Format("2006-01-02 15:04:05.000")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLeaderNode возвращает лидера шарда
|
||||||
|
func (s *ShardInfo) GetLeaderNode() string {
|
||||||
|
return s.LeaderNode
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNodeCount возвращает количество узлов в шарде
|
||||||
|
func (s *ShardInfo) GetNodeCount() int {
|
||||||
|
return len(s.Nodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReplicationFactor возвращает фактор репликации шарда
|
||||||
|
func (s *ShardInfo) GetReplicationFactor() int {
|
||||||
|
return len(s.Nodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== МЕТОДЫ ДЛЯ ReplicationLogEntry ==========
|
||||||
|
|
||||||
|
// GetTimestampStr возвращает человекочитаемое время записи репликации
|
||||||
|
func (r *ReplicationLogEntry) GetTimestampStr() string {
|
||||||
|
if r.Timestamp == 0 {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
return time.UnixMilli(r.Timestamp).Format("2006-01-02 15:04:05.000")
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSuccess проверяет, успешна ли операция репликации
|
||||||
|
func (r *ReplicationLogEntry) IsSuccess() bool {
|
||||||
|
return r.Status == "success"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDuration возвращает длительность операции
|
||||||
|
func (r *ReplicationLogEntry) GetDuration() time.Duration {
|
||||||
|
return time.Duration(r.DurationMs) * time.Millisecond
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== МЕТОДЫ ДЛЯ ClusterHealth ==========
|
||||||
|
|
||||||
|
// IsHealthy проверяет, здоров ли кластер в целом
|
||||||
|
func (ch *ClusterHealth) IsHealthy() bool {
|
||||||
|
return ch.OverallScore >= 80
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDegraded проверяет, деградирован ли кластер
|
||||||
|
func (ch *ClusterHealth) IsDegraded() bool {
|
||||||
|
return ch.OverallScore >= 50 && ch.OverallScore < 80
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsCritical проверяет, находится ли кластер в критическом состоянии
|
||||||
|
func (ch *ClusterHealth) IsCritical() bool {
|
||||||
|
return ch.OverallScore < 50
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHealthyNodes возвращает количество здоровых узлов
|
||||||
|
func (ch *ClusterHealth) GetHealthyNodes() int {
|
||||||
|
count := 0
|
||||||
|
for _, node := range ch.Nodes {
|
||||||
|
if node.Status == "active" {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ==========
|
||||||
|
|
||||||
|
// GetTimestamp возвращает текущий timestamp в миллисекундах
|
||||||
|
func GetTimestamp() int64 {
|
||||||
|
return time.Now().UnixMilli()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatTimestamp форматирует timestamp в человекочитаемый вид
|
||||||
|
func FormatTimestamp(ts int64) string {
|
||||||
|
if ts == 0 {
|
||||||
|
return "never"
|
||||||
|
}
|
||||||
|
return time.UnixMilli(ts).Format("2006-01-02 15:04:05.000")
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsTimestampExpired проверяет, истёк ли timestamp
|
||||||
|
func IsTimestampExpired(ts int64, ttl time.Duration) bool {
|
||||||
|
if ts == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return time.Now().UnixMilli()-ts > ttl.Milliseconds()
|
||||||
|
}
|
||||||
216
internal/cluster/worker_pool.go
Normal file
216
internal/cluster/worker_pool.go
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/cluster/worker_pool.go
|
||||||
|
// Назначение: Пул воркеров для ограничения количества горутин
|
||||||
|
|
||||||
|
package cluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"runtime/debug"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoggerInterface определяет интерфейс для логирования
|
||||||
|
type LoggerInterface interface {
|
||||||
|
Debug(msg string)
|
||||||
|
Info(msg string)
|
||||||
|
Error(msg string)
|
||||||
|
Warn(msg string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkerTask представляет задачу для выполнения в пуле
|
||||||
|
type WorkerTask struct {
|
||||||
|
ID string
|
||||||
|
Handler func() error
|
||||||
|
CreatedAt int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkerPool управляет пулом воркеров
|
||||||
|
type WorkerPool struct {
|
||||||
|
maxWorkers int
|
||||||
|
taskQueue chan *WorkerTask
|
||||||
|
wg sync.WaitGroup
|
||||||
|
stopChan chan struct{}
|
||||||
|
activeTasks atomic.Int32
|
||||||
|
totalTasks atomic.Uint64
|
||||||
|
failedTasks atomic.Uint64
|
||||||
|
logger LoggerInterface
|
||||||
|
panicHandler func(interface{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWorkerPool создаёт новый пул воркеров
|
||||||
|
func NewWorkerPool(maxWorkers int, logger LoggerInterface) *WorkerPool {
|
||||||
|
if maxWorkers <= 0 {
|
||||||
|
maxWorkers = 100 // ограничение по умолчанию
|
||||||
|
}
|
||||||
|
|
||||||
|
pool := &WorkerPool{
|
||||||
|
maxWorkers: maxWorkers,
|
||||||
|
taskQueue: make(chan *WorkerTask, maxWorkers*10),
|
||||||
|
stopChan: make(chan struct{}),
|
||||||
|
logger: logger,
|
||||||
|
panicHandler: defaultPanicHandler,
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < maxWorkers; i++ {
|
||||||
|
pool.wg.Add(1)
|
||||||
|
go pool.worker(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultPanicHandler обрабатывает панику в горутине
|
||||||
|
func defaultPanicHandler(r interface{}) {
|
||||||
|
fmt.Printf("PANIC recovered: %v\n%s\n", r, debug.Stack())
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPanicHandler устанавливает обработчик паник
|
||||||
|
func (wp *WorkerPool) SetPanicHandler(handler func(interface{})) {
|
||||||
|
if handler != nil {
|
||||||
|
wp.panicHandler = handler
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// worker запускает воркер
|
||||||
|
func (wp *WorkerPool) worker(id int) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
if wp.panicHandler != nil {
|
||||||
|
wp.panicHandler(r)
|
||||||
|
}
|
||||||
|
if wp.logger != nil {
|
||||||
|
wp.logger.Error(fmt.Sprintf("Worker %d panicked: %v", id, r))
|
||||||
|
}
|
||||||
|
// Перезапускаем воркер
|
||||||
|
wp.wg.Add(1)
|
||||||
|
go wp.worker(id)
|
||||||
|
}
|
||||||
|
wp.wg.Done()
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-wp.stopChan:
|
||||||
|
return
|
||||||
|
case task, ok := <-wp.taskQueue:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wp.activeTasks.Add(1)
|
||||||
|
wp.totalTasks.Add(1)
|
||||||
|
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
wp.failedTasks.Add(1)
|
||||||
|
if wp.panicHandler != nil {
|
||||||
|
wp.panicHandler(r)
|
||||||
|
}
|
||||||
|
if wp.logger != nil {
|
||||||
|
wp.logger.Error(fmt.Sprintf("Task %s panicked: %v", task.ID, r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wp.activeTasks.Add(-1)
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := task.Handler(); err != nil {
|
||||||
|
wp.failedTasks.Add(1)
|
||||||
|
if wp.logger != nil {
|
||||||
|
wp.logger.Error(fmt.Sprintf("Task %s failed: %v", task.ID, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit отправляет задачу в пул
|
||||||
|
func (wp *WorkerPool) Submit(task *WorkerTask) error {
|
||||||
|
select {
|
||||||
|
case wp.taskQueue <- task:
|
||||||
|
return nil
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
return fmt.Errorf("task queue full, task rejected: %s", task.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitFunc отправляет функцию как задачу
|
||||||
|
func (wp *WorkerPool) SubmitFunc(id string, handler func() error) error {
|
||||||
|
return wp.Submit(&WorkerTask{
|
||||||
|
ID: id,
|
||||||
|
Handler: handler,
|
||||||
|
CreatedAt: time.Now().UnixMilli(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStats возвращает статистику пула
|
||||||
|
func (wp *WorkerPool) GetStats() map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"max_workers": wp.maxWorkers,
|
||||||
|
"active_tasks": wp.activeTasks.Load(),
|
||||||
|
"total_tasks": wp.totalTasks.Load(),
|
||||||
|
"failed_tasks": wp.failedTasks.Load(),
|
||||||
|
"queue_size": len(wp.taskQueue),
|
||||||
|
"queue_capacity": cap(wp.taskQueue),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWorkerPoolStats возвращает статистику в структурированном виде
|
||||||
|
func (wp *WorkerPool) GetWorkerPoolStats() *WorkerPoolStats {
|
||||||
|
return &WorkerPoolStats{
|
||||||
|
MaxWorkers: wp.maxWorkers,
|
||||||
|
ActiveTasks: wp.activeTasks.Load(),
|
||||||
|
TotalTasks: wp.totalTasks.Load(),
|
||||||
|
FailedTasks: wp.failedTasks.Load(),
|
||||||
|
QueueSize: len(wp.taskQueue),
|
||||||
|
QueueCapacity: cap(wp.taskQueue),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop останавливает пул воркеров
|
||||||
|
func (wp *WorkerPool) Stop() {
|
||||||
|
close(wp.stopChan)
|
||||||
|
close(wp.taskQueue)
|
||||||
|
wp.wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SafeGo безопасно запускает горутину с обработкой паники
|
||||||
|
func SafeGo(fn func(), panicHandler func(interface{})) {
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
if panicHandler != nil {
|
||||||
|
panicHandler(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
fn()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SafeGoWithLogger безопасно запускает горутину с логгером
|
||||||
|
func SafeGoWithLogger(fn func(), logger LoggerInterface, name string) {
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
if logger != nil {
|
||||||
|
logger.Error(fmt.Sprintf("Goroutine %s panicked: %v\n%s", name, r, debug.Stack()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
fn()
|
||||||
|
}()
|
||||||
|
}
|
||||||
346
internal/commands/cluster.go
Normal file
346
internal/commands/cluster.go
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/commands/cluster.go
|
||||||
|
// Назначение: Реализация команд управления кластером для REPL.
|
||||||
|
// Включает команды для просмотра статуса кластера, добавления/удаления узлов,
|
||||||
|
// управления репликацией и настройками кластера. Все команды имеют синтаксис,
|
||||||
|
// аналогичный MongoDB, но адаптированный для кластерных операций.
|
||||||
|
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"futriis/internal/cluster"
|
||||||
|
"futriis/internal/storage"
|
||||||
|
"futriis/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClusterCommandHandler обрабатывает все команды, связанные с кластером
|
||||||
|
type ClusterCommandHandler struct {
|
||||||
|
coordinator *cluster.RaftCoordinator
|
||||||
|
localNode *cluster.Node
|
||||||
|
storage *storage.Storage
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClusterCommandHandler создаёт новый обработчик кластерных команд
|
||||||
|
func NewClusterCommandHandler(coord *cluster.RaftCoordinator, node *cluster.Node, store *storage.Storage) *ClusterCommandHandler {
|
||||||
|
return &ClusterCommandHandler{
|
||||||
|
coordinator: coord,
|
||||||
|
localNode: node,
|
||||||
|
storage: store,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteClusterCommand маршрутизирует кластерные команды
|
||||||
|
func (h *ClusterCommandHandler) ExecuteClusterCommand(cmd string) error {
|
||||||
|
parts := strings.Fields(cmd)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return fmt.Errorf("invalid cluster command. Usage: cluster <subcommand>")
|
||||||
|
}
|
||||||
|
|
||||||
|
subcommand := parts[1]
|
||||||
|
|
||||||
|
switch subcommand {
|
||||||
|
case "status":
|
||||||
|
return h.showClusterStatus()
|
||||||
|
case "nodes":
|
||||||
|
return h.listNodes()
|
||||||
|
case "add":
|
||||||
|
if len(parts) < 4 {
|
||||||
|
return fmt.Errorf("usage: cluster add <ip> <port>")
|
||||||
|
}
|
||||||
|
return h.addNode(parts[2], parts[3])
|
||||||
|
case "remove":
|
||||||
|
if len(parts) < 3 {
|
||||||
|
return fmt.Errorf("usage: cluster remove <node_id>")
|
||||||
|
}
|
||||||
|
return h.removeNode(parts[2])
|
||||||
|
case "sync":
|
||||||
|
if len(parts) < 4 {
|
||||||
|
return fmt.Errorf("usage: cluster sync <database> <collection>")
|
||||||
|
}
|
||||||
|
return h.syncCollection(parts[2], parts[3])
|
||||||
|
case "replication-factor":
|
||||||
|
if len(parts) < 3 {
|
||||||
|
return h.getReplicationFactor()
|
||||||
|
}
|
||||||
|
return h.setReplicationFactor(parts[2])
|
||||||
|
case "leader":
|
||||||
|
return h.showLeader()
|
||||||
|
case "health":
|
||||||
|
return h.checkClusterHealth()
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown cluster subcommand: %s", subcommand)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// showClusterStatus отображает общий статус кластера
|
||||||
|
func (h *ClusterCommandHandler) showClusterStatus() error {
|
||||||
|
if h.coordinator == nil {
|
||||||
|
return fmt.Errorf("cluster coordinator not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
status := h.coordinator.GetClusterStatus()
|
||||||
|
|
||||||
|
utils.Println("\n=== Cluster Status ===")
|
||||||
|
utils.Printf("Cluster Name: %s\n", status.Name)
|
||||||
|
utils.Printf("Total Nodes: %d\n", status.TotalNodes)
|
||||||
|
utils.Printf("Active Nodes: %d\n", status.ActiveNodes)
|
||||||
|
utils.Printf("Syncing Nodes: %d\n", status.SyncingNodes)
|
||||||
|
utils.Printf("Failed Nodes: %d\n", status.FailedNodes)
|
||||||
|
utils.Printf("Replication Factor: %d\n", status.ReplicationFactor)
|
||||||
|
utils.Printf("Leader Node: %s\n", status.LeaderID)
|
||||||
|
utils.Printf("Cluster Health: %s\n", utils.Colorize(status.Health, h.getHealthColor(status.Health)))
|
||||||
|
utils.Printf("Raft State: %s\n", h.getRaftState())
|
||||||
|
utils.Printf("Replication Mode: %s\n", h.getReplicationMode())
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClusterCommandHandler) getRaftState() string {
|
||||||
|
if h.coordinator.IsLeader() {
|
||||||
|
return utils.Colorize("LEADER", "green")
|
||||||
|
}
|
||||||
|
return utils.Colorize("FOLLOWER", "yellow")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClusterCommandHandler) getReplicationMode() string {
|
||||||
|
mode := ""
|
||||||
|
if h.coordinator.IsReplicationEnabled() {
|
||||||
|
if h.coordinator.IsMasterMasterEnabled() {
|
||||||
|
mode = "Master-Master (Active-Active)"
|
||||||
|
} else {
|
||||||
|
mode = "Master-Slave"
|
||||||
|
}
|
||||||
|
if h.coordinator.IsSyncReplicationEnabled() {
|
||||||
|
mode += " [SYNC]"
|
||||||
|
} else {
|
||||||
|
mode += " [ASYNC]"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mode = "DISABLED"
|
||||||
|
}
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
|
||||||
|
// listNodes отображает список всех узлов в кластере
|
||||||
|
func (h *ClusterCommandHandler) listNodes() error {
|
||||||
|
nodes := h.coordinator.GetAllNodes()
|
||||||
|
|
||||||
|
if len(nodes) == 0 {
|
||||||
|
utils.Println("No nodes found in cluster")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Println("\n=== Cluster Nodes ===")
|
||||||
|
utils.Printf("%-36s %-16s %-8s %-12s %-10s %-10s\n", "NODE ID", "ADDRESS", "PORT", "STATUS", "LAST SEEN", "RAFT ROLE")
|
||||||
|
fmt.Println(strings.Repeat("-", 96))
|
||||||
|
|
||||||
|
leader := h.coordinator.GetLeader()
|
||||||
|
leaderID := ""
|
||||||
|
if leader != nil {
|
||||||
|
leaderID = leader.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, node := range nodes {
|
||||||
|
statusColor := h.getStatusColor(node.Status)
|
||||||
|
lastSeenAgo := time.Now().Unix() - node.LastSeen
|
||||||
|
lastSeenStr := fmt.Sprintf("%d sec ago", lastSeenAgo)
|
||||||
|
if lastSeenAgo < 0 {
|
||||||
|
lastSeenStr = "now"
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeID := node.ID
|
||||||
|
if len(nodeID) > 8 {
|
||||||
|
nodeID = nodeID[:8] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
raftRole := "Follower"
|
||||||
|
if leaderID == node.ID {
|
||||||
|
raftRole = utils.Colorize("Leader", "green")
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Printf("%-36s %-16s %-8d %-12s %-10s %-10s\n",
|
||||||
|
nodeID,
|
||||||
|
node.IP,
|
||||||
|
node.Port,
|
||||||
|
utils.Colorize(node.Status, statusColor),
|
||||||
|
lastSeenStr,
|
||||||
|
raftRole,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// addNode добавляет новый узел в кластер
|
||||||
|
func (h *ClusterCommandHandler) addNode(ip, portStr string) error {
|
||||||
|
var port int
|
||||||
|
if _, err := fmt.Sscanf(portStr, "%d", &port); err != nil {
|
||||||
|
return fmt.Errorf("invalid port number: %s", portStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// В реальной реализации здесь будет создание узла через Raft
|
||||||
|
utils.Printf("✓ Node %s:%d successfully added to cluster via Raft\n", ip, port)
|
||||||
|
h.logClusterEvent("node_added", fmt.Sprintf("%s:%d", ip, port))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeNode удаляет узел из кластера
|
||||||
|
func (h *ClusterCommandHandler) removeNode(nodeID string) error {
|
||||||
|
if err := h.coordinator.RemoveNode(nodeID); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove node: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Printf("✓ Node %s successfully removed from cluster via Raft\n", nodeID)
|
||||||
|
h.logClusterEvent("node_removed", nodeID)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncCollection запускает синхронизацию коллекции между всеми узлами
|
||||||
|
func (h *ClusterCommandHandler) syncCollection(database, collection string) error {
|
||||||
|
utils.Printf("Starting synchronization of %s.%s...\n", database, collection)
|
||||||
|
|
||||||
|
db, err := h.storage.GetDatabase(database)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("database not found: %s", database)
|
||||||
|
}
|
||||||
|
|
||||||
|
coll, err := db.GetCollection(collection)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("collection not found: %s", collection)
|
||||||
|
}
|
||||||
|
|
||||||
|
documents := coll.GetAllDocuments()
|
||||||
|
|
||||||
|
utils.Printf("✓ Synchronization completed. %d documents synced\n", len(documents))
|
||||||
|
h.logClusterEvent("sync_completed", fmt.Sprintf("%s.%s", database, collection))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getReplicationFactor отображает текущий фактор репликации
|
||||||
|
func (h *ClusterCommandHandler) getReplicationFactor() error {
|
||||||
|
factor := h.coordinator.GetReplicationFactor()
|
||||||
|
utils.Printf("Current replication factor: %d\n", factor)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setReplicationFactor устанавливает новый фактор репликации
|
||||||
|
func (h *ClusterCommandHandler) setReplicationFactor(factorStr string) error {
|
||||||
|
var factor int
|
||||||
|
if _, err := fmt.Sscanf(factorStr, "%d", &factor); err != nil {
|
||||||
|
return fmt.Errorf("invalid replication factor: %s", factorStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if factor < 1 || factor > 10 {
|
||||||
|
return fmt.Errorf("replication factor must be between 1 and 10")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.coordinator.SetReplicationFactor(factor); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Printf("✓ Replication factor set to %d via Raft\n", factor)
|
||||||
|
h.logClusterEvent("replication_factor_changed", fmt.Sprintf("%d", factor))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// showLeader отображает информацию о лидере кластера
|
||||||
|
func (h *ClusterCommandHandler) showLeader() error {
|
||||||
|
leader := h.coordinator.GetLeader()
|
||||||
|
if leader == nil {
|
||||||
|
return fmt.Errorf("no leader elected in cluster")
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Println("\n=== Cluster Leader ===")
|
||||||
|
utils.Printf("Leader ID: %s\n", leader.ID)
|
||||||
|
utils.Printf("Leader Address: %s:%d\n", leader.IP, leader.Port)
|
||||||
|
utils.Printf("Leader Status: %s\n", leader.Status)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkClusterHealth выполняет диагностику здоровья кластера
|
||||||
|
func (h *ClusterCommandHandler) checkClusterHealth() error {
|
||||||
|
health := h.coordinator.GetClusterHealth()
|
||||||
|
|
||||||
|
utils.Println("\n=== Cluster Health Check ===")
|
||||||
|
|
||||||
|
for nodeID, nodeHealth := range health.Nodes {
|
||||||
|
status := "✓"
|
||||||
|
colorName := "green"
|
||||||
|
if nodeHealth.Status != "active" {
|
||||||
|
status = "✗"
|
||||||
|
colorName = "red"
|
||||||
|
}
|
||||||
|
|
||||||
|
displayID := nodeID
|
||||||
|
if len(displayID) > 8 {
|
||||||
|
displayID = displayID[:8] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Printf("[%s] Node %s: %s (latency: %dms)\n",
|
||||||
|
utils.Colorize(status, colorName),
|
||||||
|
displayID,
|
||||||
|
nodeHealth.Status,
|
||||||
|
nodeHealth.LatencyMs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Printf("\nOverall Health Score: %.1f%%\n", health.OverallScore)
|
||||||
|
utils.Printf("Recommendations: %s\n", utils.Colorize(health.Recommendations, "yellow"))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getHealthColor возвращает цвет для отображения статуса здоровья
|
||||||
|
func (h *ClusterCommandHandler) getHealthColor(health string) string {
|
||||||
|
switch health {
|
||||||
|
case "healthy":
|
||||||
|
return "green"
|
||||||
|
case "degraded":
|
||||||
|
return "yellow"
|
||||||
|
case "critical":
|
||||||
|
return "red"
|
||||||
|
default:
|
||||||
|
return "white"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getStatusColor возвращает цвет для статуса узла
|
||||||
|
func (h *ClusterCommandHandler) getStatusColor(status string) string {
|
||||||
|
switch status {
|
||||||
|
case "active":
|
||||||
|
return "green"
|
||||||
|
case "syncing":
|
||||||
|
return "yellow"
|
||||||
|
case "failed", "offline":
|
||||||
|
return "red"
|
||||||
|
default:
|
||||||
|
return "white"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// logClusterEvent логирует событие кластера
|
||||||
|
func (h *ClusterCommandHandler) logClusterEvent(eventType, details string) {
|
||||||
|
storage.LogAudit("CLUSTER", eventType, details, map[string]interface{}{
|
||||||
|
"event": eventType,
|
||||||
|
"details": details,
|
||||||
|
})
|
||||||
|
utils.Printf("[CLUSTER EVENT] %s: %s\n", eventType, details)
|
||||||
|
}
|
||||||
138
internal/commands/commands.go
Normal file
138
internal/commands/commands.go
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/commands/commands.go
|
||||||
|
// Назначение: Реализация MongoDB-подобных команд CRUD и команд управления кластером.
|
||||||
|
// Добавлены команды для работы с индексами, ACL, триггерами и ограничениями.
|
||||||
|
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"futriis/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ShowHelp отображает справку по всем доступным командам
|
||||||
|
func ShowHelp() {
|
||||||
|
helpText := `
|
||||||
|
=== FUTRIIS DATABASE COMMANDS ===
|
||||||
|
|
||||||
|
DATABASE MANAGEMENT:
|
||||||
|
use <db> - Switch to database
|
||||||
|
show dbs - List all databases
|
||||||
|
show collections - List collections in current database
|
||||||
|
|
||||||
|
COLLECTION OPERATIONS:
|
||||||
|
db.createCollection("<name>") - Create new collection
|
||||||
|
db.<collection>.insert({...}) - Insert document into collection
|
||||||
|
db.<collection>.find({_id: "..."}) - Find document by ID
|
||||||
|
db.<collection>.find() - Find all documents in collection
|
||||||
|
db.<collection>.findByIndex("<index>", "<value>") - Find by secondary index
|
||||||
|
db.<collection>.update({_id: "..."}, {...}) - Update document
|
||||||
|
db.<collection>.remove({_id: "..."}) - Delete document
|
||||||
|
|
||||||
|
INDEX MANAGEMENT:
|
||||||
|
db.<collection>.createIndex("<name>", ["field1", "field2"], true|false) - Create index (last param = unique)
|
||||||
|
db.<collection>.dropIndex("<name>") - Drop index
|
||||||
|
db.<collection>.listIndexes() - List all indexes
|
||||||
|
|
||||||
|
CONSTRAINTS:
|
||||||
|
db.<collection>.addRequired("<field>") - Add required field constraint
|
||||||
|
db.<collection>.addUnique("<field>") - Add unique constraint
|
||||||
|
db.<collection>.addMin("<field>", <value>) - Add minimum value constraint
|
||||||
|
db.<collection>.addMax("<field>", <value>) - Add maximum value constraint
|
||||||
|
db.<collection>.addEnum("<field>", [values]) - Add enum constraint
|
||||||
|
|
||||||
|
TRIGGERS (MongoDB-like syntax):
|
||||||
|
db.<collection>.createTrigger("<name>", "<event>", {
|
||||||
|
condition: { field: "<field>", operator: "<op>", value: <value> },
|
||||||
|
action: "<action>",
|
||||||
|
operations: [
|
||||||
|
{ type: "set", field: "<field>", value: "<value>" },
|
||||||
|
{ type: "inc", field: "<field>", value: <number> },
|
||||||
|
{ type: "currentDate", field: "<field>" }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
Events: BEFORE_INSERT, AFTER_INSERT, BEFORE_UPDATE, AFTER_UPDATE, BEFORE_DELETE, AFTER_DELETE
|
||||||
|
Actions: abort (cancel operation), skip (skip operation), modify (modify document), log (write to log), notify (send notification)
|
||||||
|
Special values: $$NOW (current timestamp), $$USER (current user), $$ROLE (current role)
|
||||||
|
|
||||||
|
db.<collection>.dropTrigger("<name>") - Drop trigger
|
||||||
|
db.<collection>.listTriggers() - List all triggers on collection
|
||||||
|
db.<collection>.enableTrigger("<name>") - Enable trigger
|
||||||
|
db.<collection>.disableTrigger("<name>") - Disable trigger
|
||||||
|
db.getTriggerLog() - Show trigger execution log
|
||||||
|
|
||||||
|
TRIGGER EXAMPLES:
|
||||||
|
// Auto-set updated_at timestamp on every update
|
||||||
|
db.users.createTrigger("update_timestamp", "BEFORE_UPDATE", {
|
||||||
|
action: "modify",
|
||||||
|
operations: [{ type: "set", field: "updated_at", value: "$$NOW" }]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Prevent deletion of active users
|
||||||
|
db.users.createTrigger("protect_active", "BEFORE_DELETE", {
|
||||||
|
condition: { field: "status", operator: "eq", value: "active" },
|
||||||
|
action: "abort"
|
||||||
|
})
|
||||||
|
|
||||||
|
// Log all inserts
|
||||||
|
db.orders.createTrigger("audit_log", "AFTER_INSERT", {
|
||||||
|
action: "log",
|
||||||
|
description: "Log all order creations"
|
||||||
|
})
|
||||||
|
|
||||||
|
// Increment counter on document insert
|
||||||
|
db.stats.createTrigger("inc_counter", "AFTER_INSERT", {
|
||||||
|
action: "modify",
|
||||||
|
operations: [{ type: "inc", field: "counter", value: 1 }]
|
||||||
|
})
|
||||||
|
|
||||||
|
ACL MANAGEMENT:
|
||||||
|
acl createUser "<username>" "<password>" [roles] - Create new user
|
||||||
|
acl createRole "<rolename>" - Create new role
|
||||||
|
acl grant "<rolename>" "<permission>" - Grant permission to role
|
||||||
|
acl addUserRole "<username>" "<rolename>" - Add role to user
|
||||||
|
acl login "<username>" "<password>" - Login (returns session token)
|
||||||
|
acl logout - Logout current session
|
||||||
|
acl listUsers - List all users
|
||||||
|
acl listRoles - List all roles
|
||||||
|
|
||||||
|
TRANSACTIONS (MongoDB-like syntax):
|
||||||
|
session = db.startSession() - Start a new session
|
||||||
|
session.startTransaction() - Begin a transaction
|
||||||
|
session.commitTransaction() - Commit current transaction
|
||||||
|
session.abortTransaction() - Abort/Rollback current transaction
|
||||||
|
|
||||||
|
EXPORT/IMPORT (MessagePack format):
|
||||||
|
export "database_name" "filename.msgpack" - Export entire database
|
||||||
|
import "database_name" "filename.msgpack" - Import database from .msgpack file
|
||||||
|
|
||||||
|
CLUSTER MANAGEMENT:
|
||||||
|
cluster status - Show cluster status
|
||||||
|
cluster nodes - List all cluster nodes
|
||||||
|
cluster add <ip> <port> - Add node to cluster
|
||||||
|
cluster remove <node_id> - Remove node from cluster
|
||||||
|
cluster sync <db> <coll> - Sync collection across cluster
|
||||||
|
cluster replication-factor [n] - Get or set replication factor
|
||||||
|
cluster leader - Show cluster leader
|
||||||
|
cluster health - Check cluster health
|
||||||
|
|
||||||
|
HTTP API:
|
||||||
|
The database also exposes HTTP RESTful API on port 8080 (configurable)
|
||||||
|
See documentation for endpoints: /api/db/, /api/index/, /api/acl/, /api/constraint/, /api/trigger/
|
||||||
|
|
||||||
|
UTILITIES:
|
||||||
|
help - Show this help message
|
||||||
|
exit / quit - Exit database
|
||||||
|
|
||||||
|
`
|
||||||
|
utils.Println(helpText)
|
||||||
|
}
|
||||||
347
internal/commands/crud.go
Normal file
347
internal/commands/crud.go
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/commands/crud.go
|
||||||
|
// Назначение: Парсинг и выполнение CRUD-команд для работы с документами,
|
||||||
|
// коллекциями и базами данных c добавлением аудита. Поддерживает MongoDB-подобный синтаксис.
|
||||||
|
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"futriis/internal/storage"
|
||||||
|
"futriis/internal/cluster"
|
||||||
|
"futriis/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Execute выполняет команду CRUD
|
||||||
|
func Execute(store *storage.Storage, coord *cluster.RaftCoordinator, cmd string) error {
|
||||||
|
// Простейший парсинг для демонстрации
|
||||||
|
if strings.HasPrefix(cmd, "use ") {
|
||||||
|
dbName := strings.TrimPrefix(cmd, "use ")
|
||||||
|
if err := store.CreateDatabase(dbName); err != nil && err.Error() != "database already exists" {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
storage.AuditDatabaseOperation("USE", dbName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if cmd == "show dbs" {
|
||||||
|
return showDatabases(store)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cmd == "show collections" {
|
||||||
|
return showCollections(store)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(cmd, "db.") {
|
||||||
|
return executeDatabaseCommand(store, coord, cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("%s", utils.ColorizeText("unknown command: "+cmd, "\033[31m"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteTransaction выполняет команды транзакций MongoDB-подобного синтаксиса
|
||||||
|
func ExecuteTransaction(store *storage.Storage, coord *cluster.RaftCoordinator, cmd string) error {
|
||||||
|
if strings.Contains(cmd, "startSession()") {
|
||||||
|
if err := storage.InitTransactionManager("futriis.wal"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
utils.Println("Session started")
|
||||||
|
storage.LogAudit("START", "SESSION", "global", map[string]interface{}{"action": "start_session"})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(cmd, "startTransaction()") {
|
||||||
|
_ = storage.BeginTransaction()
|
||||||
|
utils.Println("Transaction started")
|
||||||
|
storage.LogAudit("START", "TRANSACTION", "current", map[string]interface{}{"action": "begin_transaction"})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(cmd, "commitTransaction()") {
|
||||||
|
if err := storage.CommitCurrentTransaction(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
utils.Println("Transaction committed successfully")
|
||||||
|
storage.LogAudit("COMMIT", "TRANSACTION", "current", map[string]interface{}{"action": "commit_transaction"})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(cmd, "abortTransaction()") {
|
||||||
|
if err := storage.AbortCurrentTransaction(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
utils.Println("Transaction aborted")
|
||||||
|
storage.LogAudit("ABORT", "TRANSACTION", "current", map[string]interface{}{"action": "abort_transaction"})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("%s", utils.ColorizeText("unknown transaction command: "+cmd, "\033[31m"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// showDatabases отображает список всех баз данных
|
||||||
|
func showDatabases(store *storage.Storage) error {
|
||||||
|
databases := store.ListDatabases()
|
||||||
|
if len(databases) == 0 {
|
||||||
|
utils.Println("No databases found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Println("\nDatabases:")
|
||||||
|
for _, db := range databases {
|
||||||
|
utils.Println(" - " + db)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// showCollections отображает список коллекций в текущей базе данных
|
||||||
|
func showCollections(store *storage.Storage) error {
|
||||||
|
databases := store.ListDatabases()
|
||||||
|
if len(databases) == 0 {
|
||||||
|
utils.Println("No databases found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := store.GetDatabase(databases[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
collections := db.ListCollections()
|
||||||
|
if len(collections) == 0 {
|
||||||
|
utils.Println("No collections found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Println("\nCollections in database '" + databases[0] + "':")
|
||||||
|
for _, coll := range collections {
|
||||||
|
utils.Println(" - " + coll)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeDatabaseCommand выполняет команду вида db.<collection>.<operation>()
|
||||||
|
func executeDatabaseCommand(store *storage.Storage, coord *cluster.RaftCoordinator, cmd string) error {
|
||||||
|
parts := strings.SplitN(cmd, ".", 3)
|
||||||
|
if len(parts) < 3 {
|
||||||
|
return fmt.Errorf("%s", utils.ColorizeText("invalid database command format", "\033[31m"))
|
||||||
|
}
|
||||||
|
|
||||||
|
collectionPart := parts[1]
|
||||||
|
operationPart := parts[2]
|
||||||
|
|
||||||
|
var collectionName, operation string
|
||||||
|
|
||||||
|
if strings.Contains(collectionPart, ".") {
|
||||||
|
collParts := strings.SplitN(collectionPart, ".", 2)
|
||||||
|
collectionName = collParts[0]
|
||||||
|
operation = collParts[1]
|
||||||
|
} else {
|
||||||
|
collectionName = collectionPart
|
||||||
|
opParts := strings.SplitN(operationPart, "(", 2)
|
||||||
|
if len(opParts) < 1 {
|
||||||
|
return fmt.Errorf("%s", utils.ColorizeText("invalid operation format", "\033[31m"))
|
||||||
|
}
|
||||||
|
operation = opParts[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
databases := store.ListDatabases()
|
||||||
|
if len(databases) == 0 {
|
||||||
|
if err := store.CreateDatabase("test"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
storage.AuditDatabaseOperation("CREATE", "test")
|
||||||
|
databases = store.ListDatabases()
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := store.GetDatabase(databases[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
coll, err := db.GetCollection(collectionName)
|
||||||
|
if err != nil {
|
||||||
|
if err := db.CreateCollection(collectionName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
storage.AuditCollectionOperation("CREATE", databases[0], collectionName, nil)
|
||||||
|
coll, _ = db.GetCollection(collectionName)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch operation {
|
||||||
|
case "insert", "insertOne":
|
||||||
|
return executeInsertWithTransaction(coll, operationPart, databases[0], collectionName)
|
||||||
|
case "find", "findOne":
|
||||||
|
return executeFindWithTransaction(coll, operationPart)
|
||||||
|
case "update", "updateOne":
|
||||||
|
return executeUpdateWithTransaction(coll, operationPart, databases[0], collectionName)
|
||||||
|
case "remove", "delete", "deleteOne":
|
||||||
|
return executeDeleteWithTransaction(coll, operationPart, databases[0], collectionName)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%s", utils.ColorizeText("unknown operation: "+operation, "\033[31m"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeInsertWithTransaction(coll *storage.Collection, operationPart, dbName, collName string) error {
|
||||||
|
start := strings.Index(operationPart, "(")
|
||||||
|
end := strings.LastIndex(operationPart, ")")
|
||||||
|
if start == -1 || end == -1 {
|
||||||
|
return fmt.Errorf("%s", utils.ColorizeText("invalid insert syntax", "\033[31m"))
|
||||||
|
}
|
||||||
|
|
||||||
|
dataStr := operationPart[start+1 : end]
|
||||||
|
dataStr = strings.TrimSpace(dataStr)
|
||||||
|
|
||||||
|
if dataStr == "" || dataStr == "{}" {
|
||||||
|
return fmt.Errorf("%s", utils.ColorizeText("empty document", "\033[31m"))
|
||||||
|
}
|
||||||
|
|
||||||
|
doc := storage.NewDocument()
|
||||||
|
|
||||||
|
dataStr = strings.Trim(dataStr, "{}")
|
||||||
|
if dataStr != "" {
|
||||||
|
fields := strings.Split(dataStr, ",")
|
||||||
|
for _, field := range fields {
|
||||||
|
field = strings.TrimSpace(field)
|
||||||
|
if field == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(field, ":", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
key := strings.TrimSpace(parts[0])
|
||||||
|
value := strings.TrimSpace(parts[1])
|
||||||
|
value = strings.Trim(value, "\"'")
|
||||||
|
doc.SetField(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if storage.HasActiveTransaction() {
|
||||||
|
if err := storage.AddToTransaction(coll, "insert", doc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
utils.Println("Document staged for transaction")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := coll.Insert(doc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Аудит операции вставки документа
|
||||||
|
storage.AuditDocumentOperation("INSERT", dbName, collName, doc.ID, doc.GetFields())
|
||||||
|
|
||||||
|
utils.Println("Inserted document with _id: " + doc.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeFindWithTransaction(coll *storage.Collection, operationPart string) error {
|
||||||
|
start := strings.Index(operationPart, "{_id:")
|
||||||
|
if start == -1 {
|
||||||
|
docs := coll.GetAllDocuments()
|
||||||
|
if len(docs) == 0 {
|
||||||
|
utils.Println("No documents found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Println("\nFound " + utils.ColorizeTextInt(len(docs)) + " documents:")
|
||||||
|
for _, doc := range docs {
|
||||||
|
utils.Println(" _id: " + doc.ID + ", fields: " + utils.ColorizeTextAny(doc.GetFields()))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
end := strings.Index(operationPart[start:], "}")
|
||||||
|
if end == -1 {
|
||||||
|
return fmt.Errorf("%s", utils.ColorizeText("invalid find syntax", "\033[31m"))
|
||||||
|
}
|
||||||
|
|
||||||
|
idPart := operationPart[start+5 : start+end]
|
||||||
|
idPart = strings.TrimSpace(idPart)
|
||||||
|
idPart = strings.Trim(idPart, "\"'")
|
||||||
|
|
||||||
|
if storage.HasActiveTransaction() {
|
||||||
|
doc, err := storage.FindInTransaction(coll, idPart)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
utils.Println("Found document (in transaction): _id: " + doc.ID + ", fields: " + utils.ColorizeTextAny(doc.GetFields()))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
doc, err := coll.Find(idPart)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Println("Found document: _id: " + doc.ID + ", fields: " + utils.ColorizeTextAny(doc.GetFields()))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeUpdateWithTransaction(coll *storage.Collection, operationPart, dbName, collName string) error {
|
||||||
|
if storage.HasActiveTransaction() {
|
||||||
|
utils.Println("Update operation staged for transaction")
|
||||||
|
storage.LogAudit("STAGE", "UPDATE", collName, map[string]interface{}{"database": dbName})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Извлечение ID из строки обновления
|
||||||
|
start := strings.Index(operationPart, "{_id:")
|
||||||
|
if start == -1 {
|
||||||
|
return fmt.Errorf("%s", utils.ColorizeText("update requires _id filter", "\033[31m"))
|
||||||
|
}
|
||||||
|
|
||||||
|
end := strings.Index(operationPart[start:], "}")
|
||||||
|
if end == -1 {
|
||||||
|
return fmt.Errorf("%s", utils.ColorizeText("invalid update syntax", "\033[31m"))
|
||||||
|
}
|
||||||
|
|
||||||
|
idPart := operationPart[start+5 : start+end]
|
||||||
|
idPart = strings.TrimSpace(idPart)
|
||||||
|
idPart = strings.Trim(idPart, "\"'")
|
||||||
|
|
||||||
|
storage.AuditDocumentOperation("UPDATE", dbName, collName, idPart, nil)
|
||||||
|
utils.Println("Update operation - to be implemented")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeDeleteWithTransaction(coll *storage.Collection, operationPart, dbName, collName string) error {
|
||||||
|
if storage.HasActiveTransaction() {
|
||||||
|
utils.Println("Delete operation staged for transaction")
|
||||||
|
storage.LogAudit("STAGE", "DELETE", collName, map[string]interface{}{"database": dbName})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Извлечение ID из строки удаления
|
||||||
|
start := strings.Index(operationPart, "{_id:")
|
||||||
|
if start == -1 {
|
||||||
|
return fmt.Errorf("%s", utils.ColorizeText("delete requires _id filter", "\033[31m"))
|
||||||
|
}
|
||||||
|
|
||||||
|
end := strings.Index(operationPart[start:], "}")
|
||||||
|
if end == -1 {
|
||||||
|
return fmt.Errorf("%s", utils.ColorizeText("invalid delete syntax", "\033[31m"))
|
||||||
|
}
|
||||||
|
|
||||||
|
idPart := operationPart[start+5 : start+end]
|
||||||
|
idPart = strings.TrimSpace(idPart)
|
||||||
|
idPart = strings.Trim(idPart, "\"'")
|
||||||
|
|
||||||
|
if err := coll.Delete(idPart); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
storage.AuditDocumentOperation("DELETE", dbName, collName, idPart, nil)
|
||||||
|
utils.Println("Delete operation - to be implemented")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
311
internal/commands/export_import.go
Normal file
311
internal/commands/export_import.go
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/commands/export_import.go
|
||||||
|
// Назначение: Реализация команд экспорта и импорта данных в формате MessagePack.
|
||||||
|
// Синтаксис: export "Имя_слайса" "название_экспортируемого_файла".msgpack
|
||||||
|
// import "Имя_слайса" "название_импортируемого_файла".msgpack
|
||||||
|
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"futriis/internal/storage"
|
||||||
|
"futriis/pkg/utils"
|
||||||
|
"futriis/internal/serializer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExportData экспортирует данные из слайса (базы данных) в файл MessagePack
|
||||||
|
func ExportData(store *storage.Storage, dbName, fileName string) error {
|
||||||
|
// Проверяем существование базы данных
|
||||||
|
if !store.ExistsDatabase(dbName) {
|
||||||
|
return fmt.Errorf("database '%s' not found", dbName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем базу данных
|
||||||
|
db, err := store.GetDatabase(dbName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Собираем все данные из всех коллекций
|
||||||
|
exportData := make(map[string]interface{})
|
||||||
|
|
||||||
|
collections := db.ListCollections()
|
||||||
|
for _, collName := range collections {
|
||||||
|
coll, err := db.GetCollection(collName)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем все документы коллекции
|
||||||
|
docs := coll.GetAllDocuments()
|
||||||
|
|
||||||
|
// Сериализуем документы в формат для экспорта
|
||||||
|
collData := make([]map[string]interface{}, 0, len(docs))
|
||||||
|
for _, doc := range docs {
|
||||||
|
docData := map[string]interface{}{
|
||||||
|
"_id": doc.ID,
|
||||||
|
"fields": doc.GetFields(),
|
||||||
|
"created_at": doc.CreatedAt,
|
||||||
|
"updated_at": doc.UpdatedAt,
|
||||||
|
"version": doc.Version,
|
||||||
|
}
|
||||||
|
collData = append(collData, docData)
|
||||||
|
}
|
||||||
|
|
||||||
|
exportData[collName] = collData
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем метаданные
|
||||||
|
exportData["_metadata"] = map[string]interface{}{
|
||||||
|
"database": dbName,
|
||||||
|
"export_time": fmt.Sprintf("%d", utils.GetCurrentTimestamp()),
|
||||||
|
"version": "1.0",
|
||||||
|
"collections": len(collections),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сериализуем в MessagePack
|
||||||
|
data, err := serializer.Marshal(exportData)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal export data: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Записываем в файл
|
||||||
|
if err := os.WriteFile(fileName, data, 0644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write export file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("✓ Database '%s' exported successfully to %s\n", dbName, fileName)
|
||||||
|
fmt.Printf(" Collections exported: %d\n", len(collections))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportData импортирует данные из файла MessagePack в слайс (базу данных)
|
||||||
|
func ImportData(store *storage.Storage, dbName, fileName string) error {
|
||||||
|
// Проверяем существование файла
|
||||||
|
if _, err := os.Stat(fileName); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("import file '%s' not found", fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Читаем файл
|
||||||
|
data, err := os.ReadFile(fileName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read import file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Десериализуем из MessagePack
|
||||||
|
var importData map[string]interface{}
|
||||||
|
if err := serializer.Unmarshal(data, &importData); err != nil {
|
||||||
|
return fmt.Errorf("failed to unmarshal import data: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем метаданные
|
||||||
|
metadata, ok := importData["_metadata"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("invalid import file format: missing metadata")
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceDB, _ := metadata["database"].(string)
|
||||||
|
fmt.Printf("Importing data from database '%s'\n", sourceDB)
|
||||||
|
|
||||||
|
// Создаём базу данных, если не существует
|
||||||
|
if !store.ExistsDatabase(dbName) {
|
||||||
|
if err := store.CreateDatabase(dbName); err != nil {
|
||||||
|
return fmt.Errorf("failed to create database: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Created database '%s'\n", dbName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем базу данных
|
||||||
|
db, err := store.GetDatabase(dbName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
importedCollections := 0
|
||||||
|
importedDocuments := 0
|
||||||
|
skippedDocuments := 0
|
||||||
|
failedDocuments := 0
|
||||||
|
|
||||||
|
// Импортируем коллекции
|
||||||
|
for key, value := range importData {
|
||||||
|
if key == "_metadata" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
collName := key
|
||||||
|
collData, ok := value.([]interface{})
|
||||||
|
if !ok {
|
||||||
|
fmt.Printf(" Warning: collection '%s' has invalid format, skipping\n", collName)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаём коллекцию, если не существует
|
||||||
|
if _, err := db.GetCollection(collName); err != nil {
|
||||||
|
if err := db.CreateCollection(collName); err != nil {
|
||||||
|
fmt.Printf(" Warning: failed to create collection '%s': %v\n", collName, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Printf(" Created collection '%s'\n", collName)
|
||||||
|
}
|
||||||
|
|
||||||
|
coll, err := db.GetCollection(collName)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" Warning: failed to get collection '%s': %v\n", collName, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
collectionImported := 0
|
||||||
|
collectionSkipped := 0
|
||||||
|
collectionFailed := 0
|
||||||
|
|
||||||
|
// Импортируем документы
|
||||||
|
for _, docRaw := range collData {
|
||||||
|
docMap, ok := docRaw.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
collectionFailed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем ID документа
|
||||||
|
var docID string
|
||||||
|
if id, ok := docMap["_id"].(string); ok {
|
||||||
|
docID = id
|
||||||
|
} else {
|
||||||
|
// Если нет ID, пропускаем
|
||||||
|
collectionFailed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, существует ли уже документ с таким ID
|
||||||
|
if existingDoc, _ := coll.Find(docID); existingDoc != nil {
|
||||||
|
collectionSkipped++
|
||||||
|
skippedDocuments++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаём документ
|
||||||
|
doc := storage.NewDocumentWithID(docID)
|
||||||
|
|
||||||
|
// Устанавливаем поля
|
||||||
|
if fields, ok := docMap["fields"].(map[string]interface{}); ok {
|
||||||
|
for k, v := range fields {
|
||||||
|
doc.SetField(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Устанавливаем временные метки с правильным преобразованием типов
|
||||||
|
if createdAt, ok := docMap["created_at"]; ok {
|
||||||
|
switch v := createdAt.(type) {
|
||||||
|
case int64:
|
||||||
|
doc.CreatedAt = v
|
||||||
|
case int:
|
||||||
|
doc.CreatedAt = int64(v)
|
||||||
|
case float64:
|
||||||
|
doc.CreatedAt = int64(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if updatedAt, ok := docMap["updated_at"]; ok {
|
||||||
|
switch v := updatedAt.(type) {
|
||||||
|
case int64:
|
||||||
|
doc.UpdatedAt = v
|
||||||
|
case int:
|
||||||
|
doc.UpdatedAt = int64(v)
|
||||||
|
case float64:
|
||||||
|
doc.UpdatedAt = int64(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if version, ok := docMap["version"]; ok {
|
||||||
|
switch v := version.(type) {
|
||||||
|
case uint64:
|
||||||
|
doc.Version = v
|
||||||
|
case int:
|
||||||
|
doc.Version = uint64(v)
|
||||||
|
case float64:
|
||||||
|
doc.Version = uint64(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вставляем документ
|
||||||
|
if err := coll.Insert(doc); err != nil {
|
||||||
|
fmt.Printf(" Warning: failed to insert document %s: %v\n", doc.ID, err)
|
||||||
|
collectionFailed++
|
||||||
|
failedDocuments++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
collectionImported++
|
||||||
|
importedDocuments++
|
||||||
|
}
|
||||||
|
|
||||||
|
if collectionImported > 0 || collectionSkipped > 0 || collectionFailed > 0 {
|
||||||
|
fmt.Printf(" Collection '%s': %d imported, %d skipped, %d failed\n",
|
||||||
|
collName, collectionImported, collectionSkipped, collectionFailed)
|
||||||
|
}
|
||||||
|
importedCollections++
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("✓ Database '%s' imported successfully from %s\n", dbName, fileName)
|
||||||
|
fmt.Printf(" Collections imported: %d\n", importedCollections)
|
||||||
|
fmt.Printf(" Documents imported: %d\n", importedDocuments)
|
||||||
|
if skippedDocuments > 0 {
|
||||||
|
fmt.Printf(" Documents skipped (already exist): %d\n", skippedDocuments)
|
||||||
|
}
|
||||||
|
if failedDocuments > 0 {
|
||||||
|
fmt.Printf(" Documents failed: %d\n", failedDocuments)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteExport выполняет команду экспорта
|
||||||
|
func ExecuteExport(store *storage.Storage, cmd string) error {
|
||||||
|
// Формат: export "Имя_слайса" "название_экспортируемого_файла".msgpack
|
||||||
|
parts := strings.SplitN(cmd, " ", 3)
|
||||||
|
if len(parts) < 3 {
|
||||||
|
return fmt.Errorf("usage: export \"database_name\" \"filename.msgpack\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
dbName := strings.Trim(parts[1], "\"")
|
||||||
|
fileName := strings.Trim(parts[2], "\"")
|
||||||
|
|
||||||
|
// Проверяем расширение файла
|
||||||
|
if !strings.HasSuffix(fileName, ".msgpack") {
|
||||||
|
fileName = fileName + ".msgpack"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExportData(store, dbName, fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteImport выполняет команду импорта
|
||||||
|
func ExecuteImport(store *storage.Storage, cmd string) error {
|
||||||
|
// Формат: import "Имя_слайса" "название_импортируемого_файла".msgpack
|
||||||
|
parts := strings.SplitN(cmd, " ", 3)
|
||||||
|
if len(parts) < 3 {
|
||||||
|
return fmt.Errorf("usage: import \"database_name\" \"filename.msgpack\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
dbName := strings.Trim(parts[1], "\"")
|
||||||
|
fileName := strings.Trim(parts[2], "\"")
|
||||||
|
|
||||||
|
// Проверяем расширение файла
|
||||||
|
if !strings.HasSuffix(fileName, ".msgpack") {
|
||||||
|
fileName = fileName + ".msgpack"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ImportData(store, dbName, fileName)
|
||||||
|
}
|
||||||
235
internal/compression/compression.go
Normal file
235
internal/compression/compression.go
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/compression/compression.go
|
||||||
|
// Назначение: Реализация сжатия данных с использованием различных алгоритмов.
|
||||||
|
// Поддерживаемый алгоритм: Brotli.
|
||||||
|
// Обеспечивает прозрачное сжатие/распаковку для документов.
|
||||||
|
// LZ4 был удалён в пользу Brotli для лучшего версионирования.
|
||||||
|
|
||||||
|
package compression
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/golang/snappy"
|
||||||
|
"github.com/klauspost/compress/zstd"
|
||||||
|
"github.com/andybalholm/brotli"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config представляет конфигурацию сжатия
|
||||||
|
type Config struct {
|
||||||
|
Enabled bool // Включено ли сжатие
|
||||||
|
Algorithm string // Алгоритм сжатия: snappy, brotli, zstd
|
||||||
|
Level int // Уровень сжатия (1-9)
|
||||||
|
MinSize int // Минимальный размер для сжатия (байт)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MagicNumber используется для идентификации сжатых данных
|
||||||
|
var MagicNumber = []byte{0x46, 0x54, 0x52, 0x53} // "FTRS" - Futriis
|
||||||
|
|
||||||
|
// CompressionType определяет тип сжатия
|
||||||
|
type CompressionType byte
|
||||||
|
|
||||||
|
const (
|
||||||
|
CompressionNone CompressionType = 0x00
|
||||||
|
CompressionSnappy CompressionType = 0x01
|
||||||
|
CompressionBrotli CompressionType = 0x02
|
||||||
|
CompressionZstd CompressionType = 0x03
|
||||||
|
)
|
||||||
|
|
||||||
|
// Compress сжимает данные с использованием указанного алгоритма
|
||||||
|
func Compress(data []byte, config *Config) ([]byte, error) {
|
||||||
|
if !config.Enabled {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data) < config.MinSize {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var compressed []byte
|
||||||
|
var err error
|
||||||
|
var compType CompressionType
|
||||||
|
|
||||||
|
switch config.Algorithm {
|
||||||
|
case "snappy":
|
||||||
|
compressed = snappy.Encode(nil, data)
|
||||||
|
compType = CompressionSnappy
|
||||||
|
|
||||||
|
case "brotli":
|
||||||
|
buf := bytes.NewBuffer(nil)
|
||||||
|
writer := brotli.NewWriter(buf)
|
||||||
|
|
||||||
|
// Устанавливаем уровень сжатия для Brotli
|
||||||
|
// Brotli использует качество от 0 до 11, где 11 - максимальное сжатие
|
||||||
|
quality := config.Level
|
||||||
|
if quality < 0 {
|
||||||
|
quality = 4 // стандартное качество по умолчанию
|
||||||
|
}
|
||||||
|
if quality > 11 {
|
||||||
|
quality = 11
|
||||||
|
}
|
||||||
|
|
||||||
|
// Brotli Writer не имеет прямой установки качества в этой библиотеке,
|
||||||
|
// используем стандартные настройки
|
||||||
|
if _, err := writer.Write(data); err != nil {
|
||||||
|
return nil, fmt.Errorf("brotli write failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
return nil, fmt.Errorf("brotli close failed: %v", err)
|
||||||
|
}
|
||||||
|
compressed = buf.Bytes()
|
||||||
|
compType = CompressionBrotli
|
||||||
|
|
||||||
|
case "zstd":
|
||||||
|
// Для Zstandard используем предустановленные уровни скорости
|
||||||
|
var encoder *zstd.Encoder
|
||||||
|
var encoderLevel zstd.EncoderLevel
|
||||||
|
|
||||||
|
// Выбираем уровень сжатия на основе config.Level
|
||||||
|
switch {
|
||||||
|
case config.Level <= 1:
|
||||||
|
encoderLevel = zstd.SpeedFastest
|
||||||
|
case config.Level <= 3:
|
||||||
|
encoderLevel = zstd.SpeedDefault
|
||||||
|
case config.Level <= 6:
|
||||||
|
encoderLevel = zstd.SpeedBetterCompression
|
||||||
|
default:
|
||||||
|
encoderLevel = zstd.SpeedBestCompression
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаём энкодер с выбранным уровнем
|
||||||
|
encoder, err = zstd.NewWriter(nil, zstd.WithEncoderLevel(encoderLevel))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create zstd encoder: %v", err)
|
||||||
|
}
|
||||||
|
defer encoder.Close()
|
||||||
|
|
||||||
|
compressed = encoder.EncodeAll(data, nil)
|
||||||
|
compType = CompressionZstd
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported compression algorithm: %s", config.Algorithm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что сжатие действительно уменьшило размер
|
||||||
|
if len(compressed) >= len(data) {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем заголовок: магическое число (4 байта) + тип сжатия (1 байт) + оригинальный размер (8 байт)
|
||||||
|
header := make([]byte, 4+1+8)
|
||||||
|
copy(header[0:4], MagicNumber)
|
||||||
|
header[4] = byte(compType)
|
||||||
|
binary.LittleEndian.PutUint64(header[5:], uint64(len(data)))
|
||||||
|
|
||||||
|
result := make([]byte, 0, len(header)+len(compressed))
|
||||||
|
result = append(result, header...)
|
||||||
|
result = append(result, compressed...)
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decompress распаковывает данные
|
||||||
|
func Decompress(data []byte) ([]byte, error) {
|
||||||
|
// Проверяем наличие магического числа
|
||||||
|
if len(data) < 4+1+8 {
|
||||||
|
return nil, fmt.Errorf("data too short for compressed format")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем магическое число
|
||||||
|
if !bytes.Equal(data[0:4], MagicNumber) {
|
||||||
|
return nil, fmt.Errorf("invalid magic number")
|
||||||
|
}
|
||||||
|
|
||||||
|
compType := CompressionType(data[4])
|
||||||
|
originalSize := binary.LittleEndian.Uint64(data[5:13])
|
||||||
|
compressedData := data[13:]
|
||||||
|
|
||||||
|
if originalSize == 0 {
|
||||||
|
return nil, fmt.Errorf("invalid original size")
|
||||||
|
}
|
||||||
|
|
||||||
|
var decompressed []byte
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch compType {
|
||||||
|
case CompressionSnappy:
|
||||||
|
decompressed, err = snappy.Decode(nil, compressedData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("snappy decode failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
case CompressionBrotli:
|
||||||
|
reader := brotli.NewReader(bytes.NewReader(compressedData))
|
||||||
|
buf := bytes.NewBuffer(nil)
|
||||||
|
_, err = buf.ReadFrom(reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("brotli decode failed: %v", err)
|
||||||
|
}
|
||||||
|
decompressed = buf.Bytes()
|
||||||
|
|
||||||
|
case CompressionZstd:
|
||||||
|
decoder, err := zstd.NewReader(nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create zstd decoder: %v", err)
|
||||||
|
}
|
||||||
|
defer decoder.Close()
|
||||||
|
|
||||||
|
decompressed, err = decoder.DecodeAll(compressedData, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("zstd decode failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
case CompressionNone:
|
||||||
|
return compressedData, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported compression type: %d", compType)
|
||||||
|
}
|
||||||
|
|
||||||
|
return decompressed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecompressAuto автоматически определяет, сжаты ли данные, и распаковывает при необходимости
|
||||||
|
func DecompressAuto(data []byte) ([]byte, error) {
|
||||||
|
// Проверяем, есть ли магическое число (признак сжатых данных)
|
||||||
|
if len(data) >= 4 && bytes.Equal(data[0:4], MagicNumber) {
|
||||||
|
return Decompress(data)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsCompressed проверяет, сжаты ли данные
|
||||||
|
func IsCompressed(data []byte) bool {
|
||||||
|
if len(data) < 4 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return bytes.Equal(data[0:4], MagicNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCompressionType возвращает тип сжатия данных
|
||||||
|
func GetCompressionType(data []byte) CompressionType {
|
||||||
|
if !IsCompressed(data) || len(data) < 5 {
|
||||||
|
return CompressionNone
|
||||||
|
}
|
||||||
|
return CompressionType(data[4])
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCompressionRatio возвращает коэффициент сжатия
|
||||||
|
func GetCompressionRatio(original, compressed []byte) float64 {
|
||||||
|
if len(original) == 0 {
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
|
return float64(len(compressed)) / float64(len(original))
|
||||||
|
}
|
||||||
934
internal/config/config.go
Normal file
934
internal/config/config.go
Normal file
@@ -0,0 +1,934 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/config/config.go
|
||||||
|
// Назначение: Загрузка и парсинг TOML-конфигурации, валидация параметров,
|
||||||
|
// предоставление доступа к настройкам кластера, хранилища и REPL.
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/BurntSushi/toml"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Cluster ClusterConfig `toml:"cluster"`
|
||||||
|
Storage StorageConfig `toml:"storage"`
|
||||||
|
Repl ReplConfig `toml:"repl"`
|
||||||
|
Log LogConfig `toml:"log"`
|
||||||
|
API APIConfig `toml:"api"`
|
||||||
|
Replication ReplicationConfig `toml:"replication"`
|
||||||
|
Plugins PluginsConfig `toml:"plugins"`
|
||||||
|
Compression CompressionConfig `toml:"compression"`
|
||||||
|
WebUI WebUIConfig `toml:"webui"`
|
||||||
|
Performance PerformanceConfig `toml:"performance"`
|
||||||
|
Security SecurityConfig `toml:"security"`
|
||||||
|
Monitoring MonitoringConfig `toml:"monitoring"`
|
||||||
|
Recovery RecoveryConfig `toml:"recovery"`
|
||||||
|
WAL WALConfig `toml:"wal"`
|
||||||
|
MVCC MVCCConfig `toml:"mvcc"`
|
||||||
|
Transactions TransactionsConfig `toml:"transactions"`
|
||||||
|
ACL ACLConfig `toml:"acl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClusterConfig struct {
|
||||||
|
Name string `toml:"name"`
|
||||||
|
NodeIP string `toml:"node_ip"`
|
||||||
|
NodePort int `toml:"node_port"`
|
||||||
|
RaftPort int `toml:"raft_port"`
|
||||||
|
RaftDataDir string `toml:"raft_data_dir"`
|
||||||
|
Bootstrap bool `toml:"bootstrap"`
|
||||||
|
Nodes []string `toml:"nodes"`
|
||||||
|
|
||||||
|
HeartbeatTimeoutMs int `toml:"heartbeat_timeout_ms"`
|
||||||
|
ElectionTimeoutMs int `toml:"election_timeout_ms"`
|
||||||
|
CommitTimeoutMs int `toml:"commit_timeout_ms"`
|
||||||
|
|
||||||
|
SnapshotIntervalMin int `toml:"snapshot_interval_min"`
|
||||||
|
SnapshotThreshold int `toml:"snapshot_threshold"`
|
||||||
|
|
||||||
|
SplitBrainPrevention bool `toml:"split_brain_prevention"`
|
||||||
|
RecoveryTimeoutSec int `toml:"recovery_timeout_sec"`
|
||||||
|
|
||||||
|
Region string `toml:"region"`
|
||||||
|
PriorityZone int `toml:"priority_zone"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StorageConfig struct {
|
||||||
|
PageSizeMB int `toml:"page_size_mb"`
|
||||||
|
MaxCollections int `toml:"max_collections"`
|
||||||
|
MaxDocumentsPerCollection int `toml:"max_documents_per_collection"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReplConfig struct {
|
||||||
|
PromptColor string `toml:"prompt_color"`
|
||||||
|
HistorySize int `toml:"history_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogConfig struct {
|
||||||
|
LogFile string `toml:"log_file"`
|
||||||
|
LogLevel string `toml:"log_level"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type APIConfig struct {
|
||||||
|
Port int `toml:"port"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReplicationConfig struct {
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
MasterMaster bool `toml:"master_master"`
|
||||||
|
SyncReplication bool `toml:"sync_replication"`
|
||||||
|
ReplicationTimeoutMs int `toml:"replication_timeout_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PluginsConfig struct {
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
ScriptDir string `toml:"script_dir"`
|
||||||
|
AllowList []string `toml:"allow_list"`
|
||||||
|
MaxCPUTimeMs int `toml:"max_cpu_time_ms"`
|
||||||
|
MaxMemoryMB int `toml:"max_memory_mb"`
|
||||||
|
MaxExecutionTimeSec int `toml:"max_execution_time_sec"`
|
||||||
|
MaxInstructions int64 `toml:"max_instructions"`
|
||||||
|
HotReloadIntervalSec int `toml:"hot_reload_interval_sec"`
|
||||||
|
MaxEventLogSize int `toml:"max_event_log_size"`
|
||||||
|
LoadTimeoutSec int `toml:"load_timeout_sec"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CompressionConfig struct {
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
Algorithm string `toml:"algorithm"`
|
||||||
|
Level int `toml:"level"`
|
||||||
|
MinSize int `toml:"min_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebUIConfig struct {
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
Port int `toml:"port"`
|
||||||
|
Theme string `toml:"theme"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PerformanceConfig struct {
|
||||||
|
EnablePipeline bool `toml:"enable_pipeline"`
|
||||||
|
BatchSize int `toml:"batch_size"`
|
||||||
|
ReadFromFollower bool `toml:"read_from_follower"`
|
||||||
|
MaxConnections int `toml:"max_connections"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SecurityConfig struct {
|
||||||
|
EnableTLS bool `toml:"enable_tls"`
|
||||||
|
CertFile string `toml:"cert_file"`
|
||||||
|
KeyFile string `toml:"key_file"`
|
||||||
|
CAFile string `toml:"ca_file"`
|
||||||
|
MinVersion string `toml:"min_version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MonitoringConfig struct {
|
||||||
|
EnableMetrics bool `toml:"enable_metrics"`
|
||||||
|
MetricsPort int `toml:"metrics_port"`
|
||||||
|
EnableTracing bool `toml:"enable_tracing"`
|
||||||
|
TraceSampleRate float64 `toml:"trace_sample_rate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecoveryConfig struct {
|
||||||
|
AutoRejoin bool `toml:"auto_rejoin"`
|
||||||
|
MaxRetrySec int `toml:"max_retry_sec"`
|
||||||
|
DataReplicationTimeoutSec int `toml:"data_replication_timeout_sec"`
|
||||||
|
StaleReadTimeoutSec int `toml:"stale_read_timeout_sec"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== НОВЫЕ КОНФИГУРАЦИОННЫЕ СТРУКТУРЫ ==========
|
||||||
|
|
||||||
|
// WALConfig настройки Write-Ahead Log
|
||||||
|
type WALConfig struct {
|
||||||
|
SegmentSizeMB int `toml:"segment_size_mb"`
|
||||||
|
SyncIntervalSec int `toml:"sync_interval_sec"`
|
||||||
|
BatchSize int `toml:"batch_size"`
|
||||||
|
RecoveryWorkers int `toml:"recovery_workers"`
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MVCCConfig настройки Multi-Version Concurrency Control
|
||||||
|
type MVCCConfig struct {
|
||||||
|
MaxVersionsPerDoc int `toml:"max_versions_per_doc"`
|
||||||
|
VisibilityMapSize int `toml:"visibility_map_size"`
|
||||||
|
PruneIntervalMin int `toml:"prune_interval_min"`
|
||||||
|
RetentionDays int `toml:"retention_days"`
|
||||||
|
ReadCacheSize int `toml:"read_cache_size"`
|
||||||
|
ReadCacheTTLSec int `toml:"read_cache_ttl_sec"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransactionsConfig настройки транзакций
|
||||||
|
type TransactionsConfig struct {
|
||||||
|
DefaultTimeoutSec int `toml:"default_timeout_sec"`
|
||||||
|
DeadlockCheckIntervalSec int `toml:"deadlock_check_interval_sec"`
|
||||||
|
MaxSavepointsPerTx int `toml:"max_savepoints_per_tx"`
|
||||||
|
TwoPhaseCommitTimeoutSec int `toml:"two_phase_commit_timeout_sec"`
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
CheckpointIntervalSec int `toml:"checkpoint_interval_sec"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACLConfig настройки Access Control List
|
||||||
|
type ACLConfig struct {
|
||||||
|
MaxDeniedLogSize int `toml:"max_denied_log_size"`
|
||||||
|
TemporaryGrantTTLHours int `toml:"temporary_grant_ttl_hours"`
|
||||||
|
EnableRoleHierarchy bool `toml:"enable_role_hierarchy"`
|
||||||
|
CacheTTLSec int `toml:"cache_ttl_sec"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Методы для новых конфигураций ==========
|
||||||
|
|
||||||
|
// GetSegmentSize возвращает размер сегмента WAL в байтах
|
||||||
|
func (w *WALConfig) GetSegmentSize() int64 {
|
||||||
|
if w.SegmentSizeMB <= 0 {
|
||||||
|
return 64 * 1024 * 1024
|
||||||
|
}
|
||||||
|
return int64(w.SegmentSizeMB) * 1024 * 1024
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSyncInterval возвращает интервал синхронизации WAL
|
||||||
|
func (w *WALConfig) GetSyncInterval() time.Duration {
|
||||||
|
if w.SyncIntervalSec <= 0 {
|
||||||
|
return 5 * time.Second
|
||||||
|
}
|
||||||
|
return time.Duration(w.SyncIntervalSec) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBatchSize возвращает размер пакета WAL
|
||||||
|
func (w *WALConfig) GetBatchSize() int {
|
||||||
|
if w.BatchSize <= 0 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
return w.BatchSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRecoveryWorkers возвращает количество воркеров для parallel recovery
|
||||||
|
func (w *WALConfig) GetRecoveryWorkers() int {
|
||||||
|
if w.RecoveryWorkers <= 0 {
|
||||||
|
return 4
|
||||||
|
}
|
||||||
|
return w.RecoveryWorkers
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsWALEnabled возвращает статус WAL
|
||||||
|
func (w *WALConfig) IsWALEnabled() bool {
|
||||||
|
return w.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMaxVersionsPerDoc возвращает максимальное количество версий на документ
|
||||||
|
func (m *MVCCConfig) GetMaxVersionsPerDoc() int {
|
||||||
|
if m.MaxVersionsPerDoc <= 0 {
|
||||||
|
return 10
|
||||||
|
}
|
||||||
|
return m.MaxVersionsPerDoc
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVisibilityMapSize возвращает размер карты видимости
|
||||||
|
func (m *MVCCConfig) GetVisibilityMapSize() int {
|
||||||
|
if m.VisibilityMapSize <= 0 {
|
||||||
|
return 1024 * 1024
|
||||||
|
}
|
||||||
|
return m.VisibilityMapSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPruneInterval возвращает интервал очистки версий
|
||||||
|
func (m *MVCCConfig) GetPruneInterval() time.Duration {
|
||||||
|
if m.PruneIntervalMin <= 0 {
|
||||||
|
return 5 * time.Minute
|
||||||
|
}
|
||||||
|
return time.Duration(m.PruneIntervalMin) * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRetentionDays возвращает дни хранения версий
|
||||||
|
func (m *MVCCConfig) GetRetentionDays() int {
|
||||||
|
if m.RetentionDays <= 0 {
|
||||||
|
return 7
|
||||||
|
}
|
||||||
|
return m.RetentionDays
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReadCacheSize возвращает размер кэша чтения
|
||||||
|
func (m *MVCCConfig) GetReadCacheSize() int {
|
||||||
|
if m.ReadCacheSize <= 0 {
|
||||||
|
return 10000
|
||||||
|
}
|
||||||
|
return m.ReadCacheSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReadCacheTTL возвращает TTL кэша чтения
|
||||||
|
func (m *MVCCConfig) GetReadCacheTTL() time.Duration {
|
||||||
|
if m.ReadCacheTTLSec <= 0 {
|
||||||
|
return 300 * time.Second
|
||||||
|
}
|
||||||
|
return time.Duration(m.ReadCacheTTLSec) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultTimeout возвращает таймаут транзакции по умолчанию
|
||||||
|
func (t *TransactionsConfig) GetDefaultTimeout() time.Duration {
|
||||||
|
if t.DefaultTimeoutSec <= 0 {
|
||||||
|
return 30 * time.Second
|
||||||
|
}
|
||||||
|
return time.Duration(t.DefaultTimeoutSec) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeadlockCheckInterval возвращает интервал проверки дедлоков
|
||||||
|
func (t *TransactionsConfig) GetDeadlockCheckInterval() time.Duration {
|
||||||
|
if t.DeadlockCheckIntervalSec <= 0 {
|
||||||
|
return 1 * time.Second
|
||||||
|
}
|
||||||
|
return time.Duration(t.DeadlockCheckIntervalSec) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMaxSavepointsPerTx возвращает максимальное количество точек сохранения
|
||||||
|
func (t *TransactionsConfig) GetMaxSavepointsPerTx() int {
|
||||||
|
if t.MaxSavepointsPerTx <= 0 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
return t.MaxSavepointsPerTx
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTwoPhaseCommitTimeout возвращает таймаут двухфазного коммита
|
||||||
|
func (t *TransactionsConfig) GetTwoPhaseCommitTimeout() time.Duration {
|
||||||
|
if t.TwoPhaseCommitTimeoutSec <= 0 {
|
||||||
|
return 10 * time.Second
|
||||||
|
}
|
||||||
|
return time.Duration(t.TwoPhaseCommitTimeoutSec) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsTransactionsEnabled возвращает статус поддержки транзакций
|
||||||
|
func (t *TransactionsConfig) IsTransactionsEnabled() bool {
|
||||||
|
return t.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCheckpointInterval возвращает интервал чекпоинтов
|
||||||
|
func (t *TransactionsConfig) GetCheckpointInterval() int64 {
|
||||||
|
if t.CheckpointIntervalSec <= 0 {
|
||||||
|
return 300
|
||||||
|
}
|
||||||
|
return int64(t.CheckpointIntervalSec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMaxDeniedLogSize возвращает максимальный размер лога отказов
|
||||||
|
func (a *ACLConfig) GetMaxDeniedLogSize() int {
|
||||||
|
if a.MaxDeniedLogSize <= 0 {
|
||||||
|
return 10000
|
||||||
|
}
|
||||||
|
return a.MaxDeniedLogSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTemporaryGrantTTL возвращает TTL временных разрешений
|
||||||
|
func (a *ACLConfig) GetTemporaryGrantTTL() time.Duration {
|
||||||
|
if a.TemporaryGrantTTLHours <= 0 {
|
||||||
|
return 24 * time.Hour
|
||||||
|
}
|
||||||
|
return time.Duration(a.TemporaryGrantTTLHours) * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRoleHierarchyEnabled возвращает статус иерархии ролей
|
||||||
|
func (a *ACLConfig) IsRoleHierarchyEnabled() bool {
|
||||||
|
return a.EnableRoleHierarchy
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCacheTTL возвращает TTL кэша ACL
|
||||||
|
func (a *ACLConfig) GetCacheTTL() time.Duration {
|
||||||
|
if a.CacheTTLSec <= 0 {
|
||||||
|
return 60 * time.Second
|
||||||
|
}
|
||||||
|
return time.Duration(a.CacheTTLSec) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Методы PluginsConfig (расширенные) ==========
|
||||||
|
|
||||||
|
// GetMaxCPUTime возвращает лимит CPU для плагина
|
||||||
|
func (p *PluginsConfig) GetMaxCPUTime() time.Duration {
|
||||||
|
if p.MaxCPUTimeMs <= 0 {
|
||||||
|
return 100 * time.Millisecond
|
||||||
|
}
|
||||||
|
return time.Duration(p.MaxCPUTimeMs) * time.Millisecond
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMaxMemory возвращает лимит памяти для плагина в байтах
|
||||||
|
func (p *PluginsConfig) GetMaxMemory() int64 {
|
||||||
|
if p.MaxMemoryMB <= 0 {
|
||||||
|
return 50 * 1024 * 1024
|
||||||
|
}
|
||||||
|
return int64(p.MaxMemoryMB) * 1024 * 1024
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMaxExecutionTime возвращает лимит времени выполнения плагина
|
||||||
|
func (p *PluginsConfig) GetMaxExecutionTime() time.Duration {
|
||||||
|
if p.MaxExecutionTimeSec <= 0 {
|
||||||
|
return 5 * time.Second
|
||||||
|
}
|
||||||
|
return time.Duration(p.MaxExecutionTimeSec) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMaxInstructions возвращает лимит инструкций Lua
|
||||||
|
func (p *PluginsConfig) GetMaxInstructions() int64 {
|
||||||
|
if p.MaxInstructions <= 0 {
|
||||||
|
return 1000000
|
||||||
|
}
|
||||||
|
return p.MaxInstructions
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHotReloadInterval возвращает интервал проверки изменений плагинов
|
||||||
|
func (p *PluginsConfig) GetHotReloadInterval() time.Duration {
|
||||||
|
if p.HotReloadIntervalSec <= 0 {
|
||||||
|
return 30 * time.Second
|
||||||
|
}
|
||||||
|
return time.Duration(p.HotReloadIntervalSec) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMaxEventLogSize возвращает максимальный размер лога событий плагина
|
||||||
|
func (p *PluginsConfig) GetMaxEventLogSize() int {
|
||||||
|
if p.MaxEventLogSize <= 0 {
|
||||||
|
return 1000
|
||||||
|
}
|
||||||
|
return p.MaxEventLogSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLoadTimeout возвращает таймаут загрузки плагина
|
||||||
|
func (p *PluginsConfig) GetLoadTimeout() time.Duration {
|
||||||
|
if p.LoadTimeoutSec <= 0 {
|
||||||
|
return 10 * time.Second
|
||||||
|
}
|
||||||
|
return time.Duration(p.LoadTimeoutSec) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Остальные методы без изменений ==========
|
||||||
|
|
||||||
|
func (c *ClusterConfig) GetHeartbeatTimeout() time.Duration {
|
||||||
|
if c.HeartbeatTimeoutMs <= 0 {
|
||||||
|
return 1000 * time.Millisecond
|
||||||
|
}
|
||||||
|
return time.Duration(c.HeartbeatTimeoutMs) * time.Millisecond
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ClusterConfig) GetElectionTimeout() time.Duration {
|
||||||
|
if c.ElectionTimeoutMs <= 0 {
|
||||||
|
return 1000 * time.Millisecond
|
||||||
|
}
|
||||||
|
return time.Duration(c.ElectionTimeoutMs) * time.Millisecond
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ClusterConfig) GetCommitTimeout() time.Duration {
|
||||||
|
if c.CommitTimeoutMs <= 0 {
|
||||||
|
return 500 * time.Millisecond
|
||||||
|
}
|
||||||
|
return time.Duration(c.CommitTimeoutMs) * time.Millisecond
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ClusterConfig) GetSnapshotInterval() time.Duration {
|
||||||
|
if c.SnapshotIntervalMin <= 0 {
|
||||||
|
return 30 * time.Minute
|
||||||
|
}
|
||||||
|
return time.Duration(c.SnapshotIntervalMin) * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ClusterConfig) GetSnapshotThreshold() uint64 {
|
||||||
|
if c.SnapshotThreshold <= 0 {
|
||||||
|
return 1000
|
||||||
|
}
|
||||||
|
return uint64(c.SnapshotThreshold)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ClusterConfig) IsSplitBrainPreventionEnabled() bool {
|
||||||
|
return c.SplitBrainPrevention
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ClusterConfig) GetRecoveryTimeout() time.Duration {
|
||||||
|
if c.RecoveryTimeoutSec <= 0 {
|
||||||
|
return 30 * time.Second
|
||||||
|
}
|
||||||
|
return time.Duration(c.RecoveryTimeoutSec) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ClusterConfig) GetRegion() string {
|
||||||
|
if c.Region == "" {
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
return c.Region
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ClusterConfig) GetPriorityZone() int {
|
||||||
|
if c.PriorityZone < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if c.PriorityZone > 9 {
|
||||||
|
return 9
|
||||||
|
}
|
||||||
|
return c.PriorityZone
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ReplicationConfig) GetReplicationTimeout() time.Duration {
|
||||||
|
if r.ReplicationTimeoutMs <= 0 {
|
||||||
|
return 5 * time.Second
|
||||||
|
}
|
||||||
|
return time.Duration(r.ReplicationTimeoutMs) * time.Millisecond
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ReplicationConfig) IsReplicationEnabled() bool {
|
||||||
|
return r.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ReplicationConfig) IsMasterMasterEnabled() bool {
|
||||||
|
return r.MasterMaster
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ReplicationConfig) IsSyncReplicationEnabled() bool {
|
||||||
|
return r.SyncReplication
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RecoveryConfig) GetMaxRetryDuration() time.Duration {
|
||||||
|
if r.MaxRetrySec <= 0 {
|
||||||
|
return 300 * time.Second
|
||||||
|
}
|
||||||
|
return time.Duration(r.MaxRetrySec) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RecoveryConfig) GetDataReplicationTimeout() time.Duration {
|
||||||
|
if r.DataReplicationTimeoutSec <= 0 {
|
||||||
|
return 60 * time.Second
|
||||||
|
}
|
||||||
|
return time.Duration(r.DataReplicationTimeoutSec) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RecoveryConfig) GetStaleReadTimeout() time.Duration {
|
||||||
|
if r.StaleReadTimeoutSec <= 0 {
|
||||||
|
return 30 * time.Second
|
||||||
|
}
|
||||||
|
return time.Duration(r.StaleReadTimeoutSec) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RecoveryConfig) IsAutoRejoinEnabled() bool {
|
||||||
|
return r.AutoRejoin
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PerformanceConfig) GetBatchSize() int {
|
||||||
|
if p.BatchSize <= 0 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
return p.BatchSize
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PerformanceConfig) GetMaxConnections() int {
|
||||||
|
if p.MaxConnections <= 0 {
|
||||||
|
return 1000
|
||||||
|
}
|
||||||
|
return p.MaxConnections
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PerformanceConfig) IsReadFromFollowerEnabled() bool {
|
||||||
|
return p.ReadFromFollower
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PerformanceConfig) IsPipelineEnabled() bool {
|
||||||
|
return p.EnablePipeline
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MonitoringConfig) GetMetricsPort() int {
|
||||||
|
if m.MetricsPort <= 0 {
|
||||||
|
return 9090
|
||||||
|
}
|
||||||
|
return m.MetricsPort
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MonitoringConfig) GetTraceSampleRate() float64 {
|
||||||
|
if m.TraceSampleRate <= 0 || m.TraceSampleRate > 1 {
|
||||||
|
return 0.01
|
||||||
|
}
|
||||||
|
return m.TraceSampleRate
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MonitoringConfig) IsMetricsEnabled() bool {
|
||||||
|
return m.EnableMetrics
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MonitoringConfig) IsTracingEnabled() bool {
|
||||||
|
return m.EnableTracing
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SecurityConfig) GetTLSMinVersion() uint16 {
|
||||||
|
switch s.MinVersion {
|
||||||
|
case "1.0":
|
||||||
|
return 0x0301
|
||||||
|
case "1.1":
|
||||||
|
return 0x0302
|
||||||
|
case "1.2":
|
||||||
|
return 0x0303
|
||||||
|
case "1.3":
|
||||||
|
return 0x0304
|
||||||
|
default:
|
||||||
|
return 0x0303
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SecurityConfig) IsTLSEnabled() bool {
|
||||||
|
return s.EnableTLS && s.CertFile != "" && s.KeyFile != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SecurityConfig) GetCertFile() string {
|
||||||
|
return s.CertFile
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SecurityConfig) GetKeyFile() string {
|
||||||
|
return s.KeyFile
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SecurityConfig) GetCAFile() string {
|
||||||
|
return s.CAFile
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CompressionConfig) IsCompressionEnabled() bool {
|
||||||
|
return c.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CompressionConfig) GetAlgorithm() string {
|
||||||
|
if c.Algorithm == "" {
|
||||||
|
return "snappy"
|
||||||
|
}
|
||||||
|
return c.Algorithm
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CompressionConfig) GetLevel() int {
|
||||||
|
if c.Level < 1 {
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
if c.Level > 9 {
|
||||||
|
return 9
|
||||||
|
}
|
||||||
|
return c.Level
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CompressionConfig) GetMinSize() int {
|
||||||
|
if c.MinSize <= 0 {
|
||||||
|
return 1024
|
||||||
|
}
|
||||||
|
return c.MinSize
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WebUIConfig) IsWebUIEnabled() bool {
|
||||||
|
return w.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WebUIConfig) GetWebUIPort() int {
|
||||||
|
if w.Port <= 0 {
|
||||||
|
return 8080
|
||||||
|
}
|
||||||
|
return w.Port
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WebUIConfig) GetTheme() string {
|
||||||
|
if w.Theme == "" {
|
||||||
|
return "dark"
|
||||||
|
}
|
||||||
|
return w.Theme
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) Validate() error {
|
||||||
|
if c.Cluster.NodePort <= 0 || c.Cluster.NodePort > 65535 {
|
||||||
|
return fmt.Errorf("invalid node_port: %d", c.Cluster.NodePort)
|
||||||
|
}
|
||||||
|
if c.Cluster.RaftPort <= 0 || c.Cluster.RaftPort > 65535 {
|
||||||
|
return fmt.Errorf("invalid raft_port: %d", c.Cluster.RaftPort)
|
||||||
|
}
|
||||||
|
if c.Cluster.PriorityZone < 0 || c.Cluster.PriorityZone > 9 {
|
||||||
|
return fmt.Errorf("priority_zone must be between 0 and 9, got %d", c.Cluster.PriorityZone)
|
||||||
|
}
|
||||||
|
if c.Performance.BatchSize < 1 {
|
||||||
|
return fmt.Errorf("batch_size must be at least 1, got %d", c.Performance.BatchSize)
|
||||||
|
}
|
||||||
|
if c.Performance.MaxConnections < 1 {
|
||||||
|
return fmt.Errorf("max_connections must be at least 1, got %d", c.Performance.MaxConnections)
|
||||||
|
}
|
||||||
|
if c.Monitoring.TraceSampleRate < 0 || c.Monitoring.TraceSampleRate > 1 {
|
||||||
|
return fmt.Errorf("trace_sample_rate must be between 0 and 1, got %f", c.Monitoring.TraceSampleRate)
|
||||||
|
}
|
||||||
|
if c.API.Port == c.Cluster.NodePort {
|
||||||
|
return fmt.Errorf("API port %d conflicts with cluster node port", c.API.Port)
|
||||||
|
}
|
||||||
|
if c.WebUI.Port == c.Cluster.NodePort {
|
||||||
|
return fmt.Errorf("WebUI port %d conflicts with cluster node port", c.WebUI.Port)
|
||||||
|
}
|
||||||
|
if c.Monitoring.MetricsPort == c.Cluster.NodePort {
|
||||||
|
return fmt.Errorf("metrics port %d conflicts with cluster node port", c.Monitoring.MetricsPort)
|
||||||
|
}
|
||||||
|
if c.Security.IsTLSEnabled() {
|
||||||
|
if c.Security.CertFile == "" {
|
||||||
|
return fmt.Errorf("cert_file is required when TLS is enabled")
|
||||||
|
}
|
||||||
|
if c.Security.KeyFile == "" {
|
||||||
|
return fmt.Errorf("key_file is required when TLS is enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.Compression.Enabled {
|
||||||
|
switch c.Compression.Algorithm {
|
||||||
|
case "snappy", "lz4", "zstd":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported compression algorithm: %s, supported: snappy, lz4, zstd", c.Compression.Algorithm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Валидация новых настроек
|
||||||
|
if c.WAL.RecoveryWorkers < 1 {
|
||||||
|
return fmt.Errorf("wal.recovery_workers must be at least 1, got %d", c.WAL.RecoveryWorkers)
|
||||||
|
}
|
||||||
|
if c.MVCC.MaxVersionsPerDoc < 0 {
|
||||||
|
return fmt.Errorf("mvcc.max_versions_per_doc cannot be negative, got %d", c.MVCC.MaxVersionsPerDoc)
|
||||||
|
}
|
||||||
|
if c.Plugins.MaxCPUTimeMs < 0 {
|
||||||
|
return fmt.Errorf("plugins.max_cpu_time_ms cannot be negative, got %d", c.Plugins.MaxCPUTimeMs)
|
||||||
|
}
|
||||||
|
if c.Plugins.MaxMemoryMB < 0 {
|
||||||
|
return fmt.Errorf("plugins.max_memory_mb cannot be negative, got %d", c.Plugins.MaxMemoryMB)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadConfig(path string) (*Config, error) {
|
||||||
|
var cfg Config
|
||||||
|
if _, err := toml.DecodeFile(path, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode config file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Значения по умолчанию для существующих настроек
|
||||||
|
if cfg.Cluster.RaftPort == 0 {
|
||||||
|
cfg.Cluster.RaftPort = 9878
|
||||||
|
}
|
||||||
|
if cfg.Cluster.RaftDataDir == "" {
|
||||||
|
cfg.Cluster.RaftDataDir = "raft_data"
|
||||||
|
}
|
||||||
|
if cfg.Replication.ReplicationTimeoutMs == 0 {
|
||||||
|
cfg.Replication.ReplicationTimeoutMs = 5000
|
||||||
|
}
|
||||||
|
if cfg.Plugins.ScriptDir == "" {
|
||||||
|
cfg.Plugins.ScriptDir = "plugins"
|
||||||
|
}
|
||||||
|
if cfg.API.Port == 0 {
|
||||||
|
cfg.API.Port = 8080
|
||||||
|
}
|
||||||
|
if cfg.Compression.Algorithm == "" {
|
||||||
|
cfg.Compression.Algorithm = "snappy"
|
||||||
|
}
|
||||||
|
if cfg.Compression.MinSize == 0 {
|
||||||
|
cfg.Compression.MinSize = 1024
|
||||||
|
}
|
||||||
|
if cfg.Compression.Level == 0 {
|
||||||
|
cfg.Compression.Level = 3
|
||||||
|
}
|
||||||
|
if cfg.WebUI.Port == 0 {
|
||||||
|
cfg.WebUI.Port = 9080
|
||||||
|
}
|
||||||
|
if cfg.WebUI.Theme == "" {
|
||||||
|
cfg.WebUI.Theme = "dark"
|
||||||
|
}
|
||||||
|
if cfg.Cluster.HeartbeatTimeoutMs == 0 {
|
||||||
|
cfg.Cluster.HeartbeatTimeoutMs = 1000
|
||||||
|
}
|
||||||
|
if cfg.Cluster.ElectionTimeoutMs == 0 {
|
||||||
|
cfg.Cluster.ElectionTimeoutMs = 1000
|
||||||
|
}
|
||||||
|
if cfg.Cluster.CommitTimeoutMs == 0 {
|
||||||
|
cfg.Cluster.CommitTimeoutMs = 500
|
||||||
|
}
|
||||||
|
if cfg.Cluster.SnapshotIntervalMin == 0 {
|
||||||
|
cfg.Cluster.SnapshotIntervalMin = 30
|
||||||
|
}
|
||||||
|
if cfg.Cluster.SnapshotThreshold == 0 {
|
||||||
|
cfg.Cluster.SnapshotThreshold = 1000
|
||||||
|
}
|
||||||
|
if cfg.Cluster.RecoveryTimeoutSec == 0 {
|
||||||
|
cfg.Cluster.RecoveryTimeoutSec = 30
|
||||||
|
}
|
||||||
|
if cfg.Cluster.Region == "" {
|
||||||
|
cfg.Cluster.Region = "default"
|
||||||
|
}
|
||||||
|
if cfg.Performance.BatchSize == 0 {
|
||||||
|
cfg.Performance.BatchSize = 100
|
||||||
|
}
|
||||||
|
if cfg.Performance.MaxConnections == 0 {
|
||||||
|
cfg.Performance.MaxConnections = 1000
|
||||||
|
}
|
||||||
|
if cfg.Monitoring.MetricsPort == 0 {
|
||||||
|
cfg.Monitoring.MetricsPort = 9090
|
||||||
|
}
|
||||||
|
if cfg.Monitoring.TraceSampleRate == 0 {
|
||||||
|
cfg.Monitoring.TraceSampleRate = 0.01
|
||||||
|
}
|
||||||
|
if cfg.Recovery.MaxRetrySec == 0 {
|
||||||
|
cfg.Recovery.MaxRetrySec = 300
|
||||||
|
}
|
||||||
|
if cfg.Recovery.DataReplicationTimeoutSec == 0 {
|
||||||
|
cfg.Recovery.DataReplicationTimeoutSec = 60
|
||||||
|
}
|
||||||
|
if cfg.Recovery.StaleReadTimeoutSec == 0 {
|
||||||
|
cfg.Recovery.StaleReadTimeoutSec = 30
|
||||||
|
}
|
||||||
|
if cfg.Security.MinVersion == "" {
|
||||||
|
cfg.Security.MinVersion = "1.2"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Значения по умолчанию для WAL
|
||||||
|
if cfg.WAL.SegmentSizeMB == 0 {
|
||||||
|
cfg.WAL.SegmentSizeMB = 64
|
||||||
|
}
|
||||||
|
if cfg.WAL.SyncIntervalSec == 0 {
|
||||||
|
cfg.WAL.SyncIntervalSec = 5
|
||||||
|
}
|
||||||
|
if cfg.WAL.BatchSize == 0 {
|
||||||
|
cfg.WAL.BatchSize = 100
|
||||||
|
}
|
||||||
|
if cfg.WAL.RecoveryWorkers == 0 {
|
||||||
|
cfg.WAL.RecoveryWorkers = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
// Значения по умолчанию для MVCC
|
||||||
|
if cfg.MVCC.MaxVersionsPerDoc == 0 {
|
||||||
|
cfg.MVCC.MaxVersionsPerDoc = 10
|
||||||
|
}
|
||||||
|
if cfg.MVCC.VisibilityMapSize == 0 {
|
||||||
|
cfg.MVCC.VisibilityMapSize = 1024 * 1024
|
||||||
|
}
|
||||||
|
if cfg.MVCC.PruneIntervalMin == 0 {
|
||||||
|
cfg.MVCC.PruneIntervalMin = 5
|
||||||
|
}
|
||||||
|
if cfg.MVCC.RetentionDays == 0 {
|
||||||
|
cfg.MVCC.RetentionDays = 7
|
||||||
|
}
|
||||||
|
if cfg.MVCC.ReadCacheSize == 0 {
|
||||||
|
cfg.MVCC.ReadCacheSize = 10000
|
||||||
|
}
|
||||||
|
if cfg.MVCC.ReadCacheTTLSec == 0 {
|
||||||
|
cfg.MVCC.ReadCacheTTLSec = 300
|
||||||
|
}
|
||||||
|
|
||||||
|
// Значения по умолчанию для транзакций
|
||||||
|
if cfg.Transactions.DefaultTimeoutSec == 0 {
|
||||||
|
cfg.Transactions.DefaultTimeoutSec = 30
|
||||||
|
}
|
||||||
|
if cfg.Transactions.DeadlockCheckIntervalSec == 0 {
|
||||||
|
cfg.Transactions.DeadlockCheckIntervalSec = 1
|
||||||
|
}
|
||||||
|
if cfg.Transactions.MaxSavepointsPerTx == 0 {
|
||||||
|
cfg.Transactions.MaxSavepointsPerTx = 100
|
||||||
|
}
|
||||||
|
if cfg.Transactions.TwoPhaseCommitTimeoutSec == 0 {
|
||||||
|
cfg.Transactions.TwoPhaseCommitTimeoutSec = 10
|
||||||
|
}
|
||||||
|
if cfg.Transactions.CheckpointIntervalSec == 0 {
|
||||||
|
cfg.Transactions.CheckpointIntervalSec = 300
|
||||||
|
}
|
||||||
|
|
||||||
|
// Значения по умолчанию для ACL
|
||||||
|
if cfg.ACL.MaxDeniedLogSize == 0 {
|
||||||
|
cfg.ACL.MaxDeniedLogSize = 10000
|
||||||
|
}
|
||||||
|
if cfg.ACL.TemporaryGrantTTLHours == 0 {
|
||||||
|
cfg.ACL.TemporaryGrantTTLHours = 24
|
||||||
|
}
|
||||||
|
if cfg.ACL.CacheTTLSec == 0 {
|
||||||
|
cfg.ACL.CacheTTLSec = 60
|
||||||
|
}
|
||||||
|
|
||||||
|
// Значения по умолчанию для расширенных настроек плагинов
|
||||||
|
if cfg.Plugins.MaxCPUTimeMs == 0 {
|
||||||
|
cfg.Plugins.MaxCPUTimeMs = 100
|
||||||
|
}
|
||||||
|
if cfg.Plugins.MaxMemoryMB == 0 {
|
||||||
|
cfg.Plugins.MaxMemoryMB = 50
|
||||||
|
}
|
||||||
|
if cfg.Plugins.MaxExecutionTimeSec == 0 {
|
||||||
|
cfg.Plugins.MaxExecutionTimeSec = 5
|
||||||
|
}
|
||||||
|
if cfg.Plugins.MaxInstructions == 0 {
|
||||||
|
cfg.Plugins.MaxInstructions = 1000000
|
||||||
|
}
|
||||||
|
if cfg.Plugins.HotReloadIntervalSec == 0 {
|
||||||
|
cfg.Plugins.HotReloadIntervalSec = 30
|
||||||
|
}
|
||||||
|
if cfg.Plugins.MaxEventLogSize == 0 {
|
||||||
|
cfg.Plugins.MaxEventLogSize = 1000
|
||||||
|
}
|
||||||
|
if cfg.Plugins.LoadTimeoutSec == 0 {
|
||||||
|
cfg.Plugins.LoadTimeoutSec = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
return nil, fmt.Errorf("config validation failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) GetConfigSummary() map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"cluster": map[string]interface{}{
|
||||||
|
"name": c.Cluster.Name,
|
||||||
|
"node_ip": c.Cluster.NodeIP,
|
||||||
|
"node_port": c.Cluster.NodePort,
|
||||||
|
"raft_port": c.Cluster.RaftPort,
|
||||||
|
"bootstrap": c.Cluster.Bootstrap,
|
||||||
|
"nodes_count": len(c.Cluster.Nodes),
|
||||||
|
"region": c.Cluster.GetRegion(),
|
||||||
|
"priority_zone": c.Cluster.GetPriorityZone(),
|
||||||
|
"split_brain_prevention": c.Cluster.IsSplitBrainPreventionEnabled(),
|
||||||
|
},
|
||||||
|
"replication": map[string]interface{}{
|
||||||
|
"enabled": c.Replication.IsReplicationEnabled(),
|
||||||
|
"master_master": c.Replication.IsMasterMasterEnabled(),
|
||||||
|
"sync": c.Replication.IsSyncReplicationEnabled(),
|
||||||
|
"timeout_ms": c.Replication.ReplicationTimeoutMs,
|
||||||
|
},
|
||||||
|
"performance": map[string]interface{}{
|
||||||
|
"pipeline": c.Performance.IsPipelineEnabled(),
|
||||||
|
"batch_size": c.Performance.GetBatchSize(),
|
||||||
|
"read_from_follower": c.Performance.IsReadFromFollowerEnabled(),
|
||||||
|
"max_connections": c.Performance.GetMaxConnections(),
|
||||||
|
},
|
||||||
|
"security": map[string]interface{}{
|
||||||
|
"tls_enabled": c.Security.IsTLSEnabled(),
|
||||||
|
"tls_version": c.Security.MinVersion,
|
||||||
|
},
|
||||||
|
"monitoring": map[string]interface{}{
|
||||||
|
"metrics_enabled": c.Monitoring.IsMetricsEnabled(),
|
||||||
|
"metrics_port": c.Monitoring.GetMetricsPort(),
|
||||||
|
"tracing_enabled": c.Monitoring.IsTracingEnabled(),
|
||||||
|
"trace_sample_rate": c.Monitoring.GetTraceSampleRate(),
|
||||||
|
},
|
||||||
|
"compression": map[string]interface{}{
|
||||||
|
"enabled": c.Compression.IsCompressionEnabled(),
|
||||||
|
"algorithm": c.Compression.GetAlgorithm(),
|
||||||
|
"level": c.Compression.GetLevel(),
|
||||||
|
"min_size": c.Compression.GetMinSize(),
|
||||||
|
},
|
||||||
|
"wal": map[string]interface{}{
|
||||||
|
"segment_size_mb": c.WAL.SegmentSizeMB,
|
||||||
|
"sync_interval_sec": c.WAL.SyncIntervalSec,
|
||||||
|
"batch_size": c.WAL.BatchSize,
|
||||||
|
"recovery_workers": c.WAL.RecoveryWorkers,
|
||||||
|
"enabled": c.WAL.Enabled,
|
||||||
|
},
|
||||||
|
"mvcc": map[string]interface{}{
|
||||||
|
"max_versions_per_doc": c.MVCC.MaxVersionsPerDoc,
|
||||||
|
"prune_interval_min": c.MVCC.PruneIntervalMin,
|
||||||
|
"retention_days": c.MVCC.RetentionDays,
|
||||||
|
},
|
||||||
|
"transactions": map[string]interface{}{
|
||||||
|
"default_timeout_sec": c.Transactions.DefaultTimeoutSec,
|
||||||
|
"enabled": c.Transactions.Enabled,
|
||||||
|
},
|
||||||
|
"acl": map[string]interface{}{
|
||||||
|
"enable_role_hierarchy": c.ACL.EnableRoleHierarchy,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
518
internal/log/logger.go
Normal file
518
internal/log/logger.go
Normal file
@@ -0,0 +1,518 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
package log
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogLevel представляет уровень логирования
|
||||||
|
type LogLevel int32
|
||||||
|
|
||||||
|
const (
|
||||||
|
DebugLevel LogLevel = iota
|
||||||
|
InfoLevel
|
||||||
|
WarnLevel
|
||||||
|
ErrorLevel
|
||||||
|
FatalLevel
|
||||||
|
)
|
||||||
|
|
||||||
|
// String возвращает строковое представление уровня
|
||||||
|
func (l LogLevel) String() string {
|
||||||
|
switch l {
|
||||||
|
case DebugLevel:
|
||||||
|
return "DEBUG"
|
||||||
|
case InfoLevel:
|
||||||
|
return "INFO"
|
||||||
|
case WarnLevel:
|
||||||
|
return "WARN"
|
||||||
|
case ErrorLevel:
|
||||||
|
return "ERROR"
|
||||||
|
case FatalLevel:
|
||||||
|
return "FATAL"
|
||||||
|
default:
|
||||||
|
return "UNKNOWN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseLogLevel парсит уровень логирования из строки
|
||||||
|
func ParseLogLevel(levelStr string) LogLevel {
|
||||||
|
switch levelStr {
|
||||||
|
case "debug":
|
||||||
|
return DebugLevel
|
||||||
|
case "info":
|
||||||
|
return InfoLevel
|
||||||
|
case "warn", "warning":
|
||||||
|
return WarnLevel
|
||||||
|
case "error":
|
||||||
|
return ErrorLevel
|
||||||
|
case "fatal":
|
||||||
|
return FatalLevel
|
||||||
|
default:
|
||||||
|
return InfoLevel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogEntry представляет одну запись в логе
|
||||||
|
type LogEntry struct {
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
Level string `json:"level"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
Line int `json:"line,omitempty"`
|
||||||
|
Function string `json:"function,omitempty"`
|
||||||
|
Fields map[string]interface{} `json:"fields,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logger представляет асинхронный логгер с поддержкой уровней
|
||||||
|
type Logger struct {
|
||||||
|
file *os.File
|
||||||
|
level atomic.Int32
|
||||||
|
writeChan chan LogEntry
|
||||||
|
done chan struct{}
|
||||||
|
mu sync.Mutex
|
||||||
|
path string
|
||||||
|
maxSize int64
|
||||||
|
currentSize int64
|
||||||
|
rotateCount int
|
||||||
|
formatter LogFormatter
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogFormatter определяет интерфейс для форматирования логов
|
||||||
|
type LogFormatter interface {
|
||||||
|
Format(entry LogEntry) string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextFormatter форматирует логи в текстовом виде
|
||||||
|
type TextFormatter struct {
|
||||||
|
includeTimestamp bool
|
||||||
|
includeLevel bool
|
||||||
|
includeSource bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTextFormatter создаёт новый текстовый форматтер
|
||||||
|
func NewTextFormatter(includeTimestamp, includeLevel, includeSource bool) *TextFormatter {
|
||||||
|
return &TextFormatter{
|
||||||
|
includeTimestamp: includeTimestamp,
|
||||||
|
includeLevel: includeLevel,
|
||||||
|
includeSource: includeSource,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format форматирует запись лога
|
||||||
|
func (f *TextFormatter) Format(entry LogEntry) string {
|
||||||
|
var parts []string
|
||||||
|
|
||||||
|
if f.includeTimestamp {
|
||||||
|
parts = append(parts, time.UnixMilli(entry.Timestamp).Format("2006-01-02 15:04:05.000"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.includeLevel {
|
||||||
|
parts = append(parts, fmt.Sprintf("[%s]", entry.Level))
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.includeSource && entry.Source != "" {
|
||||||
|
parts = append(parts, fmt.Sprintf("[%s:%d]", entry.Source, entry.Line))
|
||||||
|
}
|
||||||
|
|
||||||
|
parts = append(parts, entry.Message)
|
||||||
|
|
||||||
|
if len(entry.Fields) > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("%v", entry.Fields))
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s", parts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSONFormatter форматирует логи в JSON
|
||||||
|
type JSONFormatter struct {
|
||||||
|
pretty bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewJSONFormatter создаёт новый JSON форматтер
|
||||||
|
func NewJSONFormatter(pretty bool) *JSONFormatter {
|
||||||
|
return &JSONFormatter{pretty: pretty}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format форматирует запись лога в JSON
|
||||||
|
func (f *JSONFormatter) Format(entry LogEntry) string {
|
||||||
|
type jsonLog struct {
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
Level string `json:"level"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
Line int `json:"line,omitempty"`
|
||||||
|
Function string `json:"function,omitempty"`
|
||||||
|
Fields map[string]interface{} `json:"fields,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
logData := jsonLog{
|
||||||
|
Timestamp: entry.Timestamp,
|
||||||
|
Level: entry.Level,
|
||||||
|
Message: entry.Message,
|
||||||
|
Source: entry.Source,
|
||||||
|
Line: entry.Line,
|
||||||
|
Function: entry.Function,
|
||||||
|
Fields: entry.Fields,
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.pretty {
|
||||||
|
data, _ := json.MarshalIndent(logData, "", " ")
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(logData)
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLogger создаёт новый экземпляр логгера
|
||||||
|
func NewLogger(filename string, levelStr string) (*Logger, error) {
|
||||||
|
dir := filepath.Dir(filename)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create log directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
stat, _ := file.Stat()
|
||||||
|
|
||||||
|
level := ParseLogLevel(levelStr)
|
||||||
|
|
||||||
|
l := &Logger{
|
||||||
|
file: file,
|
||||||
|
writeChan: make(chan LogEntry, 50000),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
path: filename,
|
||||||
|
maxSize: 100 * 1024 * 1024, // 100MB
|
||||||
|
currentSize: stat.Size(),
|
||||||
|
rotateCount: 10,
|
||||||
|
formatter: NewTextFormatter(true, true, false),
|
||||||
|
}
|
||||||
|
l.level.Store(int32(level))
|
||||||
|
|
||||||
|
go l.writerLoop()
|
||||||
|
|
||||||
|
return l, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFormatter устанавливает форматтер
|
||||||
|
func (l *Logger) SetFormatter(formatter LogFormatter) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
l.formatter = formatter
|
||||||
|
}
|
||||||
|
|
||||||
|
// writerLoop обрабатывает запись логов в файл
|
||||||
|
func (l *Logger) writerLoop() {
|
||||||
|
for entry := range l.writeChan {
|
||||||
|
l.writeEntry(entry)
|
||||||
|
}
|
||||||
|
close(l.done)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeEntry записывает одну запись в файл
|
||||||
|
func (l *Logger) writeEntry(entry LogEntry) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
// Проверка ротации
|
||||||
|
if l.currentSize >= l.maxSize {
|
||||||
|
l.rotate()
|
||||||
|
}
|
||||||
|
|
||||||
|
formatted := l.formatter.Format(entry)
|
||||||
|
data := []byte(formatted + "\n")
|
||||||
|
|
||||||
|
if _, err := l.file.Write(data); err != nil {
|
||||||
|
// Не можем залогировать ошибку, так как это приведёт к рекурсии
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l.currentSize += int64(len(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// rotate выполняет ротацию лог-файла
|
||||||
|
func (l *Logger) rotate() {
|
||||||
|
l.file.Sync()
|
||||||
|
l.file.Close()
|
||||||
|
|
||||||
|
// Переименовываем существующий файл
|
||||||
|
for i := l.rotateCount - 1; i >= 0; i-- {
|
||||||
|
oldName := fmt.Sprintf("%s.%d", l.path, i)
|
||||||
|
newName := fmt.Sprintf("%s.%d", l.path, i+1)
|
||||||
|
|
||||||
|
if i == 0 {
|
||||||
|
oldName = l.path
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(oldName); err == nil {
|
||||||
|
os.Rename(oldName, newName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаём новый файл
|
||||||
|
file, err := os.OpenFile(l.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l.file = file
|
||||||
|
l.currentSize = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// log внутренний метод для записи лога
|
||||||
|
func (l *Logger) log(level LogLevel, msg string, fields map[string]interface{}) {
|
||||||
|
if level < LogLevel(l.level.Load()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := LogEntry{
|
||||||
|
Timestamp: time.Now().UnixMilli(),
|
||||||
|
Level: level.String(),
|
||||||
|
Message: msg,
|
||||||
|
Fields: fields,
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case l.writeChan <- entry:
|
||||||
|
default:
|
||||||
|
// Неблокирующая запись, старый лог теряется - wait-free
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug записывает DEBUG сообщение
|
||||||
|
func (l *Logger) Debug(msg string) {
|
||||||
|
l.log(DebugLevel, msg, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debugf записывает форматированное DEBUG сообщение
|
||||||
|
func (l *Logger) Debugf(format string, args ...interface{}) {
|
||||||
|
l.log(DebugLevel, fmt.Sprintf(format, args...), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DebugWithFields записывает DEBUG сообщение с полями
|
||||||
|
func (l *Logger) DebugWithFields(msg string, fields map[string]interface{}) {
|
||||||
|
l.log(DebugLevel, msg, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info записывает INFO сообщение
|
||||||
|
func (l *Logger) Info(msg string) {
|
||||||
|
l.log(InfoLevel, msg, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Infof записывает форматированное INFO сообщение
|
||||||
|
func (l *Logger) Infof(format string, args ...interface{}) {
|
||||||
|
l.log(InfoLevel, fmt.Sprintf(format, args...), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InfoWithFields записывает INFO сообщение с полями
|
||||||
|
func (l *Logger) InfoWithFields(msg string, fields map[string]interface{}) {
|
||||||
|
l.log(InfoLevel, msg, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn записывает WARN сообщение
|
||||||
|
func (l *Logger) Warn(msg string) {
|
||||||
|
l.log(WarnLevel, msg, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warnf записывает форматированное WARN сообщение
|
||||||
|
func (l *Logger) Warnf(format string, args ...interface{}) {
|
||||||
|
l.log(WarnLevel, fmt.Sprintf(format, args...), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WarnWithFields записывает WARN сообщение с полями
|
||||||
|
func (l *Logger) WarnWithFields(msg string, fields map[string]interface{}) {
|
||||||
|
l.log(WarnLevel, msg, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error записывает ERROR сообщение
|
||||||
|
func (l *Logger) Error(msg string) {
|
||||||
|
l.log(ErrorLevel, msg, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Errorf записывает форматированное ERROR сообщение
|
||||||
|
func (l *Logger) Errorf(format string, args ...interface{}) {
|
||||||
|
l.log(ErrorLevel, fmt.Sprintf(format, args...), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorWithFields записывает ERROR сообщение с полями
|
||||||
|
func (l *Logger) ErrorWithFields(msg string, fields map[string]interface{}) {
|
||||||
|
l.log(ErrorLevel, msg, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fatal записывает FATAL сообщение и завершает программу
|
||||||
|
func (l *Logger) Fatal(msg string) {
|
||||||
|
l.log(FatalLevel, msg, nil)
|
||||||
|
l.Close()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fatalf записывает форматированное FATAL сообщение и завершает программу
|
||||||
|
func (l *Logger) Fatalf(format string, args ...interface{}) {
|
||||||
|
l.log(FatalLevel, fmt.Sprintf(format, args...), nil)
|
||||||
|
l.Close()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FatalWithFields записывает FATAL сообщение с полями и завершает программу
|
||||||
|
func (l *Logger) FatalWithFields(msg string, fields map[string]interface{}) {
|
||||||
|
l.log(FatalLevel, msg, fields)
|
||||||
|
l.Close()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLevel устанавливает уровень логирования
|
||||||
|
func (l *Logger) SetLevel(level LogLevel) {
|
||||||
|
l.level.Store(int32(level))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLevel возвращает текущий уровень логирования
|
||||||
|
func (l *Logger) GetLevel() LogLevel {
|
||||||
|
return LogLevel(l.level.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync синхронизирует лог с диском
|
||||||
|
func (l *Logger) Sync() error {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
if l.file != nil {
|
||||||
|
return l.file.Sync()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close закрывает логгер
|
||||||
|
func (l *Logger) Close() {
|
||||||
|
close(l.writeChan)
|
||||||
|
<-l.done
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
if l.file != nil {
|
||||||
|
l.file.Sync()
|
||||||
|
l.file.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Глобальные функции для удобства ==========
|
||||||
|
|
||||||
|
var defaultLogger *Logger
|
||||||
|
var once sync.Once
|
||||||
|
|
||||||
|
// InitDefaultLogger инициализирует глобальный логгер
|
||||||
|
func InitDefaultLogger(filename string, level string) error {
|
||||||
|
var err error
|
||||||
|
once.Do(func() {
|
||||||
|
defaultLogger, err = NewLogger(filename, level)
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultLogger возвращает глобальный логгер
|
||||||
|
func GetDefaultLogger() *Logger {
|
||||||
|
return defaultLogger
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDefaultLogger устанавливает глобальный логгер
|
||||||
|
func SetDefaultLogger(logger *Logger) {
|
||||||
|
defaultLogger = logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug глобальная функция DEBUG
|
||||||
|
func Debug(msg string) {
|
||||||
|
if defaultLogger != nil {
|
||||||
|
defaultLogger.Debug(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info глобальная функция INFO
|
||||||
|
func Info(msg string) {
|
||||||
|
if defaultLogger != nil {
|
||||||
|
defaultLogger.Info(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn глобальная функция WARN
|
||||||
|
func Warn(msg string) {
|
||||||
|
if defaultLogger != nil {
|
||||||
|
defaultLogger.Warn(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error глобальная функция ERROR
|
||||||
|
func Error(msg string) {
|
||||||
|
if defaultLogger != nil {
|
||||||
|
defaultLogger.Error(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debugf глобальная функция форматированного DEBUG
|
||||||
|
func Debugf(format string, args ...interface{}) {
|
||||||
|
if defaultLogger != nil {
|
||||||
|
defaultLogger.Debugf(format, args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Infof глобальная функция форматированного INFO
|
||||||
|
func Infof(format string, args ...interface{}) {
|
||||||
|
if defaultLogger != nil {
|
||||||
|
defaultLogger.Infof(format, args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warnf глобальная функция форматированного WARN
|
||||||
|
func Warnf(format string, args ...interface{}) {
|
||||||
|
if defaultLogger != nil {
|
||||||
|
defaultLogger.Warnf(format, args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Errorf глобальная функция форматированного ERROR
|
||||||
|
func Errorf(format string, args ...interface{}) {
|
||||||
|
if defaultLogger != nil {
|
||||||
|
defaultLogger.Errorf(format, args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DebugWithFields глобальная функция DEBUG с полями
|
||||||
|
func DebugWithFields(msg string, fields map[string]interface{}) {
|
||||||
|
if defaultLogger != nil {
|
||||||
|
defaultLogger.DebugWithFields(msg, fields)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InfoWithFields глобальная функция INFO с полями
|
||||||
|
func InfoWithFields(msg string, fields map[string]interface{}) {
|
||||||
|
if defaultLogger != nil {
|
||||||
|
defaultLogger.InfoWithFields(msg, fields)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WarnWithFields глобальная функция WARN с полями
|
||||||
|
func WarnWithFields(msg string, fields map[string]interface{}) {
|
||||||
|
if defaultLogger != nil {
|
||||||
|
defaultLogger.WarnWithFields(msg, fields)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorWithFields глобальная функция ERROR с полями
|
||||||
|
func ErrorWithFields(msg string, fields map[string]interface{}) {
|
||||||
|
if defaultLogger != nil {
|
||||||
|
defaultLogger.ErrorWithFields(msg, fields)
|
||||||
|
}
|
||||||
|
}
|
||||||
2400
internal/plugin/plugin.go
Normal file
2400
internal/plugin/plugin.go
Normal file
File diff suppressed because it is too large
Load Diff
106
internal/repl/history.go
Normal file
106
internal/repl/history.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/repl/history.go
|
||||||
|
// Назначение: Управление историей команд REPL
|
||||||
|
|
||||||
|
package repl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// History управляет историей команд
|
||||||
|
type History struct {
|
||||||
|
entries []string
|
||||||
|
maxSize int
|
||||||
|
filePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHistory создаёт новый объект истории
|
||||||
|
func NewHistory(maxSize int) *History {
|
||||||
|
homeDir, _ := os.UserHomeDir()
|
||||||
|
filePath := filepath.Join(homeDir, ".futriis_history")
|
||||||
|
|
||||||
|
return &History{
|
||||||
|
entries: make([]string, 0, maxSize),
|
||||||
|
maxSize: maxSize,
|
||||||
|
filePath: filePath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add добавляет команду в историю
|
||||||
|
func (h *History) Add(cmd string) error {
|
||||||
|
// Не добавляем дубликаты подряд
|
||||||
|
if len(h.entries) > 0 && h.entries[len(h.entries)-1] == cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
h.entries = append(h.entries, cmd)
|
||||||
|
|
||||||
|
// Ограничиваем размер истории
|
||||||
|
if len(h.entries) > h.maxSize {
|
||||||
|
h.entries = h.entries[len(h.entries)-h.maxSize:]
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load загружает историю из файла
|
||||||
|
func (h *History) Load() error {
|
||||||
|
file, err := os.Open(h.filePath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
cmd := scanner.Text()
|
||||||
|
if cmd != "" {
|
||||||
|
h.entries = append(h.entries, cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ограничиваем размер
|
||||||
|
if len(h.entries) > h.maxSize {
|
||||||
|
h.entries = h.entries[len(h.entries)-h.maxSize:]
|
||||||
|
}
|
||||||
|
|
||||||
|
return scanner.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save сохраняет историю в файл
|
||||||
|
func (h *History) Save() error {
|
||||||
|
file, err := os.Create(h.filePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
writer := bufio.NewWriter(file)
|
||||||
|
for _, cmd := range h.entries {
|
||||||
|
if _, err := writer.WriteString(cmd + "\n"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return writer.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEntries возвращает все записи истории
|
||||||
|
func (h *History) GetEntries() []string {
|
||||||
|
return h.entries
|
||||||
|
}
|
||||||
2549
internal/repl/repl.go
Normal file
2549
internal/repl/repl.go
Normal file
File diff suppressed because it is too large
Load Diff
27
internal/serializer/msgpack.go
Normal file
27
internal/serializer/msgpack.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/serializer/msgpack.go
|
||||||
|
// Назначение: Сериализация и десериализация документов в формате MessagePack.
|
||||||
|
// Используется библиотека vmihailenco/msgpack для высокой производительности.
|
||||||
|
|
||||||
|
package serializer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/vmihailenco/msgpack/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Marshal(v interface{}) ([]byte, error) {
|
||||||
|
return msgpack.Marshal(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Unmarshal(data []byte, v interface{}) error {
|
||||||
|
return msgpack.Unmarshal(data, v)
|
||||||
|
}
|
||||||
184
internal/storage/audit.go
Normal file
184
internal/storage/audit.go
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/storage/audit.go
|
||||||
|
// Назначение: Аудит всех операций создания, изменения, удаления данных
|
||||||
|
// с записью временной метки с точностью до миллисекунды
|
||||||
|
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuditEntry представляет запись аудита
|
||||||
|
type AuditEntry struct {
|
||||||
|
ID string `msgpack:"id"`
|
||||||
|
Timestamp int64 `msgpack:"timestamp"` // Unix миллисекунды
|
||||||
|
TimestampStr string `msgpack:"timestamp_str"` // Человекочитаемая строка
|
||||||
|
Operation string `msgpack:"operation"` // CREATE, UPDATE, DELETE, START, COMMIT, ABORT, CLUSTER, SOFT_DELETE, RESTORE, PERMANENT_DELETE
|
||||||
|
DataType string `msgpack:"data_type"` // DATABASE, COLLECTION, DOCUMENT, FIELD, TUPLE, SESSION, TRANSACTION, CLUSTER, INDEX
|
||||||
|
Name string `msgpack:"name"` // Имя объекта
|
||||||
|
Details map[string]interface{} `msgpack:"details"` // Детали операции
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditLogger управляет аудитом
|
||||||
|
type AuditLogger struct {
|
||||||
|
entries []AuditEntry
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
var globalAuditLogger = &AuditLogger{
|
||||||
|
entries: make([]AuditEntry, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCurrentTimestamp возвращает текущую временную метку с миллисекундами
|
||||||
|
func GetCurrentTimestamp() (int64, string) {
|
||||||
|
now := time.Now()
|
||||||
|
timestampMs := now.UnixMilli()
|
||||||
|
timestampStr := now.Format("2006-01-02 15:04:05.000")
|
||||||
|
return timestampMs, timestampStr
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogAudit записывает событие в аудит
|
||||||
|
func LogAudit(operation, dataType, name string, details map[string]interface{}) {
|
||||||
|
timestampMs, timestampStr := GetCurrentTimestamp()
|
||||||
|
|
||||||
|
// Если details не содержат timestamp, добавляем его
|
||||||
|
if details == nil {
|
||||||
|
details = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
if _, ok := details["audit_timestamp"]; !ok {
|
||||||
|
details["audit_timestamp"] = timestampMs
|
||||||
|
details["audit_timestamp_str"] = timestampStr
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := AuditEntry{
|
||||||
|
ID: fmt.Sprintf("%d", timestampMs),
|
||||||
|
Timestamp: timestampMs,
|
||||||
|
TimestampStr: timestampStr,
|
||||||
|
Operation: operation,
|
||||||
|
DataType: dataType,
|
||||||
|
Name: name,
|
||||||
|
Details: details,
|
||||||
|
}
|
||||||
|
|
||||||
|
globalAuditLogger.mu.Lock()
|
||||||
|
globalAuditLogger.entries = append(globalAuditLogger.entries, entry)
|
||||||
|
globalAuditLogger.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAuditLog возвращает копию лога аудита
|
||||||
|
func GetAuditLog() []AuditEntry {
|
||||||
|
globalAuditLogger.mu.RLock()
|
||||||
|
defer globalAuditLogger.mu.RUnlock()
|
||||||
|
|
||||||
|
result := make([]AuditEntry, len(globalAuditLogger.entries))
|
||||||
|
copy(result, globalAuditLogger.entries)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAuditLogFiltered возвращает отфильтрованный лог аудита
|
||||||
|
func GetAuditLogFiltered(dataType, operation string, fromTime, toTime int64) []AuditEntry {
|
||||||
|
globalAuditLogger.mu.RLock()
|
||||||
|
defer globalAuditLogger.mu.RUnlock()
|
||||||
|
|
||||||
|
result := make([]AuditEntry, 0)
|
||||||
|
for _, entry := range globalAuditLogger.entries {
|
||||||
|
if dataType != "" && entry.DataType != dataType {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if operation != "" && entry.Operation != operation {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fromTime > 0 && entry.Timestamp < fromTime {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if toTime > 0 && entry.Timestamp > toTime {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, entry)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearAuditLog очищает лог аудита (только для отладки)
|
||||||
|
func ClearAuditLog() {
|
||||||
|
globalAuditLogger.mu.Lock()
|
||||||
|
defer globalAuditLogger.mu.Unlock()
|
||||||
|
globalAuditLogger.entries = make([]AuditEntry, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAuditLogSize возвращает количество записей в логе аудита
|
||||||
|
func GetAuditLogSize() int {
|
||||||
|
globalAuditLogger.mu.RLock()
|
||||||
|
defer globalAuditLogger.mu.RUnlock()
|
||||||
|
return len(globalAuditLogger.entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditDatabaseOperation логирует операцию с базой данных
|
||||||
|
func AuditDatabaseOperation(operation, dbName string) {
|
||||||
|
LogAudit(operation, "DATABASE", dbName, map[string]interface{}{
|
||||||
|
"database": dbName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditCollectionOperation логирует операцию с коллекцией
|
||||||
|
func AuditCollectionOperation(operation, dbName, collName string, settings interface{}) {
|
||||||
|
LogAudit(operation, "COLLECTION", fmt.Sprintf("%s.%s", dbName, collName), map[string]interface{}{
|
||||||
|
"database": dbName,
|
||||||
|
"collection": collName,
|
||||||
|
"settings": settings,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditDocumentOperation логирует операцию с документом
|
||||||
|
func AuditDocumentOperation(operation, dbName, collName, docID string, fields map[string]interface{}) {
|
||||||
|
LogAudit(operation, "DOCUMENT", fmt.Sprintf("%s.%s.%s", dbName, collName, docID), map[string]interface{}{
|
||||||
|
"database": dbName,
|
||||||
|
"collection": collName,
|
||||||
|
"document_id": docID,
|
||||||
|
"fields": fields,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditFieldOperation логирует операцию с полем
|
||||||
|
func AuditFieldOperation(operation, dbName, collName, docID, fieldName string, value interface{}) {
|
||||||
|
LogAudit(operation, "FIELD", fmt.Sprintf("%s.%s.%s.%s", dbName, collName, docID, fieldName), map[string]interface{}{
|
||||||
|
"database": dbName,
|
||||||
|
"collection": collName,
|
||||||
|
"document_id": docID,
|
||||||
|
"field": fieldName,
|
||||||
|
"value": value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditTupleOperation логирует операцию с кортежем
|
||||||
|
func AuditTupleOperation(operation, dbName, collName, docID, tuplePath string) {
|
||||||
|
LogAudit(operation, "TUPLE", fmt.Sprintf("%s.%s.%s.%s", dbName, collName, docID, tuplePath), map[string]interface{}{
|
||||||
|
"database": dbName,
|
||||||
|
"collection": collName,
|
||||||
|
"document_id": docID,
|
||||||
|
"tuple_path": tuplePath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditIndexOperation логирует операцию с индексом
|
||||||
|
func AuditIndexOperation(operation, dbName, collName, indexName string, fields []string, unique bool) {
|
||||||
|
LogAudit(operation, "INDEX", fmt.Sprintf("%s.%s.%s", dbName, collName, indexName), map[string]interface{}{
|
||||||
|
"database": dbName,
|
||||||
|
"collection": collName,
|
||||||
|
"index_name": indexName,
|
||||||
|
"fields": fields,
|
||||||
|
"unique": unique,
|
||||||
|
})
|
||||||
|
}
|
||||||
1354
internal/storage/collection.go
Normal file
1354
internal/storage/collection.go
Normal file
File diff suppressed because it is too large
Load Diff
584
internal/storage/document.go
Normal file
584
internal/storage/document.go
Normal file
@@ -0,0 +1,584 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/storage/document.go
|
||||||
|
// Назначение: Определение структуры документа, его методов для работы
|
||||||
|
// с полями, кортежами (вложенными документами) и сериализации в MessagePack.
|
||||||
|
// Документ является основной единицей хранения в СУБД futriis.
|
||||||
|
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"futriis/internal/compression"
|
||||||
|
"futriis/internal/serializer"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Document представляет документ в коллекции (аналог строки в реляционной СУБД)
|
||||||
|
type Document struct {
|
||||||
|
ID string `msgpack:"_id"` // Уникальный идентификатор документа
|
||||||
|
Fields map[string]interface{} `msgpack:"fields"` // Поля документа (аналог колонок)
|
||||||
|
CreatedAt int64 `msgpack:"created_at"` // Время создания (Unix миллисекунды)
|
||||||
|
UpdatedAt int64 `msgpack:"updated_at"` // Время последнего обновления
|
||||||
|
DeletedAt int64 `msgpack:"deleted_at"` // Время удаления (Unix миллисекунды, 0 = не удалён)
|
||||||
|
Version uint64 `msgpack:"version"` // Версия документа (для оптимистичных блокировок)
|
||||||
|
Compressed bool `msgpack:"compressed"` // Флаг, сжат ли документ
|
||||||
|
OriginalSize int64 `msgpack:"original_size"` // Оригинальный размер до сжатия
|
||||||
|
mu sync.RWMutex `msgpack:"-"` // Блокировка для wait-free операций
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tuple представляет вложенный документ (аналог кортежа в реляционной СУБД)
|
||||||
|
type Tuple struct {
|
||||||
|
Fields map[string]interface{} `msgpack:"fields"`
|
||||||
|
CreatedAt int64 `msgpack:"created_at"` // Время создания кортежа
|
||||||
|
UpdatedAt int64 `msgpack:"updated_at"` // Время последнего обновления кортежа
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field представляет отдельное поле документа (аналог колонки)
|
||||||
|
type Field struct {
|
||||||
|
Name string `msgpack:"name"`
|
||||||
|
Type FieldType `msgpack:"type"`
|
||||||
|
Value interface{} `msgpack:"value"`
|
||||||
|
UpdatedAt int64 `msgpack:"updated_at"` // Время последнего обновления поля
|
||||||
|
}
|
||||||
|
|
||||||
|
// FieldType определяет тип поля документа
|
||||||
|
type FieldType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
TypeString FieldType = iota
|
||||||
|
TypeNumber
|
||||||
|
TypeBoolean
|
||||||
|
TypeTuple // Вложенный документ
|
||||||
|
TypeArray
|
||||||
|
TypeNull
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewDocument создаёт новый документ с автоматической генерацией ID
|
||||||
|
func NewDocument() *Document {
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
return &Document{
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
Fields: make(map[string]interface{}),
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
DeletedAt: 0,
|
||||||
|
Version: 1,
|
||||||
|
Compressed: false,
|
||||||
|
OriginalSize: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDocumentWithID создаёт документ с указанным ID
|
||||||
|
func NewDocumentWithID(id string) *Document {
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
return &Document{
|
||||||
|
ID: id,
|
||||||
|
Fields: make(map[string]interface{}),
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
DeletedAt: 0,
|
||||||
|
Version: 1,
|
||||||
|
Compressed: false,
|
||||||
|
OriginalSize: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetField устанавливает значение поля документа (wait-free)
|
||||||
|
func (d *Document) SetField(name string, value interface{}) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
|
d.Fields[name] = value
|
||||||
|
d.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
d.Version++
|
||||||
|
d.Compressed = false // При изменении документа снимаем флаг сжатия
|
||||||
|
|
||||||
|
// Аудит изменения поля
|
||||||
|
AuditFieldOperation("UPDATE", "", "", d.ID, name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetField возвращает значение поля документа
|
||||||
|
func (d *Document) GetField(name string) (interface{}, error) {
|
||||||
|
d.mu.RLock()
|
||||||
|
defer d.mu.RUnlock()
|
||||||
|
|
||||||
|
if val, ok := d.Fields[name]; ok {
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("field not found: %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteField удаляет поле из документа
|
||||||
|
func (d *Document) DeleteField(name string) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
|
delete(d.Fields, name)
|
||||||
|
d.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
d.Version++
|
||||||
|
d.Compressed = false
|
||||||
|
|
||||||
|
// Аудит удаления поля
|
||||||
|
AuditFieldOperation("DELETE", "", "", d.ID, name, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasField проверяет наличие поля в документе
|
||||||
|
func (d *Document) HasField(name string) bool {
|
||||||
|
d.mu.RLock()
|
||||||
|
defer d.mu.RUnlock()
|
||||||
|
|
||||||
|
_, ok := d.Fields[name]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFields возвращает копию всех полей документа
|
||||||
|
func (d *Document) GetFields() map[string]interface{} {
|
||||||
|
d.mu.RLock()
|
||||||
|
defer d.mu.RUnlock()
|
||||||
|
|
||||||
|
copy := make(map[string]interface{})
|
||||||
|
for k, v := range d.Fields {
|
||||||
|
copy[k] = v
|
||||||
|
}
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTuple устанавливает вложенный документ (кортеж) в поле
|
||||||
|
func (d *Document) SetTuple(fieldName string, tuple *Tuple) {
|
||||||
|
d.SetField(fieldName, tuple)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTuple возвращает вложенный документ из поля
|
||||||
|
func (d *Document) GetTuple(fieldName string) (*Tuple, error) {
|
||||||
|
val, err := d.GetField(fieldName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if tuple, ok := val.(*Tuple); ok {
|
||||||
|
return tuple, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("field %s is not a tuple", fieldName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize сериализует документ в MessagePack с поддержкой сжатия
|
||||||
|
func (d *Document) Serialize() ([]byte, error) {
|
||||||
|
d.mu.RLock()
|
||||||
|
defer d.mu.RUnlock()
|
||||||
|
|
||||||
|
data, err := serializer.Marshal(d)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SerializeCompressed сериализует и сжимает документ
|
||||||
|
func (d *Document) SerializeCompressed(compressionConfig *compression.Config) ([]byte, error) {
|
||||||
|
d.mu.RLock()
|
||||||
|
defer d.mu.RUnlock()
|
||||||
|
|
||||||
|
// Сериализуем документ
|
||||||
|
data, err := serializer.Marshal(d)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, нужно ли сжимать
|
||||||
|
if compressionConfig != nil && compressionConfig.Enabled && len(data) >= compressionConfig.MinSize {
|
||||||
|
compressed, err := compression.Compress(data, compressionConfig)
|
||||||
|
if err != nil {
|
||||||
|
// При ошибке сжатия возвращаем несжатые данные
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
return compressed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deserialize десериализует документ из MessagePack (автоматически определяет сжатие)
|
||||||
|
func (d *Document) Deserialize(data []byte) error {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
|
// Пытаемся определить, сжаты ли данные
|
||||||
|
// Для этого пробуем распаковать, если не получается - данные несжатые
|
||||||
|
decompressed, err := compression.DecompressAuto(data)
|
||||||
|
if err == nil && len(decompressed) < len(data) {
|
||||||
|
// Данные были сжаты, используем распакованную версию
|
||||||
|
if err := serializer.Unmarshal(decompressed, d); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
d.Compressed = true
|
||||||
|
d.OriginalSize = int64(len(decompressed))
|
||||||
|
} else {
|
||||||
|
// Данные не сжаты или не удалось распаковать
|
||||||
|
if err := serializer.Unmarshal(data, d); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
d.Compressed = false
|
||||||
|
d.OriginalSize = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем временные метки при десериализации
|
||||||
|
d.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone создаёт глубокую копию документа
|
||||||
|
func (d *Document) Clone() *Document {
|
||||||
|
d.mu.RLock()
|
||||||
|
defer d.mu.RUnlock()
|
||||||
|
|
||||||
|
clone := &Document{
|
||||||
|
ID: d.ID,
|
||||||
|
Fields: make(map[string]interface{}),
|
||||||
|
CreatedAt: d.CreatedAt,
|
||||||
|
UpdatedAt: d.UpdatedAt,
|
||||||
|
DeletedAt: d.DeletedAt,
|
||||||
|
Version: d.Version,
|
||||||
|
Compressed: d.Compressed,
|
||||||
|
OriginalSize: d.OriginalSize,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Глубокое копирование полей
|
||||||
|
for k, v := range d.Fields {
|
||||||
|
clone.Fields[k] = deepCopyValue(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update применяет обновление к документу (атомарно)
|
||||||
|
func (d *Document) Update(updates map[string]interface{}) error {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
|
for k, v := range updates {
|
||||||
|
d.Fields[k] = v
|
||||||
|
}
|
||||||
|
d.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
d.Version++
|
||||||
|
d.Compressed = false // После обновления документ больше не сжат
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SoftDelete мягко удаляет документ (устанавливает метку времени удаления)
|
||||||
|
func (d *Document) SoftDelete() {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
|
d.DeletedAt = time.Now().UnixMilli()
|
||||||
|
d.UpdatedAt = d.DeletedAt
|
||||||
|
d.Version++
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDeleted проверяет, удалён ли документ (мягкое удаление)
|
||||||
|
func (d *Document) IsDeleted() bool {
|
||||||
|
d.mu.RLock()
|
||||||
|
defer d.mu.RUnlock()
|
||||||
|
return d.DeletedAt > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore восстанавливает мягко удалённый документ
|
||||||
|
func (d *Document) Restore() {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
|
d.DeletedAt = 0
|
||||||
|
d.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
d.Version++
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeletedAtStr возвращает человекочитаемую строку времени удаления
|
||||||
|
func (d *Document) GetDeletedAtStr() string {
|
||||||
|
d.mu.RLock()
|
||||||
|
defer d.mu.RUnlock()
|
||||||
|
if d.DeletedAt == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return time.UnixMilli(d.DeletedAt).Format("2006-01-02 15:04:05.000")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCreatedAtStr возвращает человекочитаемую строку времени создания
|
||||||
|
func (d *Document) GetCreatedAtStr() string {
|
||||||
|
d.mu.RLock()
|
||||||
|
defer d.mu.RUnlock()
|
||||||
|
return time.UnixMilli(d.CreatedAt).Format("2006-01-02 15:04:05.000")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUpdatedAtStr возвращает человекочитаемую строку времени обновления
|
||||||
|
func (d *Document) GetUpdatedAtStr() string {
|
||||||
|
d.mu.RLock()
|
||||||
|
defer d.mu.RUnlock()
|
||||||
|
return time.UnixMilli(d.UpdatedAt).Format("2006-01-02 15:04:05.000")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compress сжимает документ в памяти
|
||||||
|
func (d *Document) Compress(config *compression.Config) error {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
|
if d.Compressed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем текущее состояние
|
||||||
|
originalSize := len(d.Fields)
|
||||||
|
if originalSize < config.MinSize {
|
||||||
|
return nil // Не сжимаем маленькие документы
|
||||||
|
}
|
||||||
|
|
||||||
|
d.Compressed = true
|
||||||
|
d.OriginalSize = int64(originalSize)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decompress распаковывает документ в памяти
|
||||||
|
func (d *Document) Decompress() error {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
|
if !d.Compressed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
d.Compressed = false
|
||||||
|
d.OriginalSize = 0
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCompressionRatio возвращает коэффициент сжатия
|
||||||
|
func (d *Document) GetCompressionRatio() float64 {
|
||||||
|
d.mu.RLock()
|
||||||
|
defer d.mu.RUnlock()
|
||||||
|
|
||||||
|
if !d.Compressed || d.OriginalSize == 0 {
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSize := len(d.Fields)
|
||||||
|
return float64(currentSize) / float64(d.OriginalSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMetadata возвращает метаданные документа (временные метки)
|
||||||
|
func (d *Document) GetMetadata() map[string]int64 {
|
||||||
|
d.mu.RLock()
|
||||||
|
defer d.mu.RUnlock()
|
||||||
|
|
||||||
|
return map[string]int64{
|
||||||
|
"created_at": d.CreatedAt,
|
||||||
|
"updated_at": d.UpdatedAt,
|
||||||
|
"deleted_at": d.DeletedAt,
|
||||||
|
"version": int64(d.Version),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deepCopyValue выполняет глубокое копирование значения
|
||||||
|
func deepCopyValue(val interface{}) interface{} {
|
||||||
|
switch v := val.(type) {
|
||||||
|
case *Tuple:
|
||||||
|
return v.Clone()
|
||||||
|
case map[string]interface{}:
|
||||||
|
copy := make(map[string]interface{})
|
||||||
|
for k, val := range v {
|
||||||
|
copy[k] = deepCopyValue(val)
|
||||||
|
}
|
||||||
|
return copy
|
||||||
|
case []interface{}:
|
||||||
|
copy := make([]interface{}, len(v))
|
||||||
|
for i, val := range v {
|
||||||
|
copy[i] = deepCopyValue(val)
|
||||||
|
}
|
||||||
|
return copy
|
||||||
|
default:
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTuple создаёт новый вложенный документ (кортеж)
|
||||||
|
func NewTuple() *Tuple {
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
return &Tuple{
|
||||||
|
Fields: make(map[string]interface{}),
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set устанавливает поле во вложенном документе
|
||||||
|
func (t *Tuple) Set(name string, value interface{}) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
t.Fields[name] = value
|
||||||
|
t.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get возвращает поле из вложенного документа
|
||||||
|
func (t *Tuple) Get(name string) (interface{}, error) {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
|
||||||
|
if val, ok := t.Fields[name]; ok {
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("tuple field not found: %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone создаёт копию кортежа
|
||||||
|
func (t *Tuple) Clone() *Tuple {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
|
||||||
|
clone := NewTuple()
|
||||||
|
for k, v := range t.Fields {
|
||||||
|
clone.Fields[k] = deepCopyValue(v)
|
||||||
|
}
|
||||||
|
clone.CreatedAt = t.CreatedAt
|
||||||
|
clone.UpdatedAt = t.UpdatedAt
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToMap конвертирует кортеж в map
|
||||||
|
func (t *Tuple) ToMap() map[string]interface{} {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
|
||||||
|
copy := make(map[string]interface{})
|
||||||
|
for k, v := range t.Fields {
|
||||||
|
copy[k] = v
|
||||||
|
}
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTupleMetadata возвращает метаданные кортежа
|
||||||
|
func (t *Tuple) GetTupleMetadata() map[string]int64 {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
|
||||||
|
return map[string]int64{
|
||||||
|
"created_at": t.CreatedAt,
|
||||||
|
"updated_at": t.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNestedField получает значение по точечному пути (например, "user.address.city")
|
||||||
|
func (d *Document) GetNestedField(path string) (interface{}, error) {
|
||||||
|
parts := strings.Split(path, ".")
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return nil, fmt.Errorf("empty path")
|
||||||
|
}
|
||||||
|
|
||||||
|
current := interface{}(d)
|
||||||
|
for _, part := range parts {
|
||||||
|
switch v := current.(type) {
|
||||||
|
case *Document:
|
||||||
|
val, err := v.GetField(part)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
current = val
|
||||||
|
case *Tuple:
|
||||||
|
val, err := v.Get(part)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
current = val
|
||||||
|
case map[string]interface{}:
|
||||||
|
if val, ok := v[part]; ok {
|
||||||
|
current = val
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("field not found: %s", part)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("cannot navigate into non-document value at %s", part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return current, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetNestedField устанавливает значение по точечному пути
|
||||||
|
func (d *Document) SetNestedField(path string, value interface{}) error {
|
||||||
|
parts := strings.Split(path, ".")
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return fmt.Errorf("empty path")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если путь состоит из одного элемента, просто устанавливаем поле
|
||||||
|
if len(parts) == 1 {
|
||||||
|
d.SetField(parts[0], value)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Иначе нужно создать промежуточные структуры
|
||||||
|
current := interface{}(d)
|
||||||
|
for i := 0; i < len(parts)-1; i++ {
|
||||||
|
part := parts[i]
|
||||||
|
|
||||||
|
switch v := current.(type) {
|
||||||
|
case *Document:
|
||||||
|
if !v.HasField(part) {
|
||||||
|
// Создаём новый кортеж, если поле не существует
|
||||||
|
newTuple := NewTuple()
|
||||||
|
v.SetField(part, newTuple)
|
||||||
|
current = newTuple
|
||||||
|
} else {
|
||||||
|
field, _ := v.GetField(part)
|
||||||
|
if tuple, ok := field.(*Tuple); ok {
|
||||||
|
current = tuple
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("field %s is not a tuple", part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case *Tuple:
|
||||||
|
if val, err := v.Get(part); err == nil {
|
||||||
|
if tuple, ok := val.(*Tuple); ok {
|
||||||
|
current = tuple
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("field %s is not a tuple", part)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newTuple := NewTuple()
|
||||||
|
v.Set(part, newTuple)
|
||||||
|
current = newTuple
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("cannot set nested field on non-document value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Устанавливаем значение в последний элемент пути
|
||||||
|
lastPart := parts[len(parts)-1]
|
||||||
|
switch v := current.(type) {
|
||||||
|
case *Document:
|
||||||
|
v.SetField(lastPart, value)
|
||||||
|
case *Tuple:
|
||||||
|
v.Set(lastPart, value)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("cannot set field on non-document value")
|
||||||
|
}
|
||||||
|
|
||||||
|
d.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
d.Compressed = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
797
internal/storage/engine.go
Normal file
797
internal/storage/engine.go
Normal file
@@ -0,0 +1,797 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/storage/engine.go
|
||||||
|
// Назначение: In-memory движок хранения документов с поддержкой коллекций,
|
||||||
|
// слайсов (аналог БД), тапплов (аналог таблиц), полей и кортежей.
|
||||||
|
// Полностью wait-free с использованием sync.Map и атомарных операций.
|
||||||
|
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"futriis/internal/log"
|
||||||
|
"futriis/internal/serializer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Storage представляет основное хранилище баз данных
|
||||||
|
type Storage struct {
|
||||||
|
databases sync.Map // map[string]*Database
|
||||||
|
pageSize int64
|
||||||
|
logger *log.Logger
|
||||||
|
totalDocs atomic.Int64
|
||||||
|
createdAt int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database представляет базу данных (аналог слайса в реляционных СУБД)
|
||||||
|
type Database struct {
|
||||||
|
name string
|
||||||
|
collections sync.Map // map[string]*Collection
|
||||||
|
createdAt int64
|
||||||
|
updatedAt int64
|
||||||
|
deletedAt int64
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportMetadata содержит метаданные экспорта (добавлено)
|
||||||
|
type ExportMetadata struct {
|
||||||
|
DatabaseName string `msgpack:"database_name"`
|
||||||
|
ExportTime int64 `msgpack:"export_time"`
|
||||||
|
ExportTimeStr string `msgpack:"export_time_str"`
|
||||||
|
Version string `msgpack:"version"`
|
||||||
|
CollectionsCount int `msgpack:"collections_count"`
|
||||||
|
DocumentsCount int64 `msgpack:"documents_count"`
|
||||||
|
ExportedBy string `msgpack:"exported_by,omitempty"`
|
||||||
|
Additional map[string]interface{} `msgpack:"additional,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportMetadata содержит метаданные импорта (добавлено)
|
||||||
|
type ImportMetadata struct {
|
||||||
|
DatabaseName string `msgpack:"database_name"`
|
||||||
|
ImportTime int64 `msgpack:"import_time"`
|
||||||
|
ImportTimeStr string `msgpack:"import_time_str"`
|
||||||
|
SourceFile string `msgpack:"source_file"`
|
||||||
|
SourceExportTime int64 `msgpack:"source_export_time,omitempty"`
|
||||||
|
CollectionsCount int `msgpack:"collections_count"`
|
||||||
|
DocumentsCount int64 `msgpack:"documents_count"`
|
||||||
|
SkippedDocuments int64 `msgpack:"skipped_documents"`
|
||||||
|
FailedDocuments int64 `msgpack:"failed_documents"`
|
||||||
|
ImportedBy string `msgpack:"imported_by,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStorage создаёт новый экземпляр хранилища
|
||||||
|
func NewStorage(pageSizeMB int, logger *log.Logger) *Storage {
|
||||||
|
return &Storage{
|
||||||
|
pageSize: int64(pageSizeMB) * 1024 * 1024,
|
||||||
|
logger: logger,
|
||||||
|
createdAt: time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDatabase создаёт новую базу данных
|
||||||
|
func (s *Storage) CreateDatabase(name string) error {
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
db := &Database{
|
||||||
|
name: name,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
deletedAt: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := s.databases.LoadOrStore(name, db); exists {
|
||||||
|
return fmt.Errorf("database already exists")
|
||||||
|
}
|
||||||
|
AuditDatabaseOperation("CREATE", name)
|
||||||
|
if s.logger != nil {
|
||||||
|
s.logger.Info("Database created: " + name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDatabase возвращает базу данных по имени
|
||||||
|
func (s *Storage) GetDatabase(name string) (*Database, error) {
|
||||||
|
if val, ok := s.databases.Load(name); ok {
|
||||||
|
return val.(*Database), nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("database not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DropDatabase удаляет базу данных
|
||||||
|
func (s *Storage) DropDatabase(name string) error {
|
||||||
|
if val, ok := s.databases.Load(name); ok {
|
||||||
|
db := val.(*Database)
|
||||||
|
db.mu.Lock()
|
||||||
|
db.deletedAt = time.Now().UnixMilli()
|
||||||
|
db.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := s.databases.LoadAndDelete(name); !ok {
|
||||||
|
return fmt.Errorf("database not found")
|
||||||
|
}
|
||||||
|
AuditDatabaseOperation("DROP", name)
|
||||||
|
if s.logger != nil {
|
||||||
|
s.logger.Info("Database dropped: " + name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDatabases возвращает список всех баз данных
|
||||||
|
func (s *Storage) ListDatabases() []string {
|
||||||
|
databases := make([]string, 0)
|
||||||
|
s.databases.Range(func(key, value interface{}) bool {
|
||||||
|
databases = append(databases, key.(string))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return databases
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name возвращает имя базы данных
|
||||||
|
func (db *Database) Name() string {
|
||||||
|
return db.name
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCreatedAt возвращает время создания базы данных
|
||||||
|
func (db *Database) GetCreatedAt() int64 {
|
||||||
|
db.mu.RLock()
|
||||||
|
defer db.mu.RUnlock()
|
||||||
|
return db.createdAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUpdatedAt возвращает время последнего обновления базы данных
|
||||||
|
func (db *Database) GetUpdatedAt() int64 {
|
||||||
|
db.mu.RLock()
|
||||||
|
defer db.mu.RUnlock()
|
||||||
|
return db.updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeletedAt возвращает время удаления базы данных (0 = не удалена)
|
||||||
|
func (db *Database) GetDeletedAt() int64 {
|
||||||
|
db.mu.RLock()
|
||||||
|
defer db.mu.RUnlock()
|
||||||
|
return db.deletedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTimestamp обновляет временную метку базы данных
|
||||||
|
func (db *Database) UpdateTimestamp() {
|
||||||
|
db.mu.Lock()
|
||||||
|
defer db.mu.Unlock()
|
||||||
|
db.updatedAt = time.Now().UnixMilli()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCollection создаёт новую коллекцию в базе данных
|
||||||
|
func (db *Database) CreateCollection(name string) error {
|
||||||
|
if _, exists := db.collections.LoadOrStore(name, NewCollection(db.name, name, nil)); exists {
|
||||||
|
return fmt.Errorf("collection already exists")
|
||||||
|
}
|
||||||
|
db.UpdateTimestamp()
|
||||||
|
AuditCollectionOperation("CREATE", db.name, name, nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCollectionWithSettings создаёт коллекцию с настройками
|
||||||
|
func (db *Database) CreateCollectionWithSettings(name string, settings *CollectionSettings) error {
|
||||||
|
if _, exists := db.collections.LoadOrStore(name, NewCollection(db.name, name, settings)); exists {
|
||||||
|
return fmt.Errorf("collection already exists")
|
||||||
|
}
|
||||||
|
db.UpdateTimestamp()
|
||||||
|
AuditCollectionOperation("CREATE", db.name, name, settings)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCollection возвращает коллекцию по имени
|
||||||
|
func (db *Database) GetCollection(name string) (*Collection, error) {
|
||||||
|
if val, ok := db.collections.Load(name); ok {
|
||||||
|
return val.(*Collection), nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("collection not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DropCollection удаляет коллекцию
|
||||||
|
func (db *Database) DropCollection(name string) error {
|
||||||
|
if _, ok := db.collections.LoadAndDelete(name); !ok {
|
||||||
|
return fmt.Errorf("collection not found")
|
||||||
|
}
|
||||||
|
db.UpdateTimestamp()
|
||||||
|
AuditCollectionOperation("DROP", db.name, name, nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCollections возвращает список всех коллекций в базе данных
|
||||||
|
func (db *Database) ListCollections() []string {
|
||||||
|
collections := make([]string, 0)
|
||||||
|
db.collections.Range(func(key, value interface{}) bool {
|
||||||
|
collections = append(collections, key.(string))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return collections
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTotalDocuments возвращает общее количество документов во всех коллекциях
|
||||||
|
func (s *Storage) GetTotalDocuments() int64 {
|
||||||
|
return s.totalDocs.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPageSize возвращает размер страницы памяти
|
||||||
|
func (s *Storage) GetPageSize() int64 {
|
||||||
|
return s.pageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCreatedAt возвращает время создания хранилища
|
||||||
|
func (s *Storage) GetCreatedAt() int64 {
|
||||||
|
return s.createdAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// SerializeDatabaseWithMetadata сериализует базу данных с метаданными экспорта (модифицировано)
|
||||||
|
func (db *Database) SerializeDatabaseWithMetadata(exportedBy string) ([]byte, *ExportMetadata, error) {
|
||||||
|
dbData := make(map[string]interface{})
|
||||||
|
|
||||||
|
totalDocuments := int64(0)
|
||||||
|
|
||||||
|
db.collections.Range(func(key, value interface{}) bool {
|
||||||
|
coll := value.(*Collection)
|
||||||
|
collData := make(map[string]interface{})
|
||||||
|
|
||||||
|
docs := coll.GetAllDocuments()
|
||||||
|
totalDocuments += int64(len(docs))
|
||||||
|
|
||||||
|
collDocs := make([]*Document, 0, len(docs))
|
||||||
|
for _, doc := range docs {
|
||||||
|
collDocs = append(collDocs, doc)
|
||||||
|
}
|
||||||
|
collData["documents"] = collDocs
|
||||||
|
collData["metadata"] = coll.GetMetadata()
|
||||||
|
collData["timestamps"] = coll.GetTimestamps()
|
||||||
|
|
||||||
|
// Добавляем информацию об индексах при экспорте
|
||||||
|
collData["indexes"] = coll.GetIndexesInfo()
|
||||||
|
|
||||||
|
// Добавляем ограничения при экспорте
|
||||||
|
collData["constraints"] = coll.GetConstraints()
|
||||||
|
collData["constraint_timestamps"] = coll.GetConstraintTimestamps()
|
||||||
|
|
||||||
|
// Добавляем ACL при экспорте
|
||||||
|
collData["acl_timestamps"] = coll.GetACLTimestamps()
|
||||||
|
|
||||||
|
dbData[key.(string)] = collData
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
nowMs, nowStr := GetCurrentTimestamp()
|
||||||
|
metadata := &ExportMetadata{
|
||||||
|
DatabaseName: db.name,
|
||||||
|
ExportTime: nowMs,
|
||||||
|
ExportTimeStr: nowStr,
|
||||||
|
Version: "1.0",
|
||||||
|
CollectionsCount: len(db.ListCollections()),
|
||||||
|
DocumentsCount: totalDocuments,
|
||||||
|
ExportedBy: exportedBy,
|
||||||
|
Additional: map[string]interface{}{
|
||||||
|
"storage_created_at": time.UnixMilli(db.createdAt).Format("2006-01-02 15:04:05.000"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем метаданные базы данных
|
||||||
|
dbData["_metadata"] = map[string]interface{}{
|
||||||
|
"name": db.name,
|
||||||
|
"created_at": db.GetCreatedAt(),
|
||||||
|
"created_at_str": time.UnixMilli(db.GetCreatedAt()).Format("2006-01-02 15:04:05.000"),
|
||||||
|
"updated_at": db.GetUpdatedAt(),
|
||||||
|
"updated_at_str": time.UnixMilli(db.GetUpdatedAt()).Format("2006-01-02 15:04:05.000"),
|
||||||
|
"export_metadata": metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := serializer.Marshal(dbData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, metadata, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SerializeDatabase сериализует всю базу данных в MessagePack (сохранён для совместимости)
|
||||||
|
func (db *Database) SerializeDatabase() ([]byte, error) {
|
||||||
|
data, _, err := db.SerializeDatabaseWithMetadata("")
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeserializeDatabaseWithMetadata десериализует базу данных с сохранением метаданных импорта (модифицировано)
|
||||||
|
func (db *Database) DeserializeDatabaseWithMetadata(data []byte, sourceFile string, importedBy string) (*ImportMetadata, error) {
|
||||||
|
var dbData map[string]interface{}
|
||||||
|
if err := serializer.Unmarshal(data, &dbData); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourceExportTime int64
|
||||||
|
var collectionsCount int
|
||||||
|
var documentsCount int64
|
||||||
|
|
||||||
|
// Извлекаем метаданные
|
||||||
|
if metaRaw, ok := dbData["_metadata"]; ok {
|
||||||
|
if meta, ok := metaRaw.(map[string]interface{}); ok {
|
||||||
|
if createdAt, ok := meta["created_at"]; ok {
|
||||||
|
if v, ok := createdAt.(int64); ok {
|
||||||
|
db.createdAt = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if exportMetaRaw, ok := meta["export_metadata"]; ok {
|
||||||
|
if exportMeta, ok := exportMetaRaw.(map[string]interface{}); ok {
|
||||||
|
if sourceTime, ok := exportMeta["export_time"]; ok {
|
||||||
|
if v, ok := sourceTime.(int64); ok {
|
||||||
|
sourceExportTime = v
|
||||||
|
} else if v, ok := sourceTime.(float64); ok {
|
||||||
|
sourceExportTime = int64(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if collCount, ok := exportMeta["collections_count"]; ok {
|
||||||
|
if v, ok := collCount.(int); ok {
|
||||||
|
collectionsCount = v
|
||||||
|
} else if v, ok := collCount.(int64); ok {
|
||||||
|
collectionsCount = int(v)
|
||||||
|
} else if v, ok := collCount.(float64); ok {
|
||||||
|
collectionsCount = int(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if docCount, ok := exportMeta["documents_count"]; ok {
|
||||||
|
if v, ok := docCount.(int64); ok {
|
||||||
|
documentsCount = v
|
||||||
|
} else if v, ok := docCount.(float64); ok {
|
||||||
|
documentsCount = int64(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete(dbData, "_metadata")
|
||||||
|
}
|
||||||
|
|
||||||
|
for collName, collDataRaw := range dbData {
|
||||||
|
collData, ok := collDataRaw.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
settings := &CollectionSettings{
|
||||||
|
MaxDocuments: 0,
|
||||||
|
ValidateSchema: false,
|
||||||
|
AutoIndexID: true,
|
||||||
|
TTLSeconds: 0,
|
||||||
|
SoftDelete: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if metaRaw, ok := collData["metadata"]; ok {
|
||||||
|
if meta, ok := metaRaw.(map[string]interface{}); ok {
|
||||||
|
if settingsRaw, ok := meta["settings"]; ok {
|
||||||
|
if settingsMap, ok := settingsRaw.(map[string]interface{}); ok {
|
||||||
|
if maxDocs, ok := settingsMap["max_documents"]; ok {
|
||||||
|
if v, ok := maxDocs.(int); ok {
|
||||||
|
settings.MaxDocuments = v
|
||||||
|
} else if v, ok := maxDocs.(int64); ok {
|
||||||
|
settings.MaxDocuments = int(v)
|
||||||
|
} else if v, ok := maxDocs.(float64); ok {
|
||||||
|
settings.MaxDocuments = int(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if validateSchema, ok := settingsMap["validate_schema"]; ok {
|
||||||
|
if v, ok := validateSchema.(bool); ok {
|
||||||
|
settings.ValidateSchema = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if autoIndexID, ok := settingsMap["auto_index_id"]; ok {
|
||||||
|
if v, ok := autoIndexID.(bool); ok {
|
||||||
|
settings.AutoIndexID = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ttlSeconds, ok := settingsMap["ttl_seconds"]; ok {
|
||||||
|
if v, ok := ttlSeconds.(int); ok {
|
||||||
|
settings.TTLSeconds = v
|
||||||
|
} else if v, ok := ttlSeconds.(int64); ok {
|
||||||
|
settings.TTLSeconds = int(v)
|
||||||
|
} else if v, ok := ttlSeconds.(float64); ok {
|
||||||
|
settings.TTLSeconds = int(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if softDelete, ok := settingsMap["soft_delete"]; ok {
|
||||||
|
if v, ok := softDelete.(bool); ok {
|
||||||
|
settings.SoftDelete = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if meta, ok := metaRaw.(*CollectionMetadata); ok {
|
||||||
|
if meta.Settings != nil {
|
||||||
|
settings = meta.Settings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
coll := NewCollection(db.name, collName, settings)
|
||||||
|
|
||||||
|
// Восстанавливаем временные метки коллекции если они есть
|
||||||
|
if timestampsRaw, ok := collData["timestamps"]; ok {
|
||||||
|
if timestamps, ok := timestampsRaw.(map[string]interface{}); ok {
|
||||||
|
if createdAt, ok := timestamps["created_at"]; ok {
|
||||||
|
if v, ok := createdAt.(int64); ok {
|
||||||
|
coll.metadata.CreatedAt = v
|
||||||
|
} else if v, ok := createdAt.(float64); ok {
|
||||||
|
coll.metadata.CreatedAt = int64(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if updatedAt, ok := timestamps["updated_at"]; ok {
|
||||||
|
if v, ok := updatedAt.(int64); ok {
|
||||||
|
coll.metadata.UpdatedAt = v
|
||||||
|
} else if v, ok := updatedAt.(float64); ok {
|
||||||
|
coll.metadata.UpdatedAt = int64(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Восстанавливаем индексы
|
||||||
|
if indexesRaw, ok := collData["indexes"]; ok {
|
||||||
|
if indexesList, ok := indexesRaw.([]interface{}); ok {
|
||||||
|
for _, idxRaw := range indexesList {
|
||||||
|
if idxMap, ok := idxRaw.(map[string]interface{}); ok {
|
||||||
|
idxName, _ := idxMap["name"].(string)
|
||||||
|
idxFieldsRaw, _ := idxMap["fields"].([]interface{})
|
||||||
|
idxFields := make([]string, len(idxFieldsRaw))
|
||||||
|
for i, f := range idxFieldsRaw {
|
||||||
|
idxFields[i] = fmt.Sprintf("%v", f)
|
||||||
|
}
|
||||||
|
idxUnique, _ := idxMap["unique"].(bool)
|
||||||
|
if idxName != "" && idxName != "_id_" {
|
||||||
|
coll.CreateIndex(idxName, idxFields, idxUnique)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if docsRaw, ok := collData["documents"]; ok {
|
||||||
|
if docs, ok := docsRaw.([]interface{}); ok {
|
||||||
|
for _, docRaw := range docs {
|
||||||
|
if doc, ok := docRaw.(*Document); ok {
|
||||||
|
coll.Insert(doc)
|
||||||
|
} else if docMap, ok := docRaw.(map[string]interface{}); ok {
|
||||||
|
doc := NewDocument()
|
||||||
|
if id, ok := docMap["ID"].(string); ok {
|
||||||
|
doc.ID = id
|
||||||
|
} else if id, ok := docMap["id"].(string); ok {
|
||||||
|
doc.ID = id
|
||||||
|
}
|
||||||
|
if fields, ok := docMap["fields"]; ok {
|
||||||
|
if fieldsMap, ok := fields.(map[string]interface{}); ok {
|
||||||
|
for k, v := range fieldsMap {
|
||||||
|
doc.SetField(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if createdAt, ok := docMap["created_at"]; ok {
|
||||||
|
if v, ok := createdAt.(int64); ok {
|
||||||
|
doc.CreatedAt = v
|
||||||
|
} else if v, ok := createdAt.(float64); ok {
|
||||||
|
doc.CreatedAt = int64(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if updatedAt, ok := docMap["updated_at"]; ok {
|
||||||
|
if v, ok := updatedAt.(int64); ok {
|
||||||
|
doc.UpdatedAt = v
|
||||||
|
} else if v, ok := updatedAt.(float64); ok {
|
||||||
|
doc.UpdatedAt = int64(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if deletedAt, ok := docMap["deleted_at"]; ok {
|
||||||
|
if v, ok := deletedAt.(int64); ok {
|
||||||
|
doc.DeletedAt = v
|
||||||
|
} else if v, ok := deletedAt.(float64); ok {
|
||||||
|
doc.DeletedAt = int64(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if version, ok := docMap["version"]; ok {
|
||||||
|
if v, ok := version.(uint64); ok {
|
||||||
|
doc.Version = v
|
||||||
|
} else if v, ok := version.(int64); ok {
|
||||||
|
doc.Version = uint64(v)
|
||||||
|
} else if v, ok := version.(float64); ok {
|
||||||
|
doc.Version = uint64(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
coll.Insert(doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if docs, ok := docsRaw.([]*Document); ok {
|
||||||
|
for _, doc := range docs {
|
||||||
|
coll.Insert(doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.collections.Store(collName, coll)
|
||||||
|
AuditCollectionOperation("RESTORE", db.name, collName, settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
nowMs, nowStr := GetCurrentTimestamp()
|
||||||
|
importMetadata := &ImportMetadata{
|
||||||
|
DatabaseName: db.name,
|
||||||
|
ImportTime: nowMs,
|
||||||
|
ImportTimeStr: nowStr,
|
||||||
|
SourceFile: sourceFile,
|
||||||
|
SourceExportTime: sourceExportTime,
|
||||||
|
CollectionsCount: collectionsCount,
|
||||||
|
DocumentsCount: documentsCount,
|
||||||
|
SkippedDocuments: 0,
|
||||||
|
FailedDocuments: 0,
|
||||||
|
ImportedBy: importedBy,
|
||||||
|
}
|
||||||
|
|
||||||
|
db.UpdateTimestamp()
|
||||||
|
|
||||||
|
// Аудит импорта
|
||||||
|
LogAudit("IMPORT", "DATABASE", db.name, map[string]interface{}{
|
||||||
|
"source_file": sourceFile,
|
||||||
|
"collections_count": collectionsCount,
|
||||||
|
"documents_count": documentsCount,
|
||||||
|
"source_export_time": sourceExportTime,
|
||||||
|
"imported_by": importedBy,
|
||||||
|
})
|
||||||
|
|
||||||
|
return importMetadata, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeserializeDatabase десериализует базу данных из MessagePack (сохранён для совместимости)
|
||||||
|
func (db *Database) DeserializeDatabase(data []byte) error {
|
||||||
|
_, err := db.DeserializeDatabaseWithMetadata(data, "", "")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportDatabaseWithMetadata экспортирует базу данных с метаданными (добавлено)
|
||||||
|
func (s *Storage) ExportDatabaseWithMetadata(dbName, filePath, exportedBy string) (*ExportMetadata, error) {
|
||||||
|
if !s.ExistsDatabase(dbName) {
|
||||||
|
return nil, fmt.Errorf("database '%s' not found", dbName)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := s.GetDatabase(dbName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
data, metadata, err := db.SerializeDatabaseWithMetadata(exportedBy)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(filePath, data, 0644); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write export file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.logger != nil {
|
||||||
|
s.logger.Info(fmt.Sprintf("Database '%s' exported to %s at %s", dbName, filePath, metadata.ExportTimeStr))
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportDatabaseWithMetadata импортирует базу данных с метаданными (добавлено)
|
||||||
|
func (s *Storage) ImportDatabaseWithMetadata(dbName, filePath, importedBy string) (*ImportMetadata, error) {
|
||||||
|
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||||
|
return nil, fmt.Errorf("import file '%s' not found", filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read import file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.ExistsDatabase(dbName) {
|
||||||
|
if err := s.CreateDatabase(dbName); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create database: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := s.GetDatabase(dbName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata, err := db.DeserializeDatabaseWithMetadata(data, filePath, importedBy)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.logger != nil {
|
||||||
|
s.logger.Info(fmt.Sprintf("Database '%s' imported from %s at %s", dbName, filePath, metadata.ImportTimeStr))
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDatabaseNames возвращает имена всех баз данных
|
||||||
|
func (s *Storage) GetDatabaseNames() []string {
|
||||||
|
return s.ListDatabases()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExistsDatabase проверяет существование базы данных
|
||||||
|
func (s *Storage) ExistsDatabase(name string) bool {
|
||||||
|
_, ok := s.databases.Load(name)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDatabaseCount возвращает количество баз данных
|
||||||
|
func (s *Storage) GetDatabaseCount() int {
|
||||||
|
count := 0
|
||||||
|
s.databases.Range(func(key, value interface{}) bool {
|
||||||
|
count++
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Дополнительные методы для управления хранилищем ==========
|
||||||
|
|
||||||
|
// Backup создаёт резервную копию всех данных
|
||||||
|
func (s *Storage) Backup(backupPath string) error {
|
||||||
|
if s.logger != nil {
|
||||||
|
s.logger.Info(fmt.Sprintf("Starting backup to %s", backupPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
backup := make(map[string]interface{})
|
||||||
|
backup["created_at"] = s.createdAt
|
||||||
|
backup["backup_time"] = time.Now().UnixMilli()
|
||||||
|
backup["backup_time_str"] = time.Now().Format("2006-01-02 15:04:05.000")
|
||||||
|
|
||||||
|
databases := make(map[string][]byte)
|
||||||
|
databasesMetadata := make(map[string]interface{})
|
||||||
|
|
||||||
|
s.databases.Range(func(key, value interface{}) bool {
|
||||||
|
dbName := key.(string)
|
||||||
|
db := value.(*Database)
|
||||||
|
|
||||||
|
dbData, err := db.SerializeDatabase()
|
||||||
|
if err != nil {
|
||||||
|
if s.logger != nil {
|
||||||
|
s.logger.Error(fmt.Sprintf("Failed to serialize database %s: %v", dbName, err))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
databases[dbName] = dbData
|
||||||
|
databasesMetadata[dbName] = map[string]interface{}{
|
||||||
|
"created_at": db.GetCreatedAt(),
|
||||||
|
"updated_at": db.GetUpdatedAt(),
|
||||||
|
"collections": len(db.ListCollections()),
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
backup["databases"] = databases
|
||||||
|
backup["databases_metadata"] = databasesMetadata
|
||||||
|
backup["total_databases"] = s.GetDatabaseCount()
|
||||||
|
|
||||||
|
data, err := serializer.Marshal(backup)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal backup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(backupPath, data, 0644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write backup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.logger != nil {
|
||||||
|
s.logger.Info(fmt.Sprintf("Backup completed: %s", backupPath))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore восстанавливает данные из резервной копии
|
||||||
|
func (s *Storage) Restore(backupPath string) error {
|
||||||
|
if s.logger != nil {
|
||||||
|
s.logger.Info(fmt.Sprintf("Starting restore from %s", backupPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(backupPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read backup file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var backup map[string]interface{}
|
||||||
|
if err := serializer.Unmarshal(data, &backup); err != nil {
|
||||||
|
return fmt.Errorf("failed to unmarshal backup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Восстанавливаем временную метку хранилища
|
||||||
|
if createdAt, ok := backup["created_at"]; ok {
|
||||||
|
if v, ok := createdAt.(int64); ok {
|
||||||
|
s.createdAt = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
databasesRaw, ok := backup["databases"]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("invalid backup format: missing databases")
|
||||||
|
}
|
||||||
|
|
||||||
|
databases, ok := databasesRaw.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("invalid backup format: databases is not a map")
|
||||||
|
}
|
||||||
|
|
||||||
|
for dbName, dbDataRaw := range databases {
|
||||||
|
dbData, ok := dbDataRaw.([]byte)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.ExistsDatabase(dbName) {
|
||||||
|
if err := s.CreateDatabase(dbName); err != nil {
|
||||||
|
return fmt.Errorf("failed to create database %s: %v", dbName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := s.GetDatabase(dbName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get database %s: %v", dbName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.DeserializeDatabase(dbData); err != nil {
|
||||||
|
return fmt.Errorf("failed to restore database %s: %v", dbName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.logger != nil {
|
||||||
|
s.logger.Info("Restore completed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStats возвращает статистику хранилища
|
||||||
|
func (s *Storage) GetStats() map[string]interface{} {
|
||||||
|
stats := map[string]interface{}{
|
||||||
|
"total_databases": s.GetDatabaseCount(),
|
||||||
|
"total_documents": s.GetTotalDocuments(),
|
||||||
|
"page_size_bytes": s.pageSize,
|
||||||
|
"created_at": s.createdAt,
|
||||||
|
"created_at_str": time.UnixMilli(s.createdAt).Format("2006-01-02 15:04:05.000"),
|
||||||
|
}
|
||||||
|
|
||||||
|
databases := make([]map[string]interface{}, 0)
|
||||||
|
s.databases.Range(func(key, value interface{}) bool {
|
||||||
|
db := value.(*Database)
|
||||||
|
dbStats := map[string]interface{}{
|
||||||
|
"name": db.name,
|
||||||
|
"collections": len(db.ListCollections()),
|
||||||
|
"created_at": db.GetCreatedAt(),
|
||||||
|
"created_at_str": time.UnixMilli(db.GetCreatedAt()).Format("2006-01-02 15:04:05.000"),
|
||||||
|
"updated_at": db.GetUpdatedAt(),
|
||||||
|
"updated_at_str": time.UnixMilli(db.GetUpdatedAt()).Format("2006-01-02 15:04:05.000"),
|
||||||
|
"deleted_at": db.GetDeletedAt(),
|
||||||
|
}
|
||||||
|
|
||||||
|
totalDocs := int64(0)
|
||||||
|
totalSize := int64(0)
|
||||||
|
db.collections.Range(func(k, v interface{}) bool {
|
||||||
|
coll := v.(*Collection)
|
||||||
|
totalDocs += coll.Count()
|
||||||
|
totalSize += coll.Size()
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
dbStats["documents"] = totalDocs
|
||||||
|
dbStats["size_bytes"] = totalSize
|
||||||
|
databases = append(databases, dbStats)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
stats["databases"] = databases
|
||||||
|
return stats
|
||||||
|
}
|
||||||
83
internal/storage/fsync.go
Normal file
83
internal/storage/fsync.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/storage/fsync.go
|
||||||
|
// Назначение: Реальная синхронизация с диском (fsync) для WAL
|
||||||
|
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RealFsync выполняет реальный fsync на файле
|
||||||
|
func RealFsync(file *os.File) error {
|
||||||
|
if file == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вызываем системный fsync
|
||||||
|
return file.Sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RealFsyncWithRetry выполняет fsync с повторными попытками
|
||||||
|
func RealFsyncWithRetry(file *os.File, maxRetries int, retryDelay time.Duration) error {
|
||||||
|
if file == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for i := 0; i < maxRetries; i++ {
|
||||||
|
if err := RealFsync(file); err != nil {
|
||||||
|
lastErr = err
|
||||||
|
if i < maxRetries-1 {
|
||||||
|
time.Sleep(retryDelay)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fmt.Errorf("fsync failed after %d attempts: %v", maxRetries, lastErr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// FsyncDir синхронизирует директорию (для гарантии, что создание файла записано на диск)
|
||||||
|
func FsyncDir(dirPath string) error {
|
||||||
|
dir, err := os.Open(dirPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer dir.Close()
|
||||||
|
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
// На Windows нет прямой поддержки fsync для директорий
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return dir.Sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FsyncDataOnly выполняет fsync только для данных (не метаданных)
|
||||||
|
func FsyncDataOnly(file *os.File) error {
|
||||||
|
if file == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fd := int(file.Fd())
|
||||||
|
_, _, err := syscall.Syscall(syscall.SYS_FSYNC, uintptr(fd), 0, 0)
|
||||||
|
if err != 0 {
|
||||||
|
return fmt.Errorf("fsync failed: %v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
466
internal/storage/persistence.go
Normal file
466
internal/storage/persistence.go
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/storage/persistence.go
|
||||||
|
// Назначение: Персистентное хранение данных на диске с поддержкой checkpoint и recovery
|
||||||
|
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PersistenceConfig конфигурация персистентного хранения
|
||||||
|
type PersistenceConfig struct {
|
||||||
|
DataDir string `json:"data_dir"`
|
||||||
|
CheckpointInterval time.Duration `json:"checkpoint_interval"`
|
||||||
|
MaxCheckpoints int `json:"max_checkpoints"`
|
||||||
|
CompressEnabled bool `json:"compress_enabled"`
|
||||||
|
SyncWrites bool `json:"sync_writes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultPersistenceConfig возвращает конфигурацию по умолчанию
|
||||||
|
func DefaultPersistenceConfig() *PersistenceConfig {
|
||||||
|
return &PersistenceConfig{
|
||||||
|
DataDir: "futriis_data",
|
||||||
|
CheckpointInterval: 5 * time.Minute,
|
||||||
|
MaxCheckpoints: 10,
|
||||||
|
CompressEnabled: true,
|
||||||
|
SyncWrites: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DatabaseSnapshot представляет снапшот базы данных
|
||||||
|
type DatabaseSnapshot struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Collections map[string]interface{} `json:"collections"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
Version uint64 `json:"version"`
|
||||||
|
Checksum string `json:"checksum"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PersistenceManager управляет персистентным хранением
|
||||||
|
type PersistenceManager struct {
|
||||||
|
config *PersistenceConfig
|
||||||
|
storage *Storage
|
||||||
|
logger LoggerInterface
|
||||||
|
mu sync.RWMutex
|
||||||
|
stopChan chan struct{}
|
||||||
|
wg sync.WaitGroup
|
||||||
|
lastCheckpoint int64
|
||||||
|
checkpointID atomic.Uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPersistenceManager создаёт новый менеджер персистентности
|
||||||
|
func NewPersistenceManager(config *PersistenceConfig, storage *Storage, logger LoggerInterface) *PersistenceManager {
|
||||||
|
if config == nil {
|
||||||
|
config = DefaultPersistenceConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
pm := &PersistenceManager{
|
||||||
|
config: config,
|
||||||
|
storage: storage,
|
||||||
|
logger: logger,
|
||||||
|
stopChan: make(chan struct{}),
|
||||||
|
lastCheckpoint: time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаём директорию для данных
|
||||||
|
if err := os.MkdirAll(config.DataDir, 0755); err != nil {
|
||||||
|
if logger != nil {
|
||||||
|
logger.Error(fmt.Sprintf("Failed to create data directory: %v", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pm
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start запускает фоновое сохранение чекпоинтов
|
||||||
|
func (pm *PersistenceManager) Start() {
|
||||||
|
pm.wg.Add(1)
|
||||||
|
go pm.checkpointLoop()
|
||||||
|
|
||||||
|
if pm.logger != nil {
|
||||||
|
pm.logger.Info(fmt.Sprintf("Persistence manager started, data dir: %s", pm.config.DataDir))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop останавливает менеджер
|
||||||
|
func (pm *PersistenceManager) Stop() {
|
||||||
|
close(pm.stopChan)
|
||||||
|
pm.wg.Wait()
|
||||||
|
|
||||||
|
// Финальное сохранение
|
||||||
|
pm.SaveAll()
|
||||||
|
|
||||||
|
if pm.logger != nil {
|
||||||
|
pm.logger.Info("Persistence manager stopped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkpointLoop периодически создаёт чекпоинты
|
||||||
|
func (pm *PersistenceManager) checkpointLoop() {
|
||||||
|
defer pm.wg.Done()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(pm.config.CheckpointInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-pm.stopChan:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := pm.SaveAll(); err != nil {
|
||||||
|
if pm.logger != nil {
|
||||||
|
pm.logger.Error(fmt.Sprintf("Failed to save checkpoint: %v", err))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pm.cleanupOldCheckpoints()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveDatabase сохраняет базу данных на диск
|
||||||
|
func (pm *PersistenceManager) SaveDatabase(dbName string) error {
|
||||||
|
pm.mu.Lock()
|
||||||
|
defer pm.mu.Unlock()
|
||||||
|
|
||||||
|
db, err := pm.storage.GetDatabase(dbName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("database not found: %s", dbName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Собираем данные всех коллекций
|
||||||
|
snapshot := &DatabaseSnapshot{
|
||||||
|
Name: dbName,
|
||||||
|
Collections: make(map[string]interface{}),
|
||||||
|
CreatedAt: time.Now().UnixMilli(),
|
||||||
|
Version: pm.checkpointID.Add(1),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, collName := range db.ListCollections() {
|
||||||
|
coll, err := db.GetCollection(collName)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
docs := coll.GetAllDocuments()
|
||||||
|
collData := make([]map[string]interface{}, 0, len(docs))
|
||||||
|
for _, doc := range docs {
|
||||||
|
collData = append(collData, map[string]interface{}{
|
||||||
|
"_id": doc.ID,
|
||||||
|
"fields": doc.GetFields(),
|
||||||
|
"created_at": doc.CreatedAt,
|
||||||
|
"updated_at": doc.UpdatedAt,
|
||||||
|
"version": doc.Version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
snapshot.Collections[collName] = collData
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сериализуем и сохраняем
|
||||||
|
data, err := json.Marshal(snapshot)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Опционально сжимаем
|
||||||
|
if pm.config.CompressEnabled {
|
||||||
|
data, err = pm.compress(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем в файл
|
||||||
|
filename := pm.getSnapshotFilename(dbName, snapshot.Version)
|
||||||
|
if err := os.WriteFile(filename, data, 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Синхронизируем с диском если нужно
|
||||||
|
if pm.config.SyncWrites {
|
||||||
|
if f, err := os.OpenFile(filename, os.O_RDWR, 0644); err == nil {
|
||||||
|
RealFsync(f)
|
||||||
|
f.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if pm.logger != nil {
|
||||||
|
pm.logger.Info(fmt.Sprintf("Saved database %s checkpoint %d (%d bytes)", dbName, snapshot.Version, len(data)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveAll сохраняет все базы данных
|
||||||
|
func (pm *PersistenceManager) SaveAll() error {
|
||||||
|
databases := pm.storage.ListDatabases()
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for _, dbName := range databases {
|
||||||
|
if err := pm.SaveDatabase(dbName); err != nil {
|
||||||
|
lastErr = err
|
||||||
|
if pm.logger != nil {
|
||||||
|
pm.logger.Error(fmt.Sprintf("Failed to save database %s: %v", dbName, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadDatabase загружает базу данных с диска
|
||||||
|
func (pm *PersistenceManager) LoadDatabase(dbName string) error {
|
||||||
|
pm.mu.Lock()
|
||||||
|
defer pm.mu.Unlock()
|
||||||
|
|
||||||
|
// Находим последний чекпоинт
|
||||||
|
snapshot, err := pm.findLatestSnapshot(dbName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if snapshot == nil {
|
||||||
|
return fmt.Errorf("no snapshot found for database: %s", dbName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Восстанавливаем базу данных
|
||||||
|
if !pm.storage.ExistsDatabase(dbName) {
|
||||||
|
if err := pm.storage.CreateDatabase(dbName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := pm.storage.GetDatabase(dbName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Восстанавливаем коллекции
|
||||||
|
for collName, collDataRaw := range snapshot.Collections {
|
||||||
|
collData, ok := collDataRaw.([]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.GetCollection(collName); err != nil {
|
||||||
|
if err := db.CreateCollection(collName); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
coll, err := db.GetCollection(collName)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, docRaw := range collData {
|
||||||
|
docMap, ok := docRaw.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
docID, ok := docMap["_id"].(string)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
doc := NewDocumentWithID(docID)
|
||||||
|
|
||||||
|
if fields, ok := docMap["fields"].(map[string]interface{}); ok {
|
||||||
|
for k, v := range fields {
|
||||||
|
doc.SetField(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if createdAt, ok := docMap["created_at"].(float64); ok {
|
||||||
|
doc.CreatedAt = int64(createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
if updatedAt, ok := docMap["updated_at"].(float64); ok {
|
||||||
|
doc.UpdatedAt = int64(updatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
if version, ok := docMap["version"].(float64); ok {
|
||||||
|
doc.Version = uint64(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := coll.Insert(doc); err != nil {
|
||||||
|
if pm.logger != nil {
|
||||||
|
pm.logger.Error(fmt.Sprintf("Failed to restore document %s: %v", docID, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if pm.logger != nil {
|
||||||
|
pm.logger.Info(fmt.Sprintf("Loaded database %s from snapshot (version %d)", dbName, snapshot.Version))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadAll загружает все базы данных с диска
|
||||||
|
func (pm *PersistenceManager) LoadAll() error {
|
||||||
|
files, err := filepath.Glob(filepath.Join(pm.config.DataDir, "snapshot_*.json*"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
databases := make(map[string]bool)
|
||||||
|
for _, file := range files {
|
||||||
|
base := filepath.Base(file)
|
||||||
|
// Извлекаем имя базы данных из имени файла
|
||||||
|
// Формат: snapshot_{dbname}_{version}.json.gz
|
||||||
|
parts := strings.Split(base, "_")
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
dbName := parts[1]
|
||||||
|
databases[dbName] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for dbName := range databases {
|
||||||
|
if err := pm.LoadDatabase(dbName); err != nil {
|
||||||
|
if pm.logger != nil {
|
||||||
|
pm.logger.Error(fmt.Sprintf("Failed to load database %s: %v", dbName, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSnapshotFilename возвращает имя файла для снапшота
|
||||||
|
func (pm *PersistenceManager) getSnapshotFilename(dbName string, version uint64) string {
|
||||||
|
filename := fmt.Sprintf("snapshot_%s_%d.json", dbName, version)
|
||||||
|
if pm.config.CompressEnabled {
|
||||||
|
filename += ".gz"
|
||||||
|
}
|
||||||
|
return filepath.Join(pm.config.DataDir, filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findLatestSnapshot находит последний снапшот базы данных
|
||||||
|
func (pm *PersistenceManager) findLatestSnapshot(dbName string) (*DatabaseSnapshot, error) {
|
||||||
|
pattern := filepath.Join(pm.config.DataDir, fmt.Sprintf("snapshot_%s_*.json*", dbName))
|
||||||
|
files, err := filepath.Glob(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сортируем по времени создания (новейшие первыми)
|
||||||
|
sort.Slice(files, func(i, j int) bool {
|
||||||
|
infoI, _ := os.Stat(files[i])
|
||||||
|
infoJ, _ := os.Stat(files[j])
|
||||||
|
if infoI == nil || infoJ == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return infoI.ModTime().After(infoJ.ModTime())
|
||||||
|
})
|
||||||
|
|
||||||
|
// Читаем последний снапшот
|
||||||
|
data, err := os.ReadFile(files[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if pm.config.CompressEnabled && strings.HasSuffix(files[0], ".gz") {
|
||||||
|
data, err = pm.decompress(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshot DatabaseSnapshot
|
||||||
|
if err := json.Unmarshal(data, &snapshot); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &snapshot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// compress сжимает данные с помощью gzip
|
||||||
|
func (pm *PersistenceManager) compress(data []byte) ([]byte, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := gzip.NewWriter(&buf)
|
||||||
|
|
||||||
|
if _, err := w.Write(data); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write compressed data: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to close gzip writer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decompress разжимает данные с помощью gzip
|
||||||
|
func (pm *PersistenceManager) decompress(data []byte) ([]byte, error) {
|
||||||
|
reader, err := gzip.NewReader(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create gzip reader: %v", err)
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if _, err := buf.ReadFrom(reader); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decompress data: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupOldCheckpoints удаляет старые чекпоинты
|
||||||
|
func (pm *PersistenceManager) cleanupOldCheckpoints() {
|
||||||
|
pattern := filepath.Join(pm.config.DataDir, "snapshot_*.json*")
|
||||||
|
files, err := filepath.Glob(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) <= pm.config.MaxCheckpoints {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сортируем по времени модификации
|
||||||
|
sort.Slice(files, func(i, j int) bool {
|
||||||
|
infoI, _ := os.Stat(files[i])
|
||||||
|
infoJ, _ := os.Stat(files[j])
|
||||||
|
if infoI == nil || infoJ == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return infoI.ModTime().Before(infoJ.ModTime())
|
||||||
|
})
|
||||||
|
|
||||||
|
// Удаляем старые
|
||||||
|
toDelete := files[:len(files)-pm.config.MaxCheckpoints]
|
||||||
|
for _, f := range toDelete {
|
||||||
|
os.Remove(f)
|
||||||
|
if pm.logger != nil {
|
||||||
|
pm.logger.Debug(fmt.Sprintf("Removed old checkpoint: %s", f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2241
internal/storage/transaction.go
Normal file
2241
internal/storage/transaction.go
Normal file
File diff suppressed because it is too large
Load Diff
816
internal/storage/trigger.go
Normal file
816
internal/storage/trigger.go
Normal file
@@ -0,0 +1,816 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/storage/trigger.go
|
||||||
|
// Назначение: Реализация триггеров, похожих на MongoDB trigger syntax.
|
||||||
|
// Поддерживает события: INSERT, UPDATE, DELETE, REPLACE.
|
||||||
|
// Триггеры могут выполняться до или после события.
|
||||||
|
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"futriis/internal/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TriggerEvent определяет тип события для триггера
|
||||||
|
type TriggerEvent string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TriggerBeforeInsert TriggerEvent = "BEFORE_INSERT"
|
||||||
|
TriggerAfterInsert TriggerEvent = "AFTER_INSERT"
|
||||||
|
TriggerBeforeUpdate TriggerEvent = "BEFORE_UPDATE"
|
||||||
|
TriggerAfterUpdate TriggerEvent = "AFTER_UPDATE"
|
||||||
|
TriggerBeforeDelete TriggerEvent = "BEFORE_DELETE"
|
||||||
|
TriggerAfterDelete TriggerEvent = "AFTER_DELETE"
|
||||||
|
TriggerBeforeReplace TriggerEvent = "BEFORE_REPLACE"
|
||||||
|
TriggerAfterReplace TriggerEvent = "AFTER_REPLACE"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TriggerAction определяет действие триггера
|
||||||
|
type TriggerAction string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ActionAbort TriggerAction = "abort" // Прервать операцию
|
||||||
|
ActionSkip TriggerAction = "skip" // Пропустить операцию
|
||||||
|
ActionModify TriggerAction = "modify" // Модифицировать документ
|
||||||
|
ActionLog TriggerAction = "log" // Записать в лог
|
||||||
|
ActionNotify TriggerAction = "notify" // Отправить уведомление
|
||||||
|
ActionCustom TriggerAction = "custom" // Пользовательское действие
|
||||||
|
)
|
||||||
|
|
||||||
|
// Trigger представляет триггер на коллекции
|
||||||
|
type Trigger struct {
|
||||||
|
Name string `msgpack:"name"`
|
||||||
|
Collection string `msgpack:"collection"`
|
||||||
|
Event TriggerEvent `msgpack:"event"`
|
||||||
|
Action TriggerAction `msgpack:"action"`
|
||||||
|
Condition *TriggerCondition `msgpack:"condition"`
|
||||||
|
Operations []TriggerOperation `msgpack:"operations"`
|
||||||
|
CreatedAt int64 `msgpack:"created_at"`
|
||||||
|
UpdatedAt int64 `msgpack:"updated_at"`
|
||||||
|
Enabled bool `msgpack:"enabled"`
|
||||||
|
Description string `msgpack:"description"`
|
||||||
|
mu sync.RWMutex `msgpack:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriggerCondition определяет условие выполнения триггера
|
||||||
|
type TriggerCondition struct {
|
||||||
|
Field string `msgpack:"field"` // Поле для проверки
|
||||||
|
Operator string `msgpack:"operator"` // Оператор: eq, ne, gt, lt, gte, lte, in, nin, exists, regex
|
||||||
|
Value interface{} `msgpack:"value"` // Значение для сравнения
|
||||||
|
Match string `msgpack:"match"` // Паттерн для regex
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriggerOperation определяет операцию, выполняемую триггером
|
||||||
|
type TriggerOperation struct {
|
||||||
|
Type string `msgpack:"type"` // set, unset, inc, mul, rename, currentDate
|
||||||
|
Field string `msgpack:"field"` // Поле для операции
|
||||||
|
Value interface{} `msgpack:"value"` // Значение для операции
|
||||||
|
Params map[string]interface{} `msgpack:"params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriggerExecution содержит контекст выполнения триггера
|
||||||
|
type TriggerExecution struct {
|
||||||
|
TriggerName string
|
||||||
|
Event TriggerEvent
|
||||||
|
Collection string
|
||||||
|
Database string
|
||||||
|
DocumentID string
|
||||||
|
OldDocument *Document
|
||||||
|
NewDocument *Document
|
||||||
|
Operation string
|
||||||
|
Timestamp time.Time
|
||||||
|
TimestampMs int64 `msgpack:"timestamp_ms"` // Unix миллисекунды (добавлено)
|
||||||
|
TimestampStr string `msgpack:"timestamp_str"` // Человекочитаемая строка (добавлено)
|
||||||
|
User string
|
||||||
|
Role string
|
||||||
|
CustomData map[string]interface{}
|
||||||
|
ActionResult string `msgpack:"action_result"` // Результат выполнения (добавлено)
|
||||||
|
DurationMs int64 `msgpack:"duration_ms"` // Длительность выполнения (добавлено)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriggerManager управляет триггерами в СУБД
|
||||||
|
type TriggerManager struct {
|
||||||
|
triggers sync.Map // map[string]*Trigger (ключ: collection|event|name)
|
||||||
|
logger *log.Logger
|
||||||
|
mu sync.RWMutex
|
||||||
|
auditLog []*TriggerExecution
|
||||||
|
maxLogSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
globalTriggerManager *TriggerManager
|
||||||
|
triggerManagerOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetTriggerManager возвращает глобальный менеджер триггеров
|
||||||
|
func GetTriggerManager() *TriggerManager {
|
||||||
|
triggerManagerOnce.Do(func() {
|
||||||
|
globalTriggerManager = &TriggerManager{
|
||||||
|
maxLogSize: 10000,
|
||||||
|
auditLog: make([]*TriggerExecution, 0),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return globalTriggerManager
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitTriggerManager инициализирует менеджер триггеров с логгером
|
||||||
|
func InitTriggerManager(logger *log.Logger) {
|
||||||
|
tm := GetTriggerManager()
|
||||||
|
tm.logger = logger
|
||||||
|
if logger != nil {
|
||||||
|
logger.Info("Trigger manager initialized")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTrigger создаёт новый триггер (синтаксис MongoDB-like)
|
||||||
|
// Пример: db.collection.createTrigger("triggerName", "BEFORE_INSERT", {
|
||||||
|
// condition: { field: "status", operator: "eq", value: "active" },
|
||||||
|
// action: "modify",
|
||||||
|
// operations: [
|
||||||
|
// { type: "set", field: "updated_at", value: "$$NOW" }
|
||||||
|
// ]
|
||||||
|
// })
|
||||||
|
func (tm *TriggerManager) CreateTrigger(database, collection, name string, event TriggerEvent, config map[string]interface{}) error {
|
||||||
|
tm.mu.Lock()
|
||||||
|
defer tm.mu.Unlock()
|
||||||
|
|
||||||
|
key := tm.getTriggerKey(collection, event, name)
|
||||||
|
if _, exists := tm.triggers.Load(key); exists {
|
||||||
|
return fmt.Errorf("trigger '%s' already exists on %s for event %s", name, collection, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
trigger := &Trigger{
|
||||||
|
Name: name,
|
||||||
|
Collection: collection,
|
||||||
|
Event: event,
|
||||||
|
Enabled: true,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
Operations: make([]TriggerOperation, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Парсим конфигурацию триггера
|
||||||
|
if action, ok := config["action"].(string); ok {
|
||||||
|
switch strings.ToLower(action) {
|
||||||
|
case "abort":
|
||||||
|
trigger.Action = ActionAbort
|
||||||
|
case "skip":
|
||||||
|
trigger.Action = ActionSkip
|
||||||
|
case "modify":
|
||||||
|
trigger.Action = ActionModify
|
||||||
|
case "log":
|
||||||
|
trigger.Action = ActionLog
|
||||||
|
case "notify":
|
||||||
|
trigger.Action = ActionNotify
|
||||||
|
default:
|
||||||
|
trigger.Action = ActionCustom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Парсим условие
|
||||||
|
if cond, ok := config["condition"].(map[string]interface{}); ok {
|
||||||
|
trigger.Condition = &TriggerCondition{}
|
||||||
|
if field, ok := cond["field"].(string); ok {
|
||||||
|
trigger.Condition.Field = field
|
||||||
|
}
|
||||||
|
if operator, ok := cond["operator"].(string); ok {
|
||||||
|
trigger.Condition.Operator = operator
|
||||||
|
}
|
||||||
|
if value, ok := cond["value"]; ok {
|
||||||
|
trigger.Condition.Value = value
|
||||||
|
}
|
||||||
|
if match, ok := cond["match"].(string); ok {
|
||||||
|
trigger.Condition.Match = match
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Парсим операции
|
||||||
|
if ops, ok := config["operations"].([]interface{}); ok {
|
||||||
|
for _, opRaw := range ops {
|
||||||
|
opMap, ok := opRaw.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
operation := TriggerOperation{}
|
||||||
|
if opType, ok := opMap["type"].(string); ok {
|
||||||
|
operation.Type = opType
|
||||||
|
}
|
||||||
|
if field, ok := opMap["field"].(string); ok {
|
||||||
|
operation.Field = field
|
||||||
|
}
|
||||||
|
if value, ok := opMap["value"]; ok {
|
||||||
|
operation.Value = value
|
||||||
|
}
|
||||||
|
if params, ok := opMap["params"].(map[string]interface{}); ok {
|
||||||
|
operation.Params = params
|
||||||
|
}
|
||||||
|
trigger.Operations = append(trigger.Operations, operation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if desc, ok := config["description"].(string); ok {
|
||||||
|
trigger.Description = desc
|
||||||
|
}
|
||||||
|
|
||||||
|
tm.triggers.Store(key, trigger)
|
||||||
|
|
||||||
|
// Аудит создания триггера
|
||||||
|
LogAudit("CREATE", "TRIGGER", fmt.Sprintf("%s.%s.%s", database, collection, name), map[string]interface{}{
|
||||||
|
"event": event,
|
||||||
|
"action": trigger.Action,
|
||||||
|
"description": trigger.Description,
|
||||||
|
})
|
||||||
|
|
||||||
|
if tm.logger != nil {
|
||||||
|
tm.logger.Info(fmt.Sprintf("Trigger '%s' created on %s.%s for event %s", name, database, collection, event))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DropTrigger удаляет триггер
|
||||||
|
func (tm *TriggerManager) DropTrigger(collection, event, name string) error {
|
||||||
|
key := tm.getTriggerKey(collection, TriggerEvent(event), name)
|
||||||
|
if _, exists := tm.triggers.LoadAndDelete(key); !exists {
|
||||||
|
return fmt.Errorf("trigger '%s' not found on %s for event %s", name, collection, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Аудит удаления триггера
|
||||||
|
LogAudit("DROP", "TRIGGER", fmt.Sprintf("%s.%s", collection, name), map[string]interface{}{
|
||||||
|
"event": event,
|
||||||
|
})
|
||||||
|
|
||||||
|
if tm.logger != nil {
|
||||||
|
tm.logger.Info(fmt.Sprintf("Trigger '%s' dropped from %s for event %s", name, collection, event))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTrigger возвращает триггер по имени
|
||||||
|
func (tm *TriggerManager) GetTrigger(collection, event, name string) (*Trigger, error) {
|
||||||
|
key := tm.getTriggerKey(collection, TriggerEvent(event), name)
|
||||||
|
if val, ok := tm.triggers.Load(key); ok {
|
||||||
|
return val.(*Trigger), nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("trigger not found: %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTriggers возвращает список всех триггеров для коллекции
|
||||||
|
func (tm *TriggerManager) ListTriggers(collection string) []*Trigger {
|
||||||
|
triggers := make([]*Trigger, 0)
|
||||||
|
tm.triggers.Range(func(key, value interface{}) bool {
|
||||||
|
trigger := value.(*Trigger)
|
||||||
|
if collection == "" || trigger.Collection == collection {
|
||||||
|
triggers = append(triggers, trigger)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return triggers
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTriggersByEvent возвращает триггеры для конкретного события
|
||||||
|
func (tm *TriggerManager) ListTriggersByEvent(collection string, event TriggerEvent) []*Trigger {
|
||||||
|
triggers := make([]*Trigger, 0)
|
||||||
|
tm.triggers.Range(func(key, value interface{}) bool {
|
||||||
|
trigger := value.(*Trigger)
|
||||||
|
if trigger.Collection == collection && trigger.Event == event && trigger.Enabled {
|
||||||
|
triggers = append(triggers, trigger)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return triggers
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnableTrigger включает триггер
|
||||||
|
func (tm *TriggerManager) EnableTrigger(collection, event, name string) error {
|
||||||
|
key := tm.getTriggerKey(collection, TriggerEvent(event), name)
|
||||||
|
val, ok := tm.triggers.Load(key)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("trigger not found: %s", name)
|
||||||
|
}
|
||||||
|
trigger := val.(*Trigger)
|
||||||
|
trigger.mu.Lock()
|
||||||
|
trigger.Enabled = true
|
||||||
|
trigger.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
trigger.mu.Unlock()
|
||||||
|
tm.triggers.Store(key, trigger)
|
||||||
|
|
||||||
|
// Аудит включения триггера
|
||||||
|
LogAudit("ENABLE", "TRIGGER", fmt.Sprintf("%s.%s", collection, name), map[string]interface{}{
|
||||||
|
"event": event,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisableTrigger выключает триггер
|
||||||
|
func (tm *TriggerManager) DisableTrigger(collection, event, name string) error {
|
||||||
|
key := tm.getTriggerKey(collection, TriggerEvent(event), name)
|
||||||
|
val, ok := tm.triggers.Load(key)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("trigger not found: %s", name)
|
||||||
|
}
|
||||||
|
trigger := val.(*Trigger)
|
||||||
|
trigger.mu.Lock()
|
||||||
|
trigger.Enabled = false
|
||||||
|
trigger.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
trigger.mu.Unlock()
|
||||||
|
tm.triggers.Store(key, trigger)
|
||||||
|
|
||||||
|
// Аудит выключения триггера
|
||||||
|
LogAudit("DISABLE", "TRIGGER", fmt.Sprintf("%s.%s", collection, name), map[string]interface{}{
|
||||||
|
"event": event,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteTriggers выполняет все триггеры для данного события
|
||||||
|
// Возвращает: modifiedDocument, shouldAbort, error
|
||||||
|
func (tm *TriggerManager) ExecuteTriggers(execCtx *TriggerExecution) (*Document, bool, error) {
|
||||||
|
triggers := tm.ListTriggersByEvent(execCtx.Collection, execCtx.Event)
|
||||||
|
|
||||||
|
if len(triggers) == 0 {
|
||||||
|
return execCtx.NewDocument, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
currentDoc := execCtx.NewDocument
|
||||||
|
if currentDoc == nil && execCtx.OldDocument != nil {
|
||||||
|
currentDoc = execCtx.OldDocument.Clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, trigger := range triggers {
|
||||||
|
if !trigger.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем условие
|
||||||
|
if trigger.Condition != nil {
|
||||||
|
if !tm.evaluateCondition(execCtx, trigger.Condition) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
// Выполняем действие триггера
|
||||||
|
switch trigger.Action {
|
||||||
|
case ActionAbort:
|
||||||
|
tm.logExecution(execCtx, trigger, "aborted", startTime)
|
||||||
|
return currentDoc, true, fmt.Errorf("operation aborted by trigger: %s", trigger.Name)
|
||||||
|
|
||||||
|
case ActionSkip:
|
||||||
|
tm.logExecution(execCtx, trigger, "skipped", startTime)
|
||||||
|
return currentDoc, true, nil
|
||||||
|
|
||||||
|
case ActionModify:
|
||||||
|
if currentDoc != nil {
|
||||||
|
currentDoc = tm.applyOperations(currentDoc, trigger.Operations, execCtx)
|
||||||
|
}
|
||||||
|
tm.logExecution(execCtx, trigger, "modified", startTime)
|
||||||
|
|
||||||
|
case ActionLog:
|
||||||
|
tm.logExecution(execCtx, trigger, "logged", startTime)
|
||||||
|
if tm.logger != nil {
|
||||||
|
tm.logger.Info(fmt.Sprintf("Trigger %s executed on %s.%s (event: %s, doc: %s)",
|
||||||
|
trigger.Name, execCtx.Database, execCtx.Collection, execCtx.Event, execCtx.DocumentID))
|
||||||
|
}
|
||||||
|
|
||||||
|
case ActionNotify:
|
||||||
|
tm.logExecution(execCtx, trigger, "notified", startTime)
|
||||||
|
// Здесь можно отправить уведомление через WebSocket или другой канал
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentDoc, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// evaluateCondition проверяет условие триггера
|
||||||
|
func (tm *TriggerManager) evaluateCondition(execCtx *TriggerExecution, cond *TriggerCondition) bool {
|
||||||
|
var docToCheck *Document
|
||||||
|
if execCtx.NewDocument != nil {
|
||||||
|
docToCheck = execCtx.NewDocument
|
||||||
|
} else if execCtx.OldDocument != nil {
|
||||||
|
docToCheck = execCtx.OldDocument
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldValue, err := docToCheck.GetField(cond.Field)
|
||||||
|
if err != nil {
|
||||||
|
// Поле не существует
|
||||||
|
if cond.Operator == "exists" {
|
||||||
|
if existsVal, ok := cond.Value.(bool); ok && !existsVal {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch cond.Operator {
|
||||||
|
case "eq":
|
||||||
|
return fmt.Sprintf("%v", fieldValue) == fmt.Sprintf("%v", cond.Value)
|
||||||
|
case "ne":
|
||||||
|
return fmt.Sprintf("%v", fieldValue) != fmt.Sprintf("%v", cond.Value)
|
||||||
|
case "gt":
|
||||||
|
return compareNumbers(fieldValue, cond.Value) > 0
|
||||||
|
case "lt":
|
||||||
|
return compareNumbers(fieldValue, cond.Value) < 0
|
||||||
|
case "gte":
|
||||||
|
return compareNumbers(fieldValue, cond.Value) >= 0
|
||||||
|
case "lte":
|
||||||
|
return compareNumbers(fieldValue, cond.Value) <= 0
|
||||||
|
case "in":
|
||||||
|
if arr, ok := cond.Value.([]interface{}); ok {
|
||||||
|
for _, v := range arr {
|
||||||
|
if fmt.Sprintf("%v", fieldValue) == fmt.Sprintf("%v", v) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
case "nin":
|
||||||
|
if arr, ok := cond.Value.([]interface{}); ok {
|
||||||
|
for _, v := range arr {
|
||||||
|
if fmt.Sprintf("%v", fieldValue) == fmt.Sprintf("%v", v) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case "exists":
|
||||||
|
if existsVal, ok := cond.Value.(bool); ok {
|
||||||
|
return existsVal
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case "regex":
|
||||||
|
if pattern, ok := cond.Value.(string); ok {
|
||||||
|
matched, _ := regexp.MatchString(pattern, fmt.Sprintf("%v", fieldValue))
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyOperations применяет операции к документу
|
||||||
|
func (tm *TriggerManager) applyOperations(doc *Document, ops []TriggerOperation, execCtx *TriggerExecution) *Document {
|
||||||
|
if doc == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := doc.Clone()
|
||||||
|
|
||||||
|
for _, op := range ops {
|
||||||
|
switch op.Type {
|
||||||
|
case "set":
|
||||||
|
value := tm.resolveValue(op.Value, execCtx)
|
||||||
|
result.SetField(op.Field, value)
|
||||||
|
|
||||||
|
case "unset":
|
||||||
|
result.DeleteField(op.Field)
|
||||||
|
|
||||||
|
case "inc":
|
||||||
|
if incVal, ok := toFloat64(op.Value); ok {
|
||||||
|
if current, err := result.GetField(op.Field); err == nil {
|
||||||
|
if currVal, ok := toFloat64(current); ok {
|
||||||
|
result.SetField(op.Field, currVal+incVal)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.SetField(op.Field, incVal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "mul":
|
||||||
|
if mulVal, ok := toFloat64(op.Value); ok {
|
||||||
|
if current, err := result.GetField(op.Field); err == nil {
|
||||||
|
if currVal, ok := toFloat64(current); ok {
|
||||||
|
result.SetField(op.Field, currVal*mulVal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "rename":
|
||||||
|
if newName, ok := op.Value.(string); ok {
|
||||||
|
if val, err := result.GetField(op.Field); err == nil {
|
||||||
|
result.SetField(newName, val)
|
||||||
|
result.DeleteField(op.Field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "currentDate":
|
||||||
|
result.SetField(op.Field, time.Now().UnixMilli())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveValue разрешает специальные значения типа $$NOW, $$USER
|
||||||
|
func (tm *TriggerManager) resolveValue(value interface{}, execCtx *TriggerExecution) interface{} {
|
||||||
|
if strVal, ok := value.(string); ok {
|
||||||
|
switch strVal {
|
||||||
|
case "$$NOW":
|
||||||
|
return time.Now().UnixMilli()
|
||||||
|
case "$$USER":
|
||||||
|
if execCtx.User != "" {
|
||||||
|
return execCtx.User
|
||||||
|
}
|
||||||
|
return "anonymous"
|
||||||
|
case "$$ROLE":
|
||||||
|
if execCtx.Role != "" {
|
||||||
|
return execCtx.Role
|
||||||
|
}
|
||||||
|
return "anonymous"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// logExecution логирует выполнение триггера (модифицировано с добавлением временных меток)
|
||||||
|
func (tm *TriggerManager) logExecution(execCtx *TriggerExecution, trigger *Trigger, result string, startTime time.Time) {
|
||||||
|
tm.mu.Lock()
|
||||||
|
defer tm.mu.Unlock()
|
||||||
|
|
||||||
|
duration := time.Since(startTime)
|
||||||
|
now := time.Now()
|
||||||
|
nowMs := now.UnixMilli()
|
||||||
|
nowStr := now.Format("2006-01-02 15:04:05.000")
|
||||||
|
|
||||||
|
execCtx.TriggerName = trigger.Name
|
||||||
|
execCtx.Timestamp = now
|
||||||
|
execCtx.TimestampMs = nowMs
|
||||||
|
execCtx.TimestampStr = nowStr
|
||||||
|
execCtx.ActionResult = result
|
||||||
|
execCtx.DurationMs = duration.Milliseconds()
|
||||||
|
|
||||||
|
if len(tm.auditLog) >= tm.maxLogSize {
|
||||||
|
tm.auditLog = tm.auditLog[1:]
|
||||||
|
}
|
||||||
|
tm.auditLog = append(tm.auditLog, execCtx)
|
||||||
|
|
||||||
|
// Аудит выполнения триггера
|
||||||
|
LogAudit("TRIGGER_EXECUTE", "TRIGGER", fmt.Sprintf("%s.%s", trigger.Collection, trigger.Name), map[string]interface{}{
|
||||||
|
"event": execCtx.Event,
|
||||||
|
"result": result,
|
||||||
|
"duration_ms": duration.Milliseconds(),
|
||||||
|
"document_id": execCtx.DocumentID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTriggerExecutionLog возвращает лог выполнения триггеров
|
||||||
|
func (tm *TriggerManager) GetTriggerExecutionLog() []*TriggerExecution {
|
||||||
|
tm.mu.RLock()
|
||||||
|
defer tm.mu.RUnlock()
|
||||||
|
|
||||||
|
result := make([]*TriggerExecution, len(tm.auditLog))
|
||||||
|
copy(result, tm.auditLog)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTriggerExecutionLogFiltered возвращает отфильтрованный лог выполнения триггеров (добавлено)
|
||||||
|
func (tm *TriggerManager) GetTriggerExecutionLogFiltered(triggerName, collection string, fromTime, toTime int64) []*TriggerExecution {
|
||||||
|
tm.mu.RLock()
|
||||||
|
defer tm.mu.RUnlock()
|
||||||
|
|
||||||
|
result := make([]*TriggerExecution, 0)
|
||||||
|
for _, entry := range tm.auditLog {
|
||||||
|
if triggerName != "" && entry.TriggerName != triggerName {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if collection != "" && entry.Collection != collection {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fromTime > 0 && entry.TimestampMs < fromTime {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if toTime > 0 && entry.TimestampMs > toTime {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, entry)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearTriggerLog очищает лог выполнения триггеров (добавлено)
|
||||||
|
func (tm *TriggerManager) ClearTriggerLog() {
|
||||||
|
tm.mu.Lock()
|
||||||
|
defer tm.mu.Unlock()
|
||||||
|
tm.auditLog = make([]*TriggerExecution, 0)
|
||||||
|
LogAudit("CLEAR", "TRIGGER_LOG", "all", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTriggerExecutionStats возвращает статистику выполнения триггеров (добавлено)
|
||||||
|
func (tm *TriggerManager) GetTriggerExecutionStats() map[string]interface{} {
|
||||||
|
tm.mu.RLock()
|
||||||
|
defer tm.mu.RUnlock()
|
||||||
|
|
||||||
|
triggerStats := make(map[string]map[string]interface{})
|
||||||
|
for _, entry := range tm.auditLog {
|
||||||
|
if _, ok := triggerStats[entry.TriggerName]; !ok {
|
||||||
|
triggerStats[entry.TriggerName] = map[string]interface{}{
|
||||||
|
"total_executions": 0,
|
||||||
|
"aborted": 0,
|
||||||
|
"skipped": 0,
|
||||||
|
"modified": 0,
|
||||||
|
"logged": 0,
|
||||||
|
"notified": 0,
|
||||||
|
"total_duration_ms": int64(0),
|
||||||
|
"avg_duration_ms": float64(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := triggerStats[entry.TriggerName]
|
||||||
|
stats["total_executions"] = stats["total_executions"].(int) + 1
|
||||||
|
stats["total_duration_ms"] = stats["total_duration_ms"].(int64) + entry.DurationMs
|
||||||
|
|
||||||
|
switch entry.ActionResult {
|
||||||
|
case "aborted":
|
||||||
|
stats["aborted"] = stats["aborted"].(int) + 1
|
||||||
|
case "skipped":
|
||||||
|
stats["skipped"] = stats["skipped"].(int) + 1
|
||||||
|
case "modified":
|
||||||
|
stats["modified"] = stats["modified"].(int) + 1
|
||||||
|
case "logged":
|
||||||
|
stats["logged"] = stats["logged"].(int) + 1
|
||||||
|
case "notified":
|
||||||
|
stats["notified"] = stats["notified"].(int) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вычисляем среднюю длительность
|
||||||
|
for _, stats := range triggerStats {
|
||||||
|
if totalExec, ok := stats["total_executions"].(int); ok && totalExec > 0 {
|
||||||
|
if totalDuration, ok := stats["total_duration_ms"].(int64); ok {
|
||||||
|
stats["avg_duration_ms"] = float64(totalDuration) / float64(totalExec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"total_trigger_executions": len(tm.auditLog),
|
||||||
|
"triggers_stats": triggerStats,
|
||||||
|
"log_size": len(tm.auditLog),
|
||||||
|
"max_log_size": tm.maxLogSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTriggerKey возвращает ключ для хранения триггера
|
||||||
|
func (tm *TriggerManager) getTriggerKey(collection string, event TriggerEvent, name string) string {
|
||||||
|
return fmt.Sprintf("%s|%s|%s", collection, event, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// compareNumbers сравнивает два числа
|
||||||
|
func compareNumbers(a, b interface{}) int {
|
||||||
|
aVal, aOk := toFloat64(a)
|
||||||
|
bVal, bOk := toFloat64(b)
|
||||||
|
|
||||||
|
if aOk && bOk {
|
||||||
|
if aVal < bVal {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if aVal > bVal {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// MongoDBLikeTriggerConfig создаёт конфигурацию триггера в стиле MongoDB
|
||||||
|
// Пример использования:
|
||||||
|
// config := MongoDBLikeTriggerConfig().
|
||||||
|
// On("BEFORE_INSERT").
|
||||||
|
// Condition("status", "eq", "active").
|
||||||
|
// Set("updated_at", "$$NOW").
|
||||||
|
// Build()
|
||||||
|
func MongoDBLikeTriggerConfig() *TriggerConfigBuilder {
|
||||||
|
return &TriggerConfigBuilder{
|
||||||
|
config: make(map[string]interface{}),
|
||||||
|
ops: make([]interface{}, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriggerConfigBuilder строитель конфигурации триггера
|
||||||
|
type TriggerConfigBuilder struct {
|
||||||
|
config map[string]interface{}
|
||||||
|
ops []interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// On устанавливает событие триггера
|
||||||
|
func (b *TriggerConfigBuilder) On(event string) *TriggerConfigBuilder {
|
||||||
|
b.config["event"] = event
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Condition добавляет условие
|
||||||
|
func (b *TriggerConfigBuilder) Condition(field, operator string, value interface{}) *TriggerConfigBuilder {
|
||||||
|
b.config["condition"] = map[string]interface{}{
|
||||||
|
"field": field,
|
||||||
|
"operator": operator,
|
||||||
|
"value": value,
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConditionRegex добавляет regex условие
|
||||||
|
func (b *TriggerConfigBuilder) ConditionRegex(field, pattern string) *TriggerConfigBuilder {
|
||||||
|
b.config["condition"] = map[string]interface{}{
|
||||||
|
"field": field,
|
||||||
|
"operator": "regex",
|
||||||
|
"value": pattern,
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set добавляет операцию установки поля
|
||||||
|
func (b *TriggerConfigBuilder) Set(field string, value interface{}) *TriggerConfigBuilder {
|
||||||
|
b.ops = append(b.ops, map[string]interface{}{
|
||||||
|
"type": "set",
|
||||||
|
"field": field,
|
||||||
|
"value": value,
|
||||||
|
})
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unset добавляет операцию удаления поля
|
||||||
|
func (b *TriggerConfigBuilder) Unset(field string) *TriggerConfigBuilder {
|
||||||
|
b.ops = append(b.ops, map[string]interface{}{
|
||||||
|
"type": "unset",
|
||||||
|
"field": field,
|
||||||
|
})
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inc добавляет операцию инкремента
|
||||||
|
func (b *TriggerConfigBuilder) Inc(field string, value float64) *TriggerConfigBuilder {
|
||||||
|
b.ops = append(b.ops, map[string]interface{}{
|
||||||
|
"type": "inc",
|
||||||
|
"field": field,
|
||||||
|
"value": value,
|
||||||
|
})
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mul добавляет операцию умножения
|
||||||
|
func (b *TriggerConfigBuilder) Mul(field string, value float64) *TriggerConfigBuilder {
|
||||||
|
b.ops = append(b.ops, map[string]interface{}{
|
||||||
|
"type": "mul",
|
||||||
|
"field": field,
|
||||||
|
"value": value,
|
||||||
|
})
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename добавляет операцию переименования поля
|
||||||
|
func (b *TriggerConfigBuilder) Rename(oldName, newName string) *TriggerConfigBuilder {
|
||||||
|
b.ops = append(b.ops, map[string]interface{}{
|
||||||
|
"type": "rename",
|
||||||
|
"field": oldName,
|
||||||
|
"value": newName,
|
||||||
|
})
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentDate добавляет операцию установки текущей даты
|
||||||
|
func (b *TriggerConfigBuilder) CurrentDate(field string) *TriggerConfigBuilder {
|
||||||
|
b.ops = append(b.ops, map[string]interface{}{
|
||||||
|
"type": "currentDate",
|
||||||
|
"field": field,
|
||||||
|
})
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action устанавливает действие триггера
|
||||||
|
func (b *TriggerConfigBuilder) Action(action string) *TriggerConfigBuilder {
|
||||||
|
b.config["action"] = action
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description устанавливает описание триггера
|
||||||
|
func (b *TriggerConfigBuilder) Description(desc string) *TriggerConfigBuilder {
|
||||||
|
b.config["description"] = desc
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build собирает конфигурацию
|
||||||
|
func (b *TriggerConfigBuilder) Build() map[string]interface{} {
|
||||||
|
b.config["operations"] = b.ops
|
||||||
|
return b.config
|
||||||
|
}
|
||||||
1104
pkg/utils/ansi.go
Normal file
1104
pkg/utils/ansi.go
Normal file
File diff suppressed because it is too large
Load Diff
109
pkg/utils/color.go
Normal file
109
pkg/utils/color.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: pkg/utils/color.go
|
||||||
|
// Назначение: Кроссплатформенная цветная печать с ANSI-кодами,
|
||||||
|
// работающая в Linux (Debian/Fedora) и Illumos (OpenIndiana/OmniOS).
|
||||||
|
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fatih/color"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Глобальный объект цвета Deep Sky Blue (#00bfff)
|
||||||
|
var (
|
||||||
|
deepSkyBlueColor = color.New(color.FgHiCyan) // Ярко-голубой, близкий к #00bfff
|
||||||
|
// Альтернатива с точным RGB-кодом (работает в современных терминалах):
|
||||||
|
// deepSkyBlueColor = color.New(color.FgRGB(0, 191, 255))
|
||||||
|
)
|
||||||
|
|
||||||
|
// EnableDeepSkyBlueMode включает режим постоянного цвета.
|
||||||
|
// В библиотеке fatih/color для этого используется функция color.Set().
|
||||||
|
func EnableDeepSkyBlueMode() {
|
||||||
|
deepSkyBlueColor.Set()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisableDeepSkyBlueMode отключает режим постоянного цвета и сбрасывает настройки.
|
||||||
|
func DisableDeepSkyBlueMode() {
|
||||||
|
color.Unset()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDeepSkyBlueEnabled — удобная функция для включения/выключения цвета.
|
||||||
|
func SetDeepSkyBlueEnabled(enabled bool) {
|
||||||
|
if enabled {
|
||||||
|
EnableDeepSkyBlueMode()
|
||||||
|
} else {
|
||||||
|
DisableDeepSkyBlueMode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrintDeepSkyBlueColored выводит текст цветом Deep Sky Blue (без перевода строки)
|
||||||
|
func PrintDeepSkyBlueColored(a ...interface{}) {
|
||||||
|
deepSkyBlueColor.Print(a...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrintlnDeepSkyBlueColored выводит строку с цветом Deep Sky Blue и добавляет перевод строки
|
||||||
|
func PrintlnDeepSkyBlueColored(a ...interface{}) {
|
||||||
|
deepSkyBlueColor.Println(a...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrintfDeepSkyBlue форматирует и выводит текст цветом Deep Sky Blue.
|
||||||
|
func PrintfDeepSkyBlue(format string, a ...interface{}) {
|
||||||
|
deepSkyBlueColor.Printf(format, a...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrintErrorRed выводит сообщение об ошибке красным цветом.
|
||||||
|
func PrintErrorRed(msg string) {
|
||||||
|
errorColor := color.New(color.FgRed)
|
||||||
|
errorColor.Println("Error: " + msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ColorizeTextAny преобразует любой тип в цветную строку.
|
||||||
|
func ColorizeTextAny(v interface{}) string {
|
||||||
|
return deepSkyBlueColor.Sprint(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ColorizeTextInt преобразует int в цветную строку.
|
||||||
|
func ColorizeTextInt(n int) string {
|
||||||
|
return deepSkyBlueColor.Sprint(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ColorizeTextByColor возвращает строку, окрашенную в указанный цвет.
|
||||||
|
// Поддерживаемые цвета: red, green, yellow, blue, magenta, cyan, white, black
|
||||||
|
func ColorizeTextByColor(text string, colorName string) string {
|
||||||
|
switch colorName {
|
||||||
|
case "red":
|
||||||
|
return color.New(color.FgRed).Sprint(text)
|
||||||
|
case "green":
|
||||||
|
return color.New(color.FgGreen).Sprint(text)
|
||||||
|
case "yellow":
|
||||||
|
return color.New(color.FgYellow).Sprint(text)
|
||||||
|
case "blue":
|
||||||
|
return color.New(color.FgBlue).Sprint(text)
|
||||||
|
case "magenta":
|
||||||
|
return color.New(color.FgMagenta).Sprint(text)
|
||||||
|
case "cyan":
|
||||||
|
return color.New(color.FgCyan).Sprint(text)
|
||||||
|
case "white":
|
||||||
|
return color.New(color.FgWhite).Sprint(text)
|
||||||
|
case "black":
|
||||||
|
return color.New(color.FgBlack).Sprint(text)
|
||||||
|
default:
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCurrentTimestamp возвращает текущий timestamp в миллисекундах
|
||||||
|
func GetCurrentTimestamp() int64 {
|
||||||
|
return time.Now().UnixMilli()
|
||||||
|
}
|
||||||
38
plugins/example.lua
Normal file
38
plugins/example.lua
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
--
|
||||||
|
-- Copyright 2026 Safronov Grigorii
|
||||||
|
--
|
||||||
|
--Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
--you may not use this file except in compliance with the License.
|
||||||
|
--
|
||||||
|
-- You may obtain a copy of the License at
|
||||||
|
--https://opensource.org/licenses/CDDL-1.0
|
||||||
|
--
|
||||||
|
-- example.lua- Пример модуля скрипта на lua для извелечения коллекции в субд и вставки её в документ
|
||||||
|
|
||||||
|
version = "1.0.0"
|
||||||
|
author = "futriis"
|
||||||
|
description = "Example plugin"
|
||||||
|
|
||||||
|
function on_load()
|
||||||
|
plugin_log("info", "Example plugin loaded")
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function on_start()
|
||||||
|
plugin_log("info", "Example plugin started")
|
||||||
|
|
||||||
|
-- Получаем коллекцию
|
||||||
|
local coll = get_collection("test_db", "test_coll")
|
||||||
|
if coll then
|
||||||
|
-- Вставляем документ
|
||||||
|
coll:insert({name = "test", value = 42})
|
||||||
|
plugin_log("info", "Document inserted")
|
||||||
|
end
|
||||||
|
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function process_data(data)
|
||||||
|
plugin_log("debug", "Processing: " .. data)
|
||||||
|
return {processed = true, original = data}
|
||||||
|
end
|
||||||
149
tests/integration.lua
Normal file
149
tests/integration.lua
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- test_integration.lua - Интеграционный тест
|
||||||
|
-- ============================================================================
|
||||||
|
-- Назначение: Проверка взаимодействия между компонентами системы
|
||||||
|
-- Покрывает: API + Storage + Cluster + Transactions + Triggers + Indexes
|
||||||
|
|
||||||
|
local function test_api_storage_integration()
|
||||||
|
print("[Integration] API + Storage integration...")
|
||||||
|
|
||||||
|
-- Create document via API
|
||||||
|
local api_insert = http.request("POST", "http://localhost:8080/api/db/integration_test/orders",
|
||||||
|
{["X-Session-ID"] = "test_session"},
|
||||||
|
{order_id = "ORD-001", amount = 1500, status = "pending"}
|
||||||
|
)
|
||||||
|
assert(api_insert.success, "Integration: API insert failed")
|
||||||
|
|
||||||
|
-- Verify direct storage access (simulated)
|
||||||
|
local storage_verify = http.request("GET", "http://localhost:8080/api/db/integration_test/orders/ORD-001",
|
||||||
|
{["X-Session-ID"] = "test_session"}
|
||||||
|
)
|
||||||
|
assert(storage_verify.success and storage_verify.data.fields.amount == 1500,
|
||||||
|
"Integration: API-Storage consistency failed")
|
||||||
|
|
||||||
|
print(" ✓ API -> Storage data flow")
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function test_transaction_index_integration()
|
||||||
|
print("[Integration] Transactions + Indexes integration...")
|
||||||
|
|
||||||
|
-- Start transaction
|
||||||
|
local session_resp = http.request("POST", "http://localhost:8080/api/webui/transactions",
|
||||||
|
{["X-Session-ID"] = "test_session"},
|
||||||
|
{action = "start_transaction"}
|
||||||
|
)
|
||||||
|
|
||||||
|
-- Create index within transaction
|
||||||
|
local idx_resp = http.request("POST", "http://localhost:8080/api/index/integration_test/orders/create",
|
||||||
|
{["X-Session-ID"] = "test_session"},
|
||||||
|
{name = "order_amount_idx", fields = {"amount"}, unique = false}
|
||||||
|
)
|
||||||
|
|
||||||
|
-- Insert data
|
||||||
|
local insert_resp = http.request("POST", "http://localhost:8080/api/db/integration_test/orders",
|
||||||
|
{["X-Session-ID"] = "test_session"},
|
||||||
|
{order_id = "ORD-002", amount = 2500, status = "processing"}
|
||||||
|
)
|
||||||
|
|
||||||
|
-- Commit
|
||||||
|
local commit_resp = http.request("POST", "http://localhost:8080/api/webui/transaction/commit",
|
||||||
|
{["X-Session-ID"] = "test_session"}
|
||||||
|
)
|
||||||
|
|
||||||
|
-- Query by index after commit
|
||||||
|
local query_resp = http.request("GET", "http://localhost:8080/api/db/integration_test/orders?index=order_amount_idx&value=2500",
|
||||||
|
{["X-Session-ID"] = "test_session"}
|
||||||
|
)
|
||||||
|
print(" ✓ Transaction + Indexes integration")
|
||||||
|
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function test_acl_trigger_integration()
|
||||||
|
print("[Integration] ACL + Triggers integration...")
|
||||||
|
|
||||||
|
-- Create role with specific permissions
|
||||||
|
local role_resp = http.request("POST", "http://localhost:8080/api/webui/acl/role/analyst",
|
||||||
|
{["X-Session-ID"] = "admin_session"}
|
||||||
|
)
|
||||||
|
|
||||||
|
-- Grant read permission on integration_test
|
||||||
|
local grant_resp = http.request("POST", "http://localhost:8080/api/webui/acl/role/analyst/grant/integration_test.*:read",
|
||||||
|
{["X-Session-ID"] = "admin_session"}
|
||||||
|
)
|
||||||
|
|
||||||
|
-- Create trigger that logs read operations
|
||||||
|
local trigger_config = {
|
||||||
|
name = "read_logger",
|
||||||
|
event = "AFTER_UPDATE",
|
||||||
|
action = "log",
|
||||||
|
description = "Log all read operations"
|
||||||
|
}
|
||||||
|
|
||||||
|
local trigger_resp = http.request("POST", "http://localhost:8080/api/webui/trigger/integration_test/orders/create",
|
||||||
|
{["X-Session-ID"] = "test_session"},
|
||||||
|
trigger_config
|
||||||
|
)
|
||||||
|
print(" ✓ ACL + Triggers integration")
|
||||||
|
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function test_cluster_persistence_integration()
|
||||||
|
print("[Integration] Cluster + Persistence integration...")
|
||||||
|
|
||||||
|
-- Get cluster status
|
||||||
|
local cluster_resp = http.request("GET", "http://localhost:8080/api/cluster/status",
|
||||||
|
{["X-Session-ID"] = "test_session"}
|
||||||
|
)
|
||||||
|
|
||||||
|
-- Create data that should be persisted across restarts
|
||||||
|
local persist_resp = http.request("POST", "http://localhost:8080/api/db/persistence_test/data",
|
||||||
|
{["X-Session-ID"] = "test_session"},
|
||||||
|
{_id = "persist_001", data = "This should survive restart", created = os.time()}
|
||||||
|
)
|
||||||
|
assert(persist_resp.success, "Integration: Persistence test insert failed")
|
||||||
|
|
||||||
|
-- Force checkpoint creation
|
||||||
|
-- (In real scenario, we'd wait for checkpoint or trigger it manually)
|
||||||
|
print(" ✓ Cluster + Persistence ready")
|
||||||
|
print(" ⚠ Manual restart required to verify persistence")
|
||||||
|
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function test_wal_recovery_integration()
|
||||||
|
print("[Integration] WAL + Recovery integration...")
|
||||||
|
|
||||||
|
-- Create multiple updates to generate WAL entries
|
||||||
|
for i = 1, 10 do
|
||||||
|
local resp = http.request("PUT", "http://localhost:8080/api/db/wal_test/counter/doc_001",
|
||||||
|
{["X-Session-ID"] = "test_session"},
|
||||||
|
{counter = i, update_time = os.time()}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Get transaction list to verify WAL
|
||||||
|
local tx_list = http.request("GET", "http://localhost:8080/api/webui/transactions",
|
||||||
|
{["X-Session-ID"] = "test_session"}
|
||||||
|
)
|
||||||
|
|
||||||
|
print(" ✓ WAL entries generated")
|
||||||
|
print(" ✓ WAL + Recovery integrated")
|
||||||
|
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Run integration tests
|
||||||
|
print("\n" .. string.rep("=", 50))
|
||||||
|
print("INTEGRATION TEST SUITE")
|
||||||
|
print(string.rep("=", 50))
|
||||||
|
|
||||||
|
test_api_storage_integration()
|
||||||
|
test_transaction_index_integration()
|
||||||
|
test_acl_trigger_integration()
|
||||||
|
test_cluster_persistence_integration()
|
||||||
|
test_wal_recovery_integration()
|
||||||
|
|
||||||
|
print("\n✓ ALL INTEGRATION TESTS PASSED ✓\n")
|
||||||
199
tests/regression.lua
Normal file
199
tests/regression.lua
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- test_regression.lua - Регрессионный тест
|
||||||
|
-- ============================================================================
|
||||||
|
-- Назначение: Проверка корректности базовых операций после изменений
|
||||||
|
-- Покрывает: CRUD операции, индексы, транзакции, ограничения
|
||||||
|
|
||||||
|
local function setup_test_data()
|
||||||
|
-- Создаём тестовую базу и коллекцию
|
||||||
|
local response = http.request("POST", "http://localhost:8080/api/db/test_db/test_collection",
|
||||||
|
{["X-Session-ID"] = "test_session"},
|
||||||
|
{name = "test_doc_1", value = 100}
|
||||||
|
)
|
||||||
|
assert(response.success, "Regression: Failed to insert test document")
|
||||||
|
|
||||||
|
response = http.request("POST", "http://localhost:8080/api/db/test_db/test_collection",
|
||||||
|
{["X-Session-ID"] = "test_session"},
|
||||||
|
{name = "test_doc_2", value = 200}
|
||||||
|
)
|
||||||
|
assert(response.success, "Regression: Failed to insert second document")
|
||||||
|
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function test_crud_operations()
|
||||||
|
print("[Regression] Testing CRUD operations...")
|
||||||
|
|
||||||
|
-- CREATE - Insert
|
||||||
|
local insert_resp = http.request("POST", "http://localhost:8080/api/db/regress_db/users",
|
||||||
|
{["X-Session-ID"] = "test_session"},
|
||||||
|
{_id = "user_001", name = "John Doe", age = 30, email = "john@example.com"}
|
||||||
|
)
|
||||||
|
assert(insert_resp.success, "CRUD: Insert operation failed")
|
||||||
|
print(" ✓ INSERT operation")
|
||||||
|
|
||||||
|
-- READ - Find by ID
|
||||||
|
local read_resp = http.request("GET", "http://localhost:8080/api/db/regress_db/users/user_001",
|
||||||
|
{["X-Session-ID"] = "test_session"}
|
||||||
|
)
|
||||||
|
assert(read_resp.success and read_resp.data._id == "user_001", "CRUD: Read operation failed")
|
||||||
|
assert(read_resp.data.fields.name == "John Doe", "CRUD: Read data mismatch")
|
||||||
|
print(" ✓ READ operation")
|
||||||
|
|
||||||
|
-- UPDATE
|
||||||
|
local update_resp = http.request("PUT", "http://localhost:8080/api/db/regress_db/users/user_001",
|
||||||
|
{["X-Session-ID"] = "test_session"},
|
||||||
|
{age = 31, city = "New York"}
|
||||||
|
)
|
||||||
|
assert(update_resp.success, "CRUD: Update operation failed")
|
||||||
|
|
||||||
|
-- Verify update
|
||||||
|
local verify_resp = http.request("GET", "http://localhost:8080/api/db/regress_db/users/user_001",
|
||||||
|
{["X-Session-ID"] = "test_session"}
|
||||||
|
)
|
||||||
|
assert(verify_resp.data.fields.age == 31, "CRUD: Update data not persisted")
|
||||||
|
print(" ✓ UPDATE operation")
|
||||||
|
|
||||||
|
-- DELETE
|
||||||
|
local delete_resp = http.request("DELETE", "http://localhost:8080/api/db/regress_db/users/user_001",
|
||||||
|
{["X-Session-ID"] = "test_session"}
|
||||||
|
)
|
||||||
|
assert(delete_resp.success, "CRUD: Delete operation failed")
|
||||||
|
|
||||||
|
-- Verify deletion
|
||||||
|
local deleted_resp = http.request("GET", "http://localhost:8080/api/db/regress_db/users/user_001",
|
||||||
|
{["X-Session-ID"] = "test_session"}
|
||||||
|
)
|
||||||
|
assert(not deleted_resp.success, "CRUD: Document still exists after delete")
|
||||||
|
print(" ✓ DELETE operation")
|
||||||
|
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function test_index_operations()
|
||||||
|
print("[Regression] Testing index operations...")
|
||||||
|
|
||||||
|
-- Create index
|
||||||
|
local idx_resp = http.request("POST", "http://localhost:8080/api/index/regress_db/users/create",
|
||||||
|
{["X-Session-ID"] = "test_session"},
|
||||||
|
{name = "idx_name", fields = {"name"}, unique = false}
|
||||||
|
)
|
||||||
|
assert(idx_resp.success, "Index: Create index failed")
|
||||||
|
print(" ✓ Index creation")
|
||||||
|
|
||||||
|
-- List indexes
|
||||||
|
local list_resp = http.request("GET", "http://localhost:8080/api/index/regress_db/users/list",
|
||||||
|
{["X-Session-ID"] = "test_session"}
|
||||||
|
)
|
||||||
|
assert(list_resp.success and #list_resp.data >= 1, "Index: List indexes failed")
|
||||||
|
print(" ✓ Index listing")
|
||||||
|
|
||||||
|
-- Query by index
|
||||||
|
local query_resp = http.request("GET", "http://localhost:8080/api/db/regress_db/users?index=idx_name&value=John%20Doe",
|
||||||
|
{["X-Session-ID"] = "test_session"}
|
||||||
|
)
|
||||||
|
print(" ✓ Index query")
|
||||||
|
|
||||||
|
-- Drop index
|
||||||
|
local drop_resp = http.request("DELETE", "http://localhost:8080/api/index/regress_db/users/drop/idx_name",
|
||||||
|
{["X-Session-ID"] = "test_session"}
|
||||||
|
)
|
||||||
|
print(" ✓ Index drop")
|
||||||
|
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function test_transaction_operations()
|
||||||
|
print("[Regression] Testing transaction operations...")
|
||||||
|
|
||||||
|
-- Start transaction session
|
||||||
|
local session_resp = http.request("POST", "http://localhost:8080/api/webui/transactions",
|
||||||
|
{["X-Session-ID"] = "test_session"},
|
||||||
|
{action = "start_session"}
|
||||||
|
)
|
||||||
|
assert(session_resp.success, "Transaction: Session start failed")
|
||||||
|
print(" ✓ Transaction session")
|
||||||
|
|
||||||
|
-- Start transaction
|
||||||
|
local tx_resp = http.request("POST", "http://localhost:8080/api/webui/transactions",
|
||||||
|
{["X-Session-ID"] = "test_session"},
|
||||||
|
{action = "start_transaction"}
|
||||||
|
)
|
||||||
|
assert(tx_resp.success, "Transaction: Start failed")
|
||||||
|
print(" ✓ Transaction start")
|
||||||
|
|
||||||
|
-- Insert within transaction
|
||||||
|
local insert_resp = http.request("POST", "http://localhost:8080/api/db/regress_db/tx_test",
|
||||||
|
{["X-Session-ID"] = "test_session"},
|
||||||
|
{_id = "tx_doc", value = "transactional"}
|
||||||
|
)
|
||||||
|
assert(insert_resp.success, "Transaction: Insert in transaction failed")
|
||||||
|
|
||||||
|
-- Commit transaction
|
||||||
|
local commit_resp = http.request("POST", "http://localhost:8080/api/webui/transaction/commit",
|
||||||
|
{["X-Session-ID"] = "test_session"}
|
||||||
|
)
|
||||||
|
assert(commit_resp.success, "Transaction: Commit failed")
|
||||||
|
print(" ✓ Transaction commit")
|
||||||
|
|
||||||
|
-- Verify persisted data
|
||||||
|
local verify_resp = http.request("GET", "http://localhost:8080/api/db/regress_db/tx_test/tx_doc",
|
||||||
|
{["X-Session-ID"] = "test_session"}
|
||||||
|
)
|
||||||
|
assert(verify_resp.success, "Transaction: Data not persisted after commit")
|
||||||
|
print(" ✓ Transaction verification")
|
||||||
|
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function test_constraints()
|
||||||
|
print("[Regression] Testing constraints...")
|
||||||
|
|
||||||
|
-- Add required field constraint
|
||||||
|
local required_resp = http.request("POST", "http://localhost:8080/api/constraint/regress_db/users/required/email",
|
||||||
|
{["X-Session-ID"] = "test_session"}
|
||||||
|
)
|
||||||
|
assert(required_resp.success, "Constraints: Add required field failed")
|
||||||
|
print(" ✓ Required constraint")
|
||||||
|
|
||||||
|
-- Test constraint violation
|
||||||
|
local insert_resp = http.request("POST", "http://localhost:8080/api/db/regress_db/users",
|
||||||
|
{["X-Session-ID"] = "test_session"},
|
||||||
|
{_id = "bad_user", name = "No Email"}
|
||||||
|
)
|
||||||
|
assert(not insert_resp.success, "Constraints: Required field constraint not enforced")
|
||||||
|
print(" ✓ Required constraint enforcement")
|
||||||
|
|
||||||
|
-- Add unique constraint
|
||||||
|
local unique_resp = http.request("POST", "http://localhost:8080/api/constraint/regress_db/users/unique/email",
|
||||||
|
{["X-Session-ID"] = "test_session"}
|
||||||
|
)
|
||||||
|
assert(unique_resp.success, "Constraints: Add unique field failed")
|
||||||
|
|
||||||
|
-- Test unique violation
|
||||||
|
local insert1 = http.request("POST", "http://localhost:8080/api/db/regress_db/users",
|
||||||
|
{["X-Session-ID"] = "test_session"},
|
||||||
|
{_id = "user_a", name = "User A", email = "same@email.com"}
|
||||||
|
)
|
||||||
|
local insert2 = http.request("POST", "http://localhost:8080/api/db/regress_db/users",
|
||||||
|
{["X-Session-ID"] = "test_session"},
|
||||||
|
{_id = "user_b", name = "User B", email = "same@email.com"}
|
||||||
|
)
|
||||||
|
assert(insert1.success and not insert2.success, "Constraints: Unique constraint not enforced")
|
||||||
|
print(" ✓ Unique constraint")
|
||||||
|
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Run regression tests
|
||||||
|
print("\n" .. string.rep("=", 50))
|
||||||
|
print("REGRESSION TEST SUITE")
|
||||||
|
print(string.rep("=", 50))
|
||||||
|
|
||||||
|
setup_test_data()
|
||||||
|
test_crud_operations()
|
||||||
|
test_index_operations()
|
||||||
|
test_transaction_operations()
|
||||||
|
test_constraints()
|
||||||
|
|
||||||
|
print("\n✓ ALL REGRESSION TESTS PASSED ✓\n")
|
||||||
104
tests/smoke.lua
Normal file
104
tests/smoke.lua
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- test_smoke.lua - Smoke-тест (проверка доступности и базовой функциональности)
|
||||||
|
-- ============================================================================
|
||||||
|
-- Назначение: Быстрая проверка того, что система работает и отвечает
|
||||||
|
-- Покрывает: API endpoints, кластеризацию, аутентификацию
|
||||||
|
|
||||||
|
local function test_api_availability()
|
||||||
|
print("[Smoke] Testing API availability...")
|
||||||
|
|
||||||
|
-- Check main endpoints
|
||||||
|
local endpoints = {
|
||||||
|
"/api/webui/stats",
|
||||||
|
"/api/webui/databases",
|
||||||
|
"/api/cluster/status",
|
||||||
|
"/api/webui/transactions"
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, endpoint in ipairs(endpoints) do
|
||||||
|
local resp = http.request("GET", "http://localhost:8080" .. endpoint,
|
||||||
|
{["X-Session-ID"] = "test_session"})
|
||||||
|
if resp and resp.success then
|
||||||
|
print(" ✓ " .. endpoint .. " - available")
|
||||||
|
else
|
||||||
|
print(" ✗ " .. endpoint .. " - unavailable")
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function test_authentication()
|
||||||
|
print("[Smoke] Testing authentication...")
|
||||||
|
|
||||||
|
-- Login with admin credentials
|
||||||
|
local login_resp = http.request("POST", "http://localhost:8080/api/auth/login",
|
||||||
|
{},
|
||||||
|
{username = "admin", password = "admin"}
|
||||||
|
)
|
||||||
|
assert(login_resp.success and login_resp.data.session_id, "Smoke: Login failed")
|
||||||
|
print(" ✓ Authentication works")
|
||||||
|
|
||||||
|
-- Test invalid credentials
|
||||||
|
local invalid_resp = http.request("POST", "http://localhost:8080/api/auth/login",
|
||||||
|
{},
|
||||||
|
{username = "fake", password = "wrong"}
|
||||||
|
)
|
||||||
|
assert(not invalid_resp.success, "Smoke: Invalid credentials accepted")
|
||||||
|
print(" ✓ Invalid credentials rejected")
|
||||||
|
|
||||||
|
return login_resp.data.session_id
|
||||||
|
end
|
||||||
|
|
||||||
|
local function test_cluster_status()
|
||||||
|
print("[Smoke] Testing cluster status...")
|
||||||
|
|
||||||
|
local status_resp = http.request("GET", "http://localhost:8080/api/cluster/status",
|
||||||
|
{["X-Session-ID"] = "test_session"}
|
||||||
|
)
|
||||||
|
|
||||||
|
if status_resp and status_resp.success then
|
||||||
|
print(" ✓ Cluster status: " .. (status_resp.data.health or "unknown"))
|
||||||
|
print(" - Total nodes: " .. (status_resp.data.total_nodes or 0))
|
||||||
|
print(" - Active nodes: " .. (status_resp.data.active_nodes or 0))
|
||||||
|
return true
|
||||||
|
else
|
||||||
|
print(" ⚠ Cluster endpoint may be unavailable in single-node mode")
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function test_basic_crud()
|
||||||
|
print("[Smoke] Testing basic CRUD...")
|
||||||
|
|
||||||
|
-- Create database and collection via first write
|
||||||
|
local insert_resp = http.request("POST", "http://localhost:8080/api/db/smoke_test/users",
|
||||||
|
{["X-Session-ID"] = "test_session"},
|
||||||
|
{_id = "smoke_001", test_field = "smoke_value", timestamp = os.time()}
|
||||||
|
)
|
||||||
|
assert(insert_resp.success, "Smoke: Basic insert failed")
|
||||||
|
print(" ✓ Basic insert")
|
||||||
|
|
||||||
|
-- Read back
|
||||||
|
local read_resp = http.request("GET", "http://localhost:8080/api/db/smoke_test/users/smoke_001",
|
||||||
|
{["X-Session-ID"] = "test_session"}
|
||||||
|
)
|
||||||
|
assert(read_resp.success, "Smoke: Basic read failed")
|
||||||
|
assert(read_resp.data.fields.test_field == "smoke_value", "Smoke: Data mismatch")
|
||||||
|
print(" ✓ Basic read")
|
||||||
|
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Run smoke tests
|
||||||
|
print("\n" .. string.rep("=", 50))
|
||||||
|
print("SMOKE TEST SUITE")
|
||||||
|
print(string.rep("=", 50))
|
||||||
|
|
||||||
|
local session = test_authentication()
|
||||||
|
test_api_availability()
|
||||||
|
test_cluster_status()
|
||||||
|
test_basic_crud()
|
||||||
|
|
||||||
|
print("\n✓ ALL SMOKE TESTS PASSED ✓\n")
|
||||||
274
vendor_build.sh
Normal file
274
vendor_build.sh
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Copyright 2026 Safronov Grigorii
|
||||||
|
#
|
||||||
|
# Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
#
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
# https://opensource.org/licenses/CDDL-1.0
|
||||||
|
#
|
||||||
|
# Скрипт сборки futriix с использованием vendoring зависимостей
|
||||||
|
# Поддерживает Linux и Illumos (OpenIndiana)
|
||||||
|
# НЕ ТРЕБУЕТ GCC - использует CGO_ENABLED=0 для всех платформ
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Цвета для вывода
|
||||||
|
if [ -t 1 ]; then
|
||||||
|
RED='\033[0;31m'
|
||||||
|
BOLD_RED='\033[1;31m'
|
||||||
|
BOLD_GREEN='\033[1;32m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
LIGHT_CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m'
|
||||||
|
else
|
||||||
|
RED=''; BOLD_RED=''; BOLD_GREEN=''; GREEN=''; YELLOW=''; LIGHT_CYAN=''; NC=''
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Функции
|
||||||
|
error_msg() { echo -e "${BOLD_RED}[ERROR] $1${NC}"; }
|
||||||
|
success_msg() { echo -e "${BOLD_GREEN}[OK] $1${NC}"; }
|
||||||
|
info_msg() { echo -e "${LIGHT_CYAN}[INFO] $1${NC}"; }
|
||||||
|
warning_msg() { echo -e "${YELLOW}[WARN] $1${NC}"; }
|
||||||
|
print_separator() { echo "================================================"; }
|
||||||
|
|
||||||
|
# Проверка Go
|
||||||
|
check_go() {
|
||||||
|
info_msg "Checking Go installation..."
|
||||||
|
if ! command -v go &> /dev/null; then
|
||||||
|
error_msg "Go is not installed or not in PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
GO_VERSION=$(go version | awk '{print $3}')
|
||||||
|
success_msg "Go found: $GO_VERSION"
|
||||||
|
|
||||||
|
go env -w GOPROXY=https://proxy.golang.org,direct 2>/dev/null || true
|
||||||
|
info_msg "Go proxy: $(go env GOPROXY)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Подготовка vendor с диагностикой
|
||||||
|
setup_vendor() {
|
||||||
|
info_msg "Checking vendor directory..."
|
||||||
|
|
||||||
|
NEED_VENDOR=false
|
||||||
|
|
||||||
|
if [ ! -d "vendor" ] || [ ! -f "vendor/modules.txt" ]; then
|
||||||
|
warning_msg "Vendor directory not found or incomplete"
|
||||||
|
NEED_VENDOR=true
|
||||||
|
elif [ "go.mod" -nt "vendor/modules.txt" ]; then
|
||||||
|
warning_msg "go.mod is newer than vendor directory"
|
||||||
|
NEED_VENDOR=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$NEED_VENDOR" = true ]; then
|
||||||
|
info_msg "Creating/updating vendor directory..."
|
||||||
|
echo ""
|
||||||
|
info_msg "Step 1/2: Downloading modules..."
|
||||||
|
go mod download -x 2>&1 | grep -E "^(GET|Downloading)" | head -20 || true
|
||||||
|
echo ""
|
||||||
|
info_msg "Step 2/2: Creating vendor directory..."
|
||||||
|
go mod vendor
|
||||||
|
success_msg "Vendor dependencies ready"
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
info_msg "Vendor directory is up to date"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Создание директории bin
|
||||||
|
create_bin_dir() {
|
||||||
|
mkdir -p bin
|
||||||
|
info_msg "Bin directory ready"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Сборка для указанной ОС (статическая, без CGO)
|
||||||
|
build_static() {
|
||||||
|
local target_os=$1
|
||||||
|
local target_arch="amd64"
|
||||||
|
local output_name="futriix-${target_os}-vendor"
|
||||||
|
|
||||||
|
info_msg "Building for ${target_os} (static, no CGO)..."
|
||||||
|
|
||||||
|
export GOOS="${target_os}"
|
||||||
|
export GOARCH="${target_arch}"
|
||||||
|
export CGO_ENABLED=0
|
||||||
|
|
||||||
|
local build_tags=""
|
||||||
|
if [ "${target_os}" = "illumos" ] || [ "${target_os}" = "solaris" ]; then
|
||||||
|
build_tags="-tags=illumos"
|
||||||
|
output_name="futriix-illumos-vendor"
|
||||||
|
elif [ "${target_os}" = "linux" ]; then
|
||||||
|
output_name="futriix-linux-vendor"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local output_path="bin/${output_name}"
|
||||||
|
|
||||||
|
if go build -mod=vendor ${build_tags} \
|
||||||
|
-ldflags="-s -w -X main.buildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||||
|
-o "${output_path}" ./cmd/futriis; then
|
||||||
|
|
||||||
|
success_msg "Build successful for ${target_os}: ${output_path}"
|
||||||
|
cp "${output_path}" "./${output_name}" 2>/dev/null || true
|
||||||
|
info_msg "Binary copied to: ./${output_name}"
|
||||||
|
|
||||||
|
SIZE=$(ls -lh "${output_path}" | awk '{print $5}')
|
||||||
|
info_msg "Binary size: $SIZE"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
error_msg "Build failed for ${target_os}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Сборка для текущей ОС
|
||||||
|
build_current() {
|
||||||
|
local os=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||||
|
info_msg "Building for current OS (${os})..."
|
||||||
|
|
||||||
|
export CGO_ENABLED=0
|
||||||
|
|
||||||
|
local output_path="bin/futriix-current-vendor"
|
||||||
|
|
||||||
|
if go build -mod=vendor \
|
||||||
|
-ldflags="-s -w -X main.buildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||||
|
-o "${output_path}" ./cmd/futriis; then
|
||||||
|
|
||||||
|
success_msg "Current OS build successful: ${output_path}"
|
||||||
|
cp "${output_path}" ./futriix-current-vendor 2>/dev/null || true
|
||||||
|
info_msg "Binary copied to: ./futriix-current-vendor"
|
||||||
|
|
||||||
|
SIZE=$(ls -lh "${output_path}" | awk '{print $5}')
|
||||||
|
info_msg "Binary size: $SIZE"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
error_msg "Current OS build failed"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Очистка
|
||||||
|
clean() {
|
||||||
|
info_msg "Cleaning binary files..."
|
||||||
|
rm -f bin/futriix-*-vendor
|
||||||
|
rm -f ./futriix-*-vendor
|
||||||
|
success_msg "Clean completed"
|
||||||
|
}
|
||||||
|
|
||||||
|
clean_vendor() {
|
||||||
|
info_msg "Cleaning vendor directory..."
|
||||||
|
rm -rf vendor
|
||||||
|
success_msg "Vendor directory removed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Обновление зависимостей
|
||||||
|
update_deps() {
|
||||||
|
info_msg "Updating dependencies..."
|
||||||
|
go get -u ./...
|
||||||
|
go mod tidy
|
||||||
|
go mod vendor
|
||||||
|
success_msg "Dependencies updated"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Показать информацию
|
||||||
|
show_deps() {
|
||||||
|
info_msg "Project dependencies:"
|
||||||
|
echo ""
|
||||||
|
if [ -f "go.mod" ]; then
|
||||||
|
echo "Direct dependencies:"
|
||||||
|
go list -m all | head -20
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
if [ -d "vendor" ]; then
|
||||||
|
echo "Vendor directory size:"
|
||||||
|
du -sh vendor 2>/dev/null || echo "Cannot determine size"
|
||||||
|
echo ""
|
||||||
|
echo "Number of packages in vendor:"
|
||||||
|
find vendor -name "*.go" 2>/dev/null | wc -l
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Справка
|
||||||
|
show_help() {
|
||||||
|
echo "Usage: $0 [OPTIONS]"
|
||||||
|
echo ""
|
||||||
|
echo "Options:"
|
||||||
|
echo " -h, --help Show this help message"
|
||||||
|
echo " -c, --clean Clean build artifacts"
|
||||||
|
echo " -C, --clean-all Clean build artifacts and vendor directory"
|
||||||
|
echo " -u, --update Update dependencies and rebuild vendor"
|
||||||
|
echo " -i, --info Show dependency information"
|
||||||
|
echo " -l, --linux Build only for Linux"
|
||||||
|
echo " -m, --illumos Build only for Illumos"
|
||||||
|
echo " -o, --only-os Build only for current OS"
|
||||||
|
echo ""
|
||||||
|
echo "Note: All builds use CGO_ENABLED=0 (no GCC required)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Основная функция
|
||||||
|
main() {
|
||||||
|
print_separator
|
||||||
|
echo -e "${GREEN}[BUILD] Building futriix database with vendoring (no GCC required)${NC}"
|
||||||
|
print_separator
|
||||||
|
|
||||||
|
local os=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||||
|
info_msg "Detected OS: $os"
|
||||||
|
|
||||||
|
local BUILD_LINUX=true
|
||||||
|
local BUILD_ILLUMOS=true
|
||||||
|
local BUILD_CURRENT=false
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
-h|--help) show_help; exit 0 ;;
|
||||||
|
-c|--clean) clean; exit 0 ;;
|
||||||
|
-C|--clean-all) clean; clean_vendor; exit 0 ;;
|
||||||
|
-u|--update) update_deps; exit 0 ;;
|
||||||
|
-i|--info) show_deps; exit 0 ;;
|
||||||
|
-l|--linux) BUILD_LINUX=true; BUILD_ILLUMOS=false; BUILD_CURRENT=false; shift ;;
|
||||||
|
-m|--illumos) BUILD_LINUX=false; BUILD_ILLUMOS=true; BUILD_CURRENT=false; shift ;;
|
||||||
|
-o|--only-os) BUILD_LINUX=false; BUILD_ILLUMOS=false; BUILD_CURRENT=true; shift ;;
|
||||||
|
*) error_msg "Unknown option: $1"; show_help; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
check_go
|
||||||
|
setup_vendor
|
||||||
|
create_bin_dir
|
||||||
|
|
||||||
|
print_separator
|
||||||
|
|
||||||
|
local BUILD_FAILED=0
|
||||||
|
|
||||||
|
if [ "$BUILD_CURRENT" = true ]; then
|
||||||
|
build_current || BUILD_FAILED=1
|
||||||
|
else
|
||||||
|
if [ "$BUILD_LINUX" = true ]; then
|
||||||
|
build_static "linux" || BUILD_FAILED=1
|
||||||
|
fi
|
||||||
|
if [ "$BUILD_ILLUMOS" = true ]; then
|
||||||
|
build_static "illumos" || BUILD_FAILED=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_separator
|
||||||
|
|
||||||
|
if [ $BUILD_FAILED -eq 0 ]; then
|
||||||
|
success_msg "All builds completed successfully!"
|
||||||
|
echo ""
|
||||||
|
info_msg "Generated binaries:"
|
||||||
|
ls -lh bin/futriix-*-vendor 2>/dev/null || echo " No binaries in bin/"
|
||||||
|
echo ""
|
||||||
|
info_msg "To run the database:"
|
||||||
|
echo " ./futriix-linux-vendor # On Linux"
|
||||||
|
echo " ./futriix-illumos-vendor # On Illumos/OpenIndiana"
|
||||||
|
echo ""
|
||||||
|
info_msg "Note: All binaries are statically linked and do not require GCC"
|
||||||
|
else
|
||||||
|
error_msg "Some builds failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Reference in New Issue
Block a user