storj.io/minio@v0.0.0-20230509071714-0cbc90f649b1/pkg/event/target/mysql.go (about)

     1  /*
     2   * MinIO Cloud Storage, (C) 2018 MinIO, Inc.
     3   *
     4   * Licensed under the Apache License, Version 2.0 (the "License");
     5   * you may not use this file except in compliance with the License.
     6   * You may obtain a copy of the License at
     7   *
     8   *     http://www.apache.org/licenses/LICENSE-2.0
     9   *
    10   * Unless required by applicable law or agreed to in writing, software
    11   * distributed under the License is distributed on an "AS IS" BASIS,
    12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13   * See the License for the specific language governing permissions and
    14   * limitations under the License.
    15   */
    16  
    17  // MySQL Notifier implementation. Two formats, "namespace" and
    18  // "access" are supported.
    19  //
    20  // * Namespace format
    21  //
    22  // On each create or update object event in MinIO Object storage
    23  // server, a row is created or updated in the table in MySQL. On each
    24  // object removal, the corresponding row is deleted from the table.
    25  //
    26  // A table with a specific structure (column names, column types, and
    27  // primary key/uniqueness constraint) is used. The user may set the
    28  // table name in the configuration. A sample SQL command that creates
    29  // a command with the required structure is:
    30  //
    31  //     CREATE TABLE myminio (
    32  //         key_name VARCHAR(2048),
    33  //         value JSONB,
    34  //         PRIMARY KEY (key_name),
    35  //     );
    36  //
    37  // MySQL's "INSERT ... ON DUPLICATE ..." feature (UPSERT) is used
    38  // here. The implementation has been tested with MySQL Ver 14.14
    39  // Distrib 5.7.17.
    40  //
    41  // * Access format
    42  //
    43  // On each event, a row is appended to the configured table. There is
    44  // no deletion or modification of existing rows.
    45  //
    46  // A different table schema is used for this format. A sample SQL
    47  // commant that creates a table with the required structure is:
    48  //
    49  // CREATE TABLE myminio (
    50  //     event_time TIMESTAMP WITH TIME ZONE NOT NULL,
    51  //     event_data JSONB
    52  // );
    53  
    54  package target
    55  
    56  import (
    57  	"context"
    58  	"database/sql"
    59  	"encoding/json"
    60  	"errors"
    61  	"fmt"
    62  	"net/url"
    63  	"os"
    64  	"path/filepath"
    65  	"strconv"
    66  	"strings"
    67  	"time"
    68  
    69  	"github.com/go-sql-driver/mysql"
    70  
    71  	"storj.io/minio/pkg/event"
    72  	xnet "storj.io/minio/pkg/net"
    73  )
    74  
    75  const (
    76  	mysqlTableExists          = `SELECT 1 FROM %s;`
    77  	mysqlCreateNamespaceTable = `CREATE TABLE %s (key_name VARCHAR(2048), value JSON, PRIMARY KEY (key_name));`
    78  	mysqlCreateAccessTable    = `CREATE TABLE %s (event_time DATETIME NOT NULL, event_data JSON);`
    79  
    80  	mysqlUpdateRow = `INSERT INTO %s (key_name, value) VALUES (?, ?) ON DUPLICATE KEY UPDATE value=VALUES(value);`
    81  	mysqlDeleteRow = `DELETE FROM %s WHERE key_name = ?;`
    82  	mysqlInsertRow = `INSERT INTO %s (event_time, event_data) VALUES (?, ?);`
    83  )
    84  
    85  // MySQL related constants
    86  const (
    87  	MySQLFormat             = "format"
    88  	MySQLDSNString          = "dsn_string"
    89  	MySQLTable              = "table"
    90  	MySQLHost               = "host"
    91  	MySQLPort               = "port"
    92  	MySQLUsername           = "username"
    93  	MySQLPassword           = "password"
    94  	MySQLDatabase           = "database"
    95  	MySQLQueueLimit         = "queue_limit"
    96  	MySQLQueueDir           = "queue_dir"
    97  	MySQLMaxOpenConnections = "max_open_connections"
    98  
    99  	EnvMySQLEnable             = "MINIO_NOTIFY_MYSQL_ENABLE"
   100  	EnvMySQLFormat             = "MINIO_NOTIFY_MYSQL_FORMAT"
   101  	EnvMySQLDSNString          = "MINIO_NOTIFY_MYSQL_DSN_STRING"
   102  	EnvMySQLTable              = "MINIO_NOTIFY_MYSQL_TABLE"
   103  	EnvMySQLHost               = "MINIO_NOTIFY_MYSQL_HOST"
   104  	EnvMySQLPort               = "MINIO_NOTIFY_MYSQL_PORT"
   105  	EnvMySQLUsername           = "MINIO_NOTIFY_MYSQL_USERNAME"
   106  	EnvMySQLPassword           = "MINIO_NOTIFY_MYSQL_PASSWORD"
   107  	EnvMySQLDatabase           = "MINIO_NOTIFY_MYSQL_DATABASE"
   108  	EnvMySQLQueueLimit         = "MINIO_NOTIFY_MYSQL_QUEUE_LIMIT"
   109  	EnvMySQLQueueDir           = "MINIO_NOTIFY_MYSQL_QUEUE_DIR"
   110  	EnvMySQLMaxOpenConnections = "MINIO_NOTIFY_MYSQL_MAX_OPEN_CONNECTIONS"
   111  )
   112  
   113  // MySQLArgs - MySQL target arguments.
   114  type MySQLArgs struct {
   115  	Enable             bool     `json:"enable"`
   116  	Format             string   `json:"format"`
   117  	DSN                string   `json:"dsnString"`
   118  	Table              string   `json:"table"`
   119  	Host               xnet.URL `json:"host"`
   120  	Port               string   `json:"port"`
   121  	User               string   `json:"user"`
   122  	Password           string   `json:"password"`
   123  	Database           string   `json:"database"`
   124  	QueueDir           string   `json:"queueDir"`
   125  	QueueLimit         uint64   `json:"queueLimit"`
   126  	MaxOpenConnections int      `json:"maxOpenConnections"`
   127  }
   128  
   129  // Validate MySQLArgs fields
   130  func (m MySQLArgs) Validate() error {
   131  	if !m.Enable {
   132  		return nil
   133  	}
   134  
   135  	if m.Format != "" {
   136  		f := strings.ToLower(m.Format)
   137  		if f != event.NamespaceFormat && f != event.AccessFormat {
   138  			return fmt.Errorf("unrecognized format")
   139  		}
   140  	}
   141  
   142  	if m.Table == "" {
   143  		return fmt.Errorf("table unspecified")
   144  	}
   145  
   146  	if m.DSN != "" {
   147  		if _, err := mysql.ParseDSN(m.DSN); err != nil {
   148  			return err
   149  		}
   150  	} else {
   151  		// Some fields need to be specified when DSN is unspecified
   152  		if m.Port == "" {
   153  			return fmt.Errorf("unspecified port")
   154  		}
   155  		if _, err := strconv.Atoi(m.Port); err != nil {
   156  			return fmt.Errorf("invalid port")
   157  		}
   158  		if m.Database == "" {
   159  			return fmt.Errorf("database unspecified")
   160  		}
   161  	}
   162  
   163  	if m.QueueDir != "" {
   164  		if !filepath.IsAbs(m.QueueDir) {
   165  			return errors.New("queueDir path should be absolute")
   166  		}
   167  	}
   168  
   169  	if m.MaxOpenConnections < 0 {
   170  		return errors.New("maxOpenConnections cannot be less than zero")
   171  	}
   172  
   173  	return nil
   174  }
   175  
   176  // MySQLTarget - MySQL target.
   177  type MySQLTarget struct {
   178  	id         event.TargetID
   179  	args       MySQLArgs
   180  	updateStmt *sql.Stmt
   181  	deleteStmt *sql.Stmt
   182  	insertStmt *sql.Stmt
   183  	db         *sql.DB
   184  	store      Store
   185  	firstPing  bool
   186  	loggerOnce func(ctx context.Context, err error, id interface{}, errKind ...interface{})
   187  }
   188  
   189  // ID - returns target ID.
   190  func (target *MySQLTarget) ID() event.TargetID {
   191  	return target.id
   192  }
   193  
   194  // HasQueueStore - Checks if the queueStore has been configured for the target
   195  func (target *MySQLTarget) HasQueueStore() bool {
   196  	return target.store != nil
   197  }
   198  
   199  // IsActive - Return true if target is up and active
   200  func (target *MySQLTarget) IsActive() (bool, error) {
   201  	if target.db == nil {
   202  		db, sErr := sql.Open("mysql", target.args.DSN)
   203  		if sErr != nil {
   204  			return false, sErr
   205  		}
   206  		target.db = db
   207  		if target.args.MaxOpenConnections > 0 {
   208  			// Set the maximum connections limit
   209  			target.db.SetMaxOpenConns(target.args.MaxOpenConnections)
   210  		}
   211  	}
   212  	if err := target.db.Ping(); err != nil {
   213  		if IsConnErr(err) {
   214  			return false, errNotConnected
   215  		}
   216  		return false, err
   217  	}
   218  	return true, nil
   219  }
   220  
   221  // Save - saves the events to the store which will be replayed when the SQL connection is active.
   222  func (target *MySQLTarget) Save(eventData event.Event) error {
   223  	if target.store != nil {
   224  		return target.store.Put(eventData)
   225  	}
   226  	_, err := target.IsActive()
   227  	if err != nil {
   228  		return err
   229  	}
   230  	return target.send(eventData)
   231  }
   232  
   233  // send - sends an event to the mysql.
   234  func (target *MySQLTarget) send(eventData event.Event) error {
   235  	if target.args.Format == event.NamespaceFormat {
   236  		objectName, err := url.QueryUnescape(eventData.S3.Object.Key)
   237  		if err != nil {
   238  			return err
   239  		}
   240  		key := eventData.S3.Bucket.Name + "/" + objectName
   241  
   242  		if eventData.EventName == event.ObjectRemovedDelete {
   243  			_, err = target.deleteStmt.Exec(key)
   244  		} else {
   245  			var data []byte
   246  			if data, err = json.Marshal(struct{ Records []event.Event }{[]event.Event{eventData}}); err != nil {
   247  				return err
   248  			}
   249  
   250  			_, err = target.updateStmt.Exec(key, data)
   251  		}
   252  
   253  		return err
   254  	}
   255  
   256  	if target.args.Format == event.AccessFormat {
   257  		eventTime, err := time.Parse(event.AMZTimeFormat, eventData.EventTime)
   258  		if err != nil {
   259  			return err
   260  		}
   261  
   262  		data, err := json.Marshal(struct{ Records []event.Event }{[]event.Event{eventData}})
   263  		if err != nil {
   264  			return err
   265  		}
   266  
   267  		_, err = target.insertStmt.Exec(eventTime, data)
   268  
   269  		return err
   270  	}
   271  
   272  	return nil
   273  }
   274  
   275  // Send - reads an event from store and sends it to MySQL.
   276  func (target *MySQLTarget) Send(eventKey string) error {
   277  
   278  	_, err := target.IsActive()
   279  	if err != nil {
   280  		return err
   281  	}
   282  
   283  	if !target.firstPing {
   284  		if err := target.executeStmts(); err != nil {
   285  			if IsConnErr(err) {
   286  				return errNotConnected
   287  			}
   288  			return err
   289  		}
   290  	}
   291  
   292  	eventData, eErr := target.store.Get(eventKey)
   293  	if eErr != nil {
   294  		// The last event key in a successful batch will be sent in the channel atmost once by the replayEvents()
   295  		// Such events will not exist and wouldve been already been sent successfully.
   296  		if os.IsNotExist(eErr) {
   297  			return nil
   298  		}
   299  		return eErr
   300  	}
   301  
   302  	if err := target.send(eventData); err != nil {
   303  		if IsConnErr(err) {
   304  			return errNotConnected
   305  		}
   306  		return err
   307  	}
   308  
   309  	// Delete the event from store.
   310  	return target.store.Del(eventKey)
   311  }
   312  
   313  // Close - closes underneath connections to MySQL database.
   314  func (target *MySQLTarget) Close() error {
   315  	if target.updateStmt != nil {
   316  		// FIXME: log returned error. ignore time being.
   317  		_ = target.updateStmt.Close()
   318  	}
   319  
   320  	if target.deleteStmt != nil {
   321  		// FIXME: log returned error. ignore time being.
   322  		_ = target.deleteStmt.Close()
   323  	}
   324  
   325  	if target.insertStmt != nil {
   326  		// FIXME: log returned error. ignore time being.
   327  		_ = target.insertStmt.Close()
   328  	}
   329  
   330  	return target.db.Close()
   331  }
   332  
   333  // Executes the table creation statements.
   334  func (target *MySQLTarget) executeStmts() error {
   335  
   336  	_, err := target.db.Exec(fmt.Sprintf(mysqlTableExists, target.args.Table))
   337  	if err != nil {
   338  		createStmt := mysqlCreateNamespaceTable
   339  		if target.args.Format == event.AccessFormat {
   340  			createStmt = mysqlCreateAccessTable
   341  		}
   342  
   343  		if _, dbErr := target.db.Exec(fmt.Sprintf(createStmt, target.args.Table)); dbErr != nil {
   344  			return dbErr
   345  		}
   346  	}
   347  
   348  	switch target.args.Format {
   349  	case event.NamespaceFormat:
   350  		// insert or update statement
   351  		if target.updateStmt, err = target.db.Prepare(fmt.Sprintf(mysqlUpdateRow, target.args.Table)); err != nil {
   352  			return err
   353  		}
   354  		// delete statement
   355  		if target.deleteStmt, err = target.db.Prepare(fmt.Sprintf(mysqlDeleteRow, target.args.Table)); err != nil {
   356  			return err
   357  		}
   358  	case event.AccessFormat:
   359  		// insert statement
   360  		if target.insertStmt, err = target.db.Prepare(fmt.Sprintf(mysqlInsertRow, target.args.Table)); err != nil {
   361  			return err
   362  		}
   363  	}
   364  
   365  	return nil
   366  
   367  }
   368  
   369  // NewMySQLTarget - creates new MySQL target.
   370  func NewMySQLTarget(id string, args MySQLArgs, doneCh <-chan struct{}, loggerOnce func(ctx context.Context, err error, id interface{}, kind ...interface{}), test bool) (*MySQLTarget, error) {
   371  	if args.DSN == "" {
   372  		config := mysql.Config{
   373  			User:                 args.User,
   374  			Passwd:               args.Password,
   375  			Net:                  "tcp",
   376  			Addr:                 args.Host.String() + ":" + args.Port,
   377  			DBName:               args.Database,
   378  			AllowNativePasswords: true,
   379  			CheckConnLiveness:    true,
   380  		}
   381  
   382  		args.DSN = config.FormatDSN()
   383  	}
   384  
   385  	target := &MySQLTarget{
   386  		id:         event.TargetID{ID: id, Name: "mysql"},
   387  		args:       args,
   388  		firstPing:  false,
   389  		loggerOnce: loggerOnce,
   390  	}
   391  
   392  	db, err := sql.Open("mysql", args.DSN)
   393  	if err != nil {
   394  		target.loggerOnce(context.Background(), err, target.ID())
   395  		return target, err
   396  	}
   397  	target.db = db
   398  
   399  	if args.MaxOpenConnections > 0 {
   400  		// Set the maximum connections limit
   401  		target.db.SetMaxOpenConns(args.MaxOpenConnections)
   402  	}
   403  
   404  	var store Store
   405  
   406  	if args.QueueDir != "" {
   407  		queueDir := filepath.Join(args.QueueDir, storePrefix+"-mysql-"+id)
   408  		store = NewQueueStore(queueDir, args.QueueLimit)
   409  		if oErr := store.Open(); oErr != nil {
   410  			target.loggerOnce(context.Background(), oErr, target.ID())
   411  			return target, oErr
   412  		}
   413  		target.store = store
   414  	}
   415  
   416  	err = target.db.Ping()
   417  	if err != nil {
   418  		if target.store == nil || !(IsConnRefusedErr(err) || IsConnResetErr(err)) {
   419  			target.loggerOnce(context.Background(), err, target.ID())
   420  			return target, err
   421  		}
   422  	} else {
   423  		if err = target.executeStmts(); err != nil {
   424  			target.loggerOnce(context.Background(), err, target.ID())
   425  			return target, err
   426  		}
   427  		target.firstPing = true
   428  	}
   429  
   430  	if target.store != nil && !test {
   431  		// Replays the events from the store.
   432  		eventKeyCh := replayEvents(target.store, doneCh, target.loggerOnce, target.ID())
   433  		// Start replaying events from the store.
   434  		go sendEvents(target, eventKeyCh, doneCh, target.loggerOnce)
   435  	}
   436  
   437  	return target, nil
   438  }